Quick Start
Quick Start
Get a working API from your PostgreSQL database in a few minutes — and call it from a plain web page. Your database is your backend: no framework, no boilerplate, no JavaScript library. We build one “Hello World” step by step: a function with a parameter, a real-time notification and a generated file.
All code of this guide is available as a finished example:
hello_world.sql (database) and
index.html (web page).
Each endpoint is described in detail on its own reference page: JSON-RPC,
Real-time Notifications (SSE) and File Download.
1. Install
Download a binary
# macOS (Homebrew)
brew install heptau/tap/pgarachne
# or download the latest release for your OS:
# https://github.com/heptau/pgarachne/releases/latest2. Set Up Your Database
Create a database, the service role PgArachne connects as, and load the PgArachne schema
(sql/schema.sql is in the repository):
createdb my_database
psql -d my_database -c "CREATE ROLE pgarachne LOGIN PASSWORD 'pgarachne_password'"
psql -d my_database -f sql/schema.sql3. Configure & Start
Create a pgarachne.env file (the server finds it in the current directory).
STATIC_FILES_PATH lets PgArachne serve the web page you create in step 5 as well, so the page and
the API share one origin and no CORS setup is needed:
DB_HOST=localhost
DB_PORT=5432
DB_USER=pgarachne
DB_SSLMODE=disable # local PostgreSQL without TLS only
STATIC_FILES_PATH=/path/to/web # folder with your index.html
# Optional: enables JWT sessions (POST /db//token); generate with openssl rand -hex 32
# JWT_SECRET=… Start the server (the service role’s password is read from PGPASSWORD or ~/.pgpass):
PGPASSWORD=pgarachne_password ./pgarachne4. Hello World — a Function with a Parameter
Every API method is a PostgreSQL function with a single jsonb argument. Create a role for the web page and
the function; the JSON object a client sends as params arrives as payload:
CREATE ROLE demo_user WITH LOGIN PASSWORD 'user_password';
GRANT USAGE ON SCHEMA api TO demo_user;
CREATE OR REPLACE FUNCTION api.hello_world(payload jsonb DEFAULT '{}'::jsonb)
RETURNS json LANGUAGE sql AS $$
SELECT json_build_object(
'message', 'Hello, ' || COALESCE(payload->>'name', 'World') || '!');
$$;
GRANT EXECUTE ON FUNCTION api.hello_world(jsonb) TO demo_user;Access is controlled by PostgreSQL itself: demo_user can call exactly the functions it was granted
EXECUTE on — nothing else is reachable.
5. Call It — from curl and from a Web Page
Your API endpoint is live. The user name and password of the PostgreSQL role are sent with HTTP Basic authentication:
curl -X POST http://localhost:8080/db/my_database/jsonrpc \
-u demo_user:user_password \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"api.hello_world","params":{"name":"Alice"},"id":1}'
# → {"jsonrpc":"2.0","result":{"message":"Hello, Alice!"},"id":1}The same call from a browser, with the built-in fetch(). Save this as index.html in the folder
from STATIC_FILES_PATH and open http://localhost:8080/:
<input id="name" value="Alice">
<button id="hello">Say hello</button>
<pre id="out"></pre>
<script>
// HTTP Basic header: "user:password" in base64 (TextEncoder handles non-ASCII)
const bytes = new TextEncoder().encode("demo_user:user_password");
const auth = "Basic " + btoa(String.fromCharCode(...bytes));
const base = location.origin + "/db/my_database"; // /{API_PREFIX}/{database}
async function rpc(method, params) {
const res = await fetch(base + "/jsonrpc", {
method: "POST",
headers: { "Authorization": auth, "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method, params, id: 1 }),
});
const body = await res.json();
if (!res.ok || body.error) throw new Error(body.error.message);
return body.result;
}
document.getElementById("hello").onclick = async () => {
const result = await rpc("api.hello_world", { name: document.getElementById("name").value });
document.getElementById("out").textContent = result.message; // Hello, Alice!
};
</script>6. Real-time — Let the Database Push a Message (SSE)
PostgreSQL can publish messages with pg_notify; PgArachne forwards them to browsers as Server-Sent Events.
Extend the function: when the caller passes "notify": true, it also publishes the greeting
on the channel hello:
CREATE OR REPLACE FUNCTION api.hello_world(payload jsonb DEFAULT '{}'::jsonb)
RETURNS json LANGUAGE plpgsql AS $$
DECLARE
greeting text := 'Hello, ' || COALESCE(payload->>'name', 'World') || '!';
BEGIN
IF COALESCE((payload->>'notify')::boolean, false) THEN
PERFORM pg_notify('hello', json_build_object('message', greeting)::text);
END IF;
RETURN json_build_object('message', greeting);
END; $$;In the page, subscribe first, then call the function. The built-in EventSource cannot send an
Authorization header, so the stream is read with fetch() (about 15 lines):
async function listen(channels, onMessage) {
const res = await fetch(base + "/sse?channels=" + encodeURIComponent(channels), {
headers: { "Authorization": auth },
});
if (!res.ok) throw new Error((await res.json()).error);
const reader = res.body.getReader(), decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let end;
while ((end = buffer.indexOf("\n\n")) >= 0) { // an event ends with a blank line
const event = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
for (const line of event.split("\n"))
if (line.startsWith("data: ")) onMessage(JSON.parse(line.slice(6)));
}
}
}
listen("hello", (m) => console.log("event:", m.data.message)); // m = {channel, data}
rpc("api.hello_world", { name: "Alice", notify: true }); // the event arrives immediatelyNOTIFY is transactional — PostgreSQL delivers it when the function’s
transaction commits, i.e. just as the call returns. Subscribe before you call: PostgreSQL does not keep
notifications for listeners that are not connected yet. SSE channels are not role-scoped
(see Real-time Notifications).7. Download a File
A function that returns rows (path, content, mime_type, store_only) can be downloaded through the
/file endpoint — as raw bytes, without base64 inside JSON. This one returns hello-world.md:
CREATE OR REPLACE FUNCTION api.hello_world_file(payload jsonb DEFAULT '{}'::jsonb)
RETURNS TABLE (path text, content bytea, mime_type text, store_only boolean)
LANGUAGE sql AS $$
SELECT 'hello-world.md',
convert_to('# Hello, ' || COALESCE(payload->>'name', 'World') || E'!\n\nGenerated by PostgreSQL at ' || now() || E'.\n', 'UTF8'),
'text/markdown',
false;
$$;
GRANT EXECUTE ON FUNCTION api.hello_world_file(jsonb) TO demo_user;In the page you can show the content or offer it as a download (return more rows and you get a ZIP automatically):
async function getFile() {
const res = await fetch(base + "/file", {
method: "POST",
headers: { "Authorization": auth, "Content-Type": "application/json" },
body: JSON.stringify({ method: "api.hello_world_file", params: { name: "Alice" } }),
});
if (!res.ok) throw new Error((await res.json()).error.message); // errors are JSON, never binary
return res.blob();
}
// show it:
console.log(await (await getFile()).text());
// or download it:
const url = URL.createObjectURL(await getFile());
const a = Object.assign(document.createElement("a"), { href: url, download: "hello-world.md" });
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);Try it with curl: curl -u demo_user:user_password -H "Content-Type: application/json" -d '{"method":"api.hello_world_file"}' http://localhost:8080/db/my_database/file -OJ.
More in File Download.
Before you go to production
- Use HTTPS. Basic credentials travel with every request; see Deployment & HTTPS. Change the demo passwords.
- Keep credentials in memory only (a JavaScript variable), never in
localStorage. To avoid resending the password, setJWT_SECRETand request a token fromPOST /db/my_database/token(see Security & Authentication). - Web page on another origin (a CDN, another port)? Set
ALLOWED_ORIGINS=https://app.example.com; cross-origin requests are blocked by default. - Least privilege: one PostgreSQL role per kind of user,
EXECUTEonly on the functions they need, Row-Level Security for per-user data, andALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLICso new functions are not exposed by accident.
8. Connect an AI Agent (MCP)
Expose the same functions to Claude Desktop or Cursor as tools via the MCP endpoint:
POST http://localhost:8080/db/my_database/mcpSee MCP (Model Context Protocol) for full connection instructions.
9. Get an OpenAPI Spec (Swagger, Postman, codegen)
Every exposed function is also described as an OpenAPI 3.1 document, filtered to what the authenticated caller may call:
curl http://localhost:8080/db/my_database/openapi.json \
-u demo_user:user_password
# or as YAML: .../openapi.yamlImport the URL directly into Swagger UI, Postman, or an OpenAPI code generator. Every method still runs through the single JSON-RPC endpoint from step 5 — see Architectural Decisions for why the spec lists a per-method path without a matching real route.
That’s it. Read the JSON-RPC reference for the request format, writing functions, errors and idempotency, or the comparison with PostgREST to understand where PgArachne fits.