Skip to content

API: RPC and REST

Every client — web, desktop, mobile, scripts, the Python service — uses the same HTTP API (apps/api, Hono on Bun). Procedures are declared contract-first in packages/api-contract (oRPC + Zod) and implemented once in apps/api/src/rpc/. The same router is exposed over two transports.

Path Purpose
GET /healthz Liveness: the process answers HTTP. For restarts (the compose healthcheck)
GET /readyz Readiness: the database answers too, within 2 s. For routing traffic (Caddy, a load balancer). 503 while Postgres is unreachable — it flaps on purpose, so nothing restarts the API over it
/api/auth/* Better Auth — sign-in, sessions, organizations and teams, admin, Stripe webhooks
POST /rpc/* oRPC transport used by the TypeScript clients (@repo/api-client) — rich types, dates stay dates
/api/v1/* The same procedures as plain REST — for Python, curl and third parties
GET /api/v1/openapi.json OpenAPI 3.1 document generated from the contract. Built once per process and served with an ETag and Cache-Control: no-cache, so a conditional request (If-None-Match) answers 304
PUT/GET/HEAD /uploads/:organizationId/:documentId Document bytes, local storage driver only (mounted when S3_BUCKET is unset). Not a procedure and not session-authenticated: the signed query string from documents.createUpload / documents.downloadUrl is the authorization, exactly like a presigned S3 URL. With a bucket configured these routes do not exist and the browser talks to the bucket

The interactive reference renders the OpenAPI document with Scalar.

import { createApiClient } from '@repo/api-client';
const api = createApiClient({ baseUrl: 'http://localhost:3000' });
const workspaces = await api.workspaces.list({ organizationId });
  • baseUrl is the API origin; the link POSTs to ${baseUrl}/rpc/<path>.
  • Browsers send the session cookie (credentials: 'include'). In SvelteKit load functions pass SvelteKit’s fetch so server-side rendering forwards the browser’s cookies.
  • Cookie-less clients (Tauri shells, CLIs) pass getToken and the client adds Authorization: Bearer ….
  • Errors are typed: safe() and isDefinedError() from @repo/api-client narrow to the codes a procedure declares (UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, LIMIT_REACHED, …).

In the app, $lib/api.ts exports a ready client (api, apiFor(fetch)) and $lib/query.ts derives TanStack Query options from the same contract.

Every procedure has a route in the contract, so it is also a plain endpoint. Examples:

Terminal window
curl "$API_URL/api/v1/health"
curl "$API_URL/api/v1/auth-config"
curl -H "Authorization: Bearer $TOKEN" "$API_URL/api/v1/workspaces?organizationId=$ORG"
curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"organizationId":"…","name":"Docs","slug":"docs"}' "$API_URL/api/v1/workspaces"

Authenticate with the session cookie or with Authorization: Bearer <token>.

bun run api:openapi writes apps/api/openapi.json for generating clients in other languages. Its info.version, GET /api/v1/health, /admin/system and the telemetry resource all read the version from the root package.json — workspace packages are unversioned — so no artefact can disagree with the changelog.

This table is derived from the contract (packages/api-contract/src/index.ts) — one entry per procedure, all 35 of them, with the route each one declares. When you add or change a procedure, change it here too, and regenerate the machine-readable copy with bun run api:openapi (the interactive reference reads the live document and never goes stale).

All REST paths below are relative to /api/v1. “member” means requireMember with the permission named in brackets, resolved from the organization roles in packages/auth/src/permissions.ts (owner, admin, member); where no permission is named, membership alone is enough.

Procedure REST Access Declared errors
system.health GET /health public
system.authConfig GET /auth-config public — { socialProviders, requireEmailVerification }
system.flags GET /flags?organizationId= signed in; a member of organizationId when one is given (else the global defaults) UNAUTHORIZED, FORBIDDEN
contact.send POST /contact public, rate-limited — the marketing site’s contact form PRECONDITION_FAILED (no email provider or no CONTACT_EMAIL)
account.me GET /me signed in UNAUTHORIZED, FORBIDDEN
Procedure REST Access Declared errors
organizations.usage GET /organizations/{organizationId}/usage member UNAUTHORIZED, FORBIDDEN
organizations.auditLog GET /organizations/{organizationId}/audit-log member (auditLog: read — owner, admin) — limit + cursor; every entry carries impersonatedBy, the platform admin behind an impersonated session (else null) UNAUTHORIZED, FORBIDDEN, BAD_REQUEST (bad cursor)
billing.status GET /organizations/{organizationId}/billing member (billing: read) — adds overLimit: [{ kind, current, limit }], the plan limits the organization already exceeds UNAUTHORIZED, FORBIDDEN
workspaces.list GET /workspaces?organizationId= member (workspace: read) UNAUTHORIZED, FORBIDDEN
workspaces.get GET /workspaces/{slug}?organizationId= member (workspace: read) — also returns storage: { usedBytes, limitBytes } NOT_FOUND
workspaces.create POST /workspaces member (workspace: create) CONFLICT (slug taken), NOT_FOUND (team), LIMIT_REACHED
workspaces.update PATCH /workspaces/{id} member (workspace: update) NOT_FOUND, CONFLICT
workspaces.delete DELETE /workspaces/{id} member (workspace: delete — owner, admin) — deletes the workspace’s stored objects too (best effort: a key that survives is a logged leak, not an undeletable workspace) NOT_FOUND

Uploaded straight to storage: createUpload returns a presigned PUT, the browser uploads the bytes, complete measures the stored object with the driver rather than trusting the size the client declared — neither transport bounds the body by itself. Details and the job kinds that process them: Documents, jobs and the AI service.

Procedure REST Access Declared errors
documents.list GET /workspaces/{workspaceId}/documents?organizationId= member (document: read) — { items, nextCursor, storage }, newest first; limit 1–200 (default 50) and cursor. storage describes the whole workspace, not the page NOT_FOUND, BAD_REQUEST (bad cursor)
documents.search POST /workspaces/{workspaceId}/documents/search member (document: read) — semantic search over document.indexed chunks (pgvector). Its own budget: 30 per minute per user, a 10 s timeout, and one AI credit unless the embedding provider is local NOT_FOUND, TOO_MANY_REQUESTS, LIMIT_REACHED (kind: 'aiCredits'), SERVICE_UNAVAILABLE (the AI service could not embed the query)
documents.get GET /documents/{id} member (document: read) NOT_FOUND
documents.createUpload POST /documents member (document: create) — { document, upload } NOT_FOUND, PAYLOAD_TOO_LARGE (data.maxBytes), LIMIT_REACHED (kind: 'storageBytes')
documents.complete POST /documents/{id}/complete member (document: create) — rewrites document.size from what the driver reports NOT_FOUND, PRECONDITION_FAILED (the object never arrived), PAYLOAD_TOO_LARGE (bigger than declared; the object is deleted), LIMIT_REACHED (the quota is re-checked against the real size)
documents.downloadUrl GET /documents/{id}/download-url member (document: read) — short-lived presigned GET NOT_FOUND, PRECONDITION_FAILED (not uploaded yet)
documents.text GET /documents/{id}/text member (document: read) — null until a document.extract job succeeded NOT_FOUND
documents.delete DELETE /documents/{id} member (document: delete) — deletes the stored file too NOT_FOUND

Queued in Postgres, run by the worker, metered in AI credits against the plan (jobCredits in packages/billing/src/catalog.ts).

Procedure REST Access Declared errors
jobs.list GET /organizations/{organizationId}/jobs member (job: read) — filters workspaceId, documentId, status; limit + cursor. result is omitted from list rows unless includeResult=true UNAUTHORIZED, FORBIDDEN, BAD_REQUEST (bad cursor)
jobs.get GET /jobs/{id} member (job: read) NOT_FOUND
jobs.create POST /jobs member (job: create) — charges the kind’s credits NOT_FOUND, PRECONDITION_FAILED (upload unfinished), LIMIT_REACHED (kind: 'aiCredits')
jobs.cancel POST /jobs/{id}/cancel member (job: cancel) — refunds the credits NOT_FOUND, CONFLICT (only queued jobs)
jobs.retry POST /jobs/{id}/retry member (job: create) — queues a fresh job from a failed one and charges the kind’s credits again; the answer is the new job, whose input.retryOf names the original. Audited as job.retry NOT_FOUND, CONFLICT (only failed jobs), LIMIT_REACHED (kind: 'aiCredits')
jobs.stream GET /organizations/{organizationId}/jobs/stream member (job: read) — server-sent events; an async iterator in the typed client. Optional workspaceId narrows it UNAUTHORIZED, FORBIDDEN, TOO_MANY_REQUESTS

jobs.stream re-earns the right to exist every 30 seconds: removing a member, changing their role, revoking their sessions or banning them ends the connection — authorizing once at subscribe time made this the one endpoint where revoking access did not revoke access. No connection lives longer than 15 minutes; the client reconnects and is authorized again. One account may hold five at a time, and a sixth is refused with TOO_MANY_REQUESTS — which SSE delivers as an event: error after the 200 that opened the stream, not as a failed request. Events carry input and result only once a job is finished; while it is queued or running they are either not there yet or repeat what the caller sent.

role = admin on the user (Better Auth’s admin plugin), unrelated to organization roles. A signed-in non-admin gets FORBIDDEN.

Procedure REST Declared errors
admin.organizations.list GET /admin/organizationssearch, limit, offset; { items, total } UNAUTHORIZED, FORBIDDEN
admin.organizations.cancelSubscription POST /admin/organizations/{organizationId}/cancel-subscriptionimmediately defaults to period end NOT_FOUND (no live subscription), PRECONDITION_FAILED (billing not configured)
admin.organizations.delete DELETE /admin/organizations/{organizationId} — body confirmSlug must equal the organization’s slug. Audited before the cascade (which removes that organization’s own log), then deletes its stored objects and lets the FK cascades take members, invitations, teams, workspaces, documents, jobs and the ledger. Answers { organizationId, objectsDeleted, objectsFailed } NOT_FOUND (no such organization, or confirmSlug does not match), PRECONDITION_FAILED (a subscription is still live — cancel it first)
admin.flags.list GET /admin/flags UNAUTHORIZED, FORBIDDEN
admin.flags.upsert PUT /admin/flags/{key} UNAUTHORIZED, FORBIDDEN
admin.flags.delete DELETE /admin/flags/{key} — removes its overrides too NOT_FOUND
admin.flags.setOverride PUT /admin/flags/{key}/organizations/{organizationId}enabled: null removes the override NOT_FOUND
admin.system.status GET /admin/system — version, uptime, counts, database and migrations, service health, configuration UNAUTHORIZED, FORBIDDEN

Organizations, members, invitations, teams, sessions, subscriptions and the admin plugin’s user operations are Better Auth endpoints under /api/auth/*, not procedures — they are documented by Better Auth’s own OpenAPI plugin and are not in /api/v1/openapi.json.

Four procedures page. Three of them use the same opaque cursor: take nextCursor out of an answer and hand it back unchanged as cursor. It is a token, not a timestamp — it encodes the last row’s (created_at, id) pair, because created_at is microsecond timestamptz and a millisecond toISOString() compared against it silently dropped every row inside the truncated millisecond. A value that is not one of our cursors is a BAD_REQUEST rather than a list that quietly restarts at page one.

Procedure Page size How to ask for the next page
documents.list limit 1–200, default 50 cursor ← the previous answer’s nextCursor; null means there is no more. storage describes the whole workspace, not the page
jobs.list limit 1–100, default 25 same cursor / nextCursor pair. includeResult (default false) adds each job’s result — off because a text.embed result holds every vector it produced
organizations.auditLog limit 1–100, default 50 same cursor / nextCursor pair
admin.organizations.list limit 1–100, default 25 offset — this one is offset-paged and also returns total

This changed: documents.list used to return every document in the workspace, and jobs.list and organizations.auditLog used to take a before timestamp. A client still sending before is sending a field the schema does not know.

Both transports answer JSON with code, message and, for the codes that declare one, data. Every procedure can answer the two codes its base builder declares (UNAUTHORIZED 401, FORBIDDEN 403) unless it is public; the table above lists what each one adds.

Code HTTP data When
BAD_REQUEST 400 { issues: [{ path, message }] } Input failed the contract’s Zod schema. Also a cursor that is not one of ours, and any free-text field carrying control characters — the issue then names params.reason = "control_characters". Single-line: document name (≤ 200), workspace name, search query, a flag’s description, the contact form’s name; the contact message keeps tab, LF and CR. One rule instead of sanitising at every sink: a newline in a name reaches an email subject as a second header, and a code point above U+00FF throws where a content-disposition filename is built. A contentType outside DOCUMENT_CONTENT_TYPES lands here as well — there is no 415
UNAUTHORIZED 401 No session (or an expired one)
FORBIDDEN 403 Signed in, but not a member / not permitted / not a platform admin
LIMIT_REACHED 403 { kind: 'workspaces' | 'members' | 'aiCredits' | 'storageBytes', limit, plan } A plan limit; only enforced while billing is configured
NOT_FOUND 404 No such row. A row that exists in another organization answers FORBIDDEN, because by-id procedures derive the tenant from the row they read. admin.organizations.delete also answers it when confirmSlug does not match the organization
CONFLICT 409 A workspace slug already taken (workspaces.create, workspaces.update); a job that is not queued (jobs.cancel) or not failed (jobs.retry)
PRECONDITION_FAILED 412 The deployment is missing configuration (no contact address, billing not set up), or the row is in the wrong state — an upload that never arrived, a download before complete, an organization still being billed
PAYLOAD_TOO_LARGE 413 { maxBytes } createUpload: the declared size is over MAX_UPLOAD_BYTES. complete: the stored object is larger than was declared (capped by MAX_UPLOAD_BYTES), and has been deleted
TOO_MANY_REQUESTS 429 documents.search past 30 per minute per user; a sixth concurrent jobs.stream for one account
SERVICE_UNAVAILABLE 503 services/ai could not be reached, or answered something unusable. Deliberately generic: its own message quotes provider URLs and model names, so that goes to the log and never to a tenant

Custom codes (LIMIT_REACHED) set an explicit status in the contract — oRPC defaults unknown codes to 500.

  1. Declare it in packages/api-contract/src/index.ts with an explicit route (so it is also REST) and its errors; export the input schema if a form will use it.
  2. Implement it in apps/api/src/rpc/requireAuth, requireMember or requireAdmin from rpc/base.ts as appropriate.
  3. Call it from any client: api.<group>.<name>(input). The typed client, the REST endpoint and the OpenAPI document update automatically.
  4. Add its row to the tables above and run bun run api:openapi.

Rules: no endpoint without a contract entry; forms validate the contract’s schema, never a copy; custom error codes set an explicit HTTP status (they default to 500 otherwise).

Better Auth rate-limits /api/auth/* itself in production. An in-process limiter guards /rpc/* and /api/v1/* (300 requests per minute per IP by default) — a guardrail against runaway clients, not a defence against determined abuse; put that at the edge. Both trust x-forwarded-for only as a single value, which is what Caddy produces. CORS allows the origins in TRUSTED_ORIGINS (plus WEB_URL) with credentials, and exposes the set-auth-token header for bearer clients. Every response carries X-Request-Id — the client’s own if it sent a well-formed one, else a fresh UUID — and the same id is on the access-log line and on any error line.

Two procedures carry a budget of their own on top, keyed on the account rather than the IP because a whole office shares one address: documents.search allows 30 per minute, and one account may hold at most five concurrent jobs.stream connections. All three counters — those two and the shared per-IP limiter — are in-process: they are sized for one API container, and running several replicas behind Caddy needs a shared store before any of them means what it says.