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.
RPC: the typed client
Section titled “RPC: the typed client”import { createApiClient } from '@repo/api-client';
const api = createApiClient({ baseUrl: 'http://localhost:3000' });const workspaces = await api.workspaces.list({ organizationId });baseUrlis the API origin; the link POSTs to${baseUrl}/rpc/<path>.- Browsers send the session cookie (
credentials: 'include'). In SvelteKitloadfunctions pass SvelteKit’sfetchso server-side rendering forwards the browser’s cookies. - Cookie-less clients (Tauri shells, CLIs) pass
getTokenand the client addsAuthorization: Bearer …. - Errors are typed:
safe()andisDefinedError()from@repo/api-clientnarrow 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.
REST: the same procedures over HTTP
Section titled “REST: the same procedures over HTTP”Every procedure has a route in the contract, so it is also a plain endpoint. Examples:
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.
Procedures
Section titled “Procedures”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.
System and public
Section titled “System and public”| 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 |
Organizations, billing, workspaces
Section titled “Organizations, billing, workspaces”| 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 |
Documents
Section titled “Documents”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.
Platform admin
Section titled “Platform admin”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/organizations — search, limit, offset; { items, total } |
UNAUTHORIZED, FORBIDDEN |
admin.organizations.cancelSubscription |
POST /admin/organizations/{organizationId}/cancel-subscription — immediately 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.
Paging and cursors
Section titled “Paging and cursors”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.
Errors
Section titled “Errors”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.
Adding a procedure
Section titled “Adding a procedure”- Declare it in
packages/api-contract/src/index.tswith an explicitroute(so it is also REST) and itserrors; export the input schema if a form will use it. - Implement it in
apps/api/src/rpc/—requireAuth,requireMemberorrequireAdminfromrpc/base.tsas appropriate. - Call it from any client:
api.<group>.<name>(input). The typed client, the REST endpoint and the OpenAPI document update automatically. - 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).
Limits and headers
Section titled “Limits and headers”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.