Quick Start

6 min read

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/latest

2. 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.sql

3. 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 ./pgarachne

4. 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 immediately
Good to know: NOTIFY 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, set JWT_SECRET and request a token from POST /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, EXECUTE only on the functions they need, Row-Level Security for per-user data, and ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC so 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/mcp

See 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.yaml

Import 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.