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 the container healthcheck: an API that stops answering shows as unhealthy in docker compose ps and fails a deploy’s wait for healthy. Nothing restarts it; compose restarts a container only when its process exits
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/<key> Document bytes, local storage driver only (mounted when S3_BUCKET is unset). PUT accepts only a staging key; GET/HEAD serve signed document keys. Not a procedure and not session-authenticated: the signature covers the exact full key. 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 ….
  • Every call carries x-csrf-token: orpc (oRPC’s SimpleCsrfProtectionLinkPlugin), and the API refuses an /rpc request without it: 403 with the code CSRF_TOKEN_MISMATCH. The value is not a secret. An HTML form cannot send a header at all, and a page on another origin can send one only after a CORS preflight that WEB_URL and TRUSTED_ORIGINS decide, so the session cookie alone never calls a procedure. A client that talks to /rpc without createApiClient must send the header itself.
  • 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.js exports a ready client (api, apiFor(fetch)), and #lib/query.js 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"

To call the RPC transport by hand instead, POST to /rpc/<path> with the input wrapped as {"json": …} and the header the typed client adds, x-csrf-token: orpc. Without that header the answer is 403 with the code CSRF_TOKEN_MISMATCH, whatever the credentials:

Terminal window
curl -X POST -H "Content-Type: application/json" -H "x-csrf-token: orpc" -d '{}' \
"$API_URL/rpc/system/health"

Authenticate with the session cookie or with Authorization: Bearer <token>. REST asks for no extra header, but a request that carries a cookie, whatever its method, answers 403 { "error": "forbidden_origin" } when a browser sent it from a page outside WEB_URL and TRUSTED_ORIGINS: its Origin is not one of them, or it has no Origin and Sec-Fetch-Site says cross-site or same-site. The session cookie is SameSite=Lax, which keeps it off a write from another site, but not off a link to a GET, nor off anything a page on a sibling subdomain sends; and a GET counts against its caller and can change something, so it is judged too. A link to a REST GET from another site, a webmail or a sibling subdomain therefore answers 403 to a signed-in browser; one typed in the address bar or opened from a bookmark does not, and neither does a link another application opens, such as a desktop mail client, which the browser sends as it does the address bar’s. A GET that changes something is not safe from such links. A request without a cookie (a bearer token, a service) is never affected, and neither is a server-side caller that forwards a cookie without an Origin.

An organization API key is a bearer token too, Authorization: Bearer starterdough_…, over REST and over /rpc (createApiClient({ baseUrl, getToken: () => key })). It is taken only by the procedures whose access below names an API key grant, and only when the key holds that grant; anywhere else it answers 401, as an unknown, expired or revoked key does, and past API_KEY_RATE_LIMIT requests a minute (120) it answers 429. The OpenAPI document declares three security schemes, session (the cookie), bearer (a session token) and apiKey, and lists the grant on every operation that takes a key. API keys has the rules.

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 report your product’s version from the root package.json. Workspace packages are unversioned. /admin/system also reports kitVersion, the kit release this build is on, which the public endpoints leave out.

This table is derived from the contract (packages/api-contract/src/index.ts): one entry per procedure, all 51 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 the tenant guard (orgScoped, or orgScopedRow for a procedure addressed by a row’s id, in apps/api/src/rpc/tenancy.ts) with the permission named in brackets, resolved from the organization roles in packages/auth/src/permissions.ts (owner, admin, member, and any of your own from product-roles.ts). Where no permission is named, membership alone is enough. Inside a workspace scoped to a team, it also means a member of that team, or an owner or admin (workspace: anyTeam); anyone else gets NOT_FOUND for the workspace and everything in it (Teams are access boundaries). A visibility rule of yours (A visibility rule of your own) hides a workspace the same way.

Procedure REST Access Declared errors
system.health GET /health public none
system.authConfig GET /auth-config public. Returns { socialProviders, requireEmailVerification, signUpOpen } none
system.origins GET /origins public. This deployment’s API and web origins (API_URL, WEB_URL), { api, web }, for a shell that asks for its server none
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: five an hour per client network, and CONTACT_RATE_LIMIT_PER_DAY sent from everyone together. The marketing site’s contact form PRECONDITION_FAILED (no email provider or no CONTACT_EMAIL), SERVICE_UNAVAILABLE (the day’s ceiling is spent)
account.me GET /me signed in UNAUTHORIZED, FORBIDDEN
Procedure REST Access Declared errors
organizations.usage GET /organizations/{organizationId}/usage member. API key: billing: read UNAUTHORIZED, FORBIDDEN
organizations.auditLog GET /organizations/{organizationId}/audit-log member (auditLog: read, owner and admin). limit + cursor; action (an action and everything under it: member matches member.role.update), from and to. Every entry carries impersonatedBy, the platform admin behind an impersonated session (else null), and apiKeyId and apiKey: { name, start }, the API key an action was taken with (else null). An entry about a workspace the caller may not see, another team’s or a deleted one, without workspace: anyTeam, comes with metadata: null; so does one about a workspace a product’s visibility rule hides from the caller, and then a deleted one too, anyTeam or not. API key: auditLog: read UNAUTHORIZED, FORBIDDEN, BAD_REQUEST (bad cursor)
organizations.auditLogExport GET /organizations/{organizationId}/audit-log/export member (auditLog: export, owner and admin). The same filters; the last 30 days unless from says otherwise, at most 366. A text/csv file (over /rpc, a File), formula cells prefixed with ', with the entries organizations.auditLog shows the caller. Ten an hour per organization. Audited as audit_log.export. API key: auditLog: export PRECONDITION_FAILED (data: { rows, max }, more than 50,000 entries), TOO_MANY_REQUESTS, BAD_REQUEST (a span over 366 days)
billing.status GET /organizations/{organizationId}/billing member (billing: read). Adds overLimit: [{ kind, current, limit }], the organization-scoped plan limits the organization already exceeds; features, the feature keys its plan includes (empty while billing is off); and annualPlans, the plans this deployment sells yearly UNAUTHORIZED, FORBIDDEN
workspaces.list GET /workspaces?organizationId= member (workspace: read). Leaves out the workspaces of teams the caller is not in, unless they hold workspace: anyTeam. By name, in compareNames order (Unicode’s root order), whatever the database’s collation. API key: workspace: read UNAUTHORIZED, FORBIDDEN
workspaces.get GET /workspaces/{slug}?organizationId= member (workspace: read). Also returns storage: { usedBytes, limitBytes }. API key: workspace: read NOT_FOUND
workspaces.create POST /workspaces member (workspace: create). A teamId must be a team the caller is in, or any of the organization’s with workspace: anyTeam. API key: workspace: create CONFLICT (slug taken), NOT_FOUND (team), LIMIT_REACHED
workspaces.update PATCH /workspaces/{id} member (workspace: update). Changing teamId needs workspace: anyTeam (owner and admin). API key: workspace: update NOT_FOUND, CONFLICT, FORBIDDEN (a teamId change without anyTeam)
workspaces.delete DELETE /workspaces/{id} member (workspace: delete, owner and admin). Deletes its documents and stored files with it; its jobs stay, outside any workspace. Deletes the workspace’s stored objects too, best effort: a key that survives is a logged leak, not an undeletable workspace. API key: workspace: delete NOT_FOUND

Uploaded straight to storage: createUpload returns a presigned PUT, the browser uploads the bytes, and complete measures the stored object with the driver rather than trusting the size the client declared. 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. API key: document: read 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 or API key, a 10 s timeout, and one AI credit unless the embedding provider is local. API key: document: read NOT_FOUND, TOO_MANY_REQUESTS, LIMIT_REACHED (kind: 'aiCredits'), SERVICE_UNAVAILABLE (the AI service could not embed the query), DEMO_MODE
documents.get GET /documents/{id} member (document: read). API key: document: read NOT_FOUND
documents.createUpload POST /documents member (document: create). { document, upload }. API key: document: create NOT_FOUND, PAYLOAD_TOO_LARGE (data.maxBytes), LIMIT_REACHED (kind: 'storageBytes'), DEMO_MODE
documents.complete POST /documents/{id}/complete member (document: create). Rewrites document.size from what the driver reports. API key: document: create 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. API key: document: read NOT_FOUND, PRECONDITION_FAILED (not uploaded yet)
documents.text GET /documents/{id}/text member (document: read). null until a document.extract job succeeded. API key: document: read NOT_FOUND
documents.delete DELETE /documents/{id} member (document: delete) for a document the caller uploaded; document: deleteAny (owner and admin) for anyone’s (mayDelete). Deletes the stored file too. API key: document: deleteAny, since a key is nobody’s uploader NOT_FOUND, FORBIDDEN (not the uploader)

Queued in Postgres, run by the worker, metered in AI credits against the plan (jobCredits in packages/billing/src/catalog.ts). Every procedure here shows only the kinds the contract lists in JOB_KINDS, the kit’s AI kinds and the product’s PRODUCT_JOB_KINDS. A job of an internal kind, and a system job that belongs to no organization, is left out of jobs.list and jobs.stream, and answers NOT_FOUND by id, even to an owner.

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. Jobs in workspaces the caller cannot see are left out, and a job outside any workspace is listed only to its creator and to owners and admins. API key: job: read NOT_FOUND (a workspaceId the caller cannot see), BAD_REQUEST (bad cursor)
jobs.get GET /jobs/{id} member (job: read). A job outside any workspace is its creator’s and the owners’ and admins’. API key: job: read NOT_FOUND
jobs.create POST /jobs member (job: create). Charges the kind’s credits. API key: job: create; without workspace: anyTeam a key must name a workspaceId for a text job (BAD_REQUEST), since a job outside every workspace would be invisible to it NOT_FOUND, PRECONDITION_FAILED (upload unfinished), LIMIT_REACHED (kind: 'aiCredits'), DEMO_MODE
jobs.cancel POST /jobs/{id}/cancel member (job: cancel), on the same jobs as jobs.get. Refunds the credits. API key: job: cancel NOT_FOUND, CONFLICT (only queued jobs)
jobs.retry POST /jobs/{id}/retry member (job: create), on the same jobs as jobs.get. 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. API key: job: create NOT_FOUND, CONFLICT (only failed jobs), LIMIT_REACHED (kind: 'aiCredits'), DEMO_MODE
jobs.stream GET /organizations/{organizationId}/jobs/stream member (job: read). Server-sent events; an async iterator in the typed client. Optional workspaceId narrows it. Emits the jobs jobs.list would list NOT_FOUND (a workspaceId the caller cannot see), TOO_MANY_REQUESTS

jobs.stream re-checks its authorization every 30 seconds. Removing a member, changing their role, revoking their sessions or banning them ends the connection, or, for a role that may still read jobs, changes which jobs it emits from then on. No connection lives longer than 15 minutes; the client reconnects and is authorized again. One account may hold five at a time. A sixth is refused with TOO_MANY_REQUESTS, which SSE delivers as an event: error after the 200 that opened the stream. Events carry input and result only once a job is finished. While it is queued or running they are either absent or repeat what the caller sent.

A product’s stored files, uploaded like documents to a presigned PUT and checked against the kind the product registers in apps/api/src/files/product.ts: its scope, media types, size cap and permissions. A product’s own procedure attaches a file to its row; there is no files.delete, a file goes with the row that owns it. Details: Stored files.

Procedure REST Access Declared errors
files.createUpload POST /files signed in. An organization’s or a workspace’s kind: member of organizationId, with the kind’s upload permission, in a workspaceId the caller can see. A user-scoped kind: no organizationId or workspaceId. { file, upload }. Refused in the public demo NOT_FOUND (kind, or workspace), BAD_REQUEST (a type the kind does not take, or ids that do not match its scope), PAYLOAD_TOO_LARGE (data.maxBytes, the kind’s cap within MAX_UPLOAD_BYTES), TOO_MANY_REQUESTS (the kind’s unclaimed files), LIMIT_REACHED (kind: 'storageBytes', a workspace file), DEMO_MODE
files.complete POST /files/{id}/complete member with the kind’s upload permission, on a file in a workspace they can see; a user-scoped file’s own account only NOT_FOUND, PRECONDITION_FAILED (the object never arrived or its size changed; retryable), PAYLOAD_TOO_LARGE, LIMIT_REACHED (the quota is re-checked against the real size)
files.downloadUrl GET /files/{id}/download-url member with the kind’s read permission, as for files.complete; a user-scoped file’s own account only. Short-lived presigned GET, always an attachment NOT_FOUND (also before complete and after a generated file’s expiresAt)

An organization’s keys, managed by a signed-in owner or admin; no API key reaches these. API keys has the scopes a key may hold.

Procedure REST Access Declared errors
apiKeys.list GET /organizations/{organizationId}/api-keys member (apiKey: read, owner and admin). Newest first, revoked and expired keys included, each with status, start, permissions, lastUsedAt and createdBy UNAUTHORIZED, FORBIDDEN
apiKeys.create POST /organizations/{organizationId}/api-keys member (apiKey: create). { name, permissions, expiresInDays } (1–365, or null for never). Answers { apiKey, key }: the only time the key appears. Audited as api_key.create. Refused in the public demo BAD_REQUEST (data.reason: 'scope': a grant outside the scopes or beyond the caller’s role), PRECONDITION_FAILED (data.limit: 50 live keys), DEMO_MODE
apiKeys.revoke POST /api-keys/{id}/revoke member (apiKey: revoke). Requests with the key fail at once. Audited as api_key.revoke. A key also answers 401 while the account that created it is banned NOT_FOUND, CONFLICT (already revoked)

An organization’s outbound webhook endpoints and their deliveries, managed by a signed-in owner or admin; no API key reaches these. Webhooks has the events, the delivery schedule and what a URL may point at. A server with WEBHOOKS_ENABLED=false answers WEBHOOKS_DISABLED (403) to every procedure here but list, deliveries and delete.

Procedure REST Access Declared errors
webhooks.list GET /organizations/{organizationId}/webhooks member (webhook: read, owner and admin). { endpoints, webhooksEnabled }: the endpoints newest first, each with eventTypes, enabled, disabledReason (manual or failing) and consecutiveFailures, never the secret; and false while the server has webhooks switched off (WEBHOOKS_ENABLED) UNAUTHORIZED, FORBIDDEN
webhooks.create POST /organizations/{organizationId}/webhooks member (webhook: manage). { url, description, eventTypes }, any event type but webhook.ping. Answers { endpoint, secret }: the only time the whsec_ secret appears. Audited as webhook.create. Refused in the public demo INVALID_URL (400, data.reason: invalid, scheme, credentials or private_address), PRECONDITION_FAILED (data.limit: 10 endpoints), WEBHOOKS_DISABLED (403, webhooks switched off), DEMO_MODE
webhooks.update PATCH /webhooks/{id} member (webhook: manage). Any of url, description, eventTypes, enabled; switching on clears the failure streak. Audited as webhook.update. Refused in the public demo NOT_FOUND, INVALID_URL, WEBHOOKS_DISABLED, DEMO_MODE
webhooks.delete DELETE /webhooks/{id} member (webhook: manage). Its deliveries go with it, pending ones unsent. Audited as webhook.delete NOT_FOUND
webhooks.rotateSecret POST /webhooks/{id}/rotate-secret member (webhook: manage). Answers { secret }; pending retries are signed with it at once. Audited as webhook.secret_rotate. Refused in the public demo NOT_FOUND, WEBHOOKS_DISABLED, DEMO_MODE
webhooks.test POST /webhooks/{id}/test member (webhook: manage). Queues a webhook.ping to this endpoint and answers { deliveryId }. Refused in the public demo NOT_FOUND, PRECONDITION_FAILED (endpoint switched off), WEBHOOKS_DISABLED, DEMO_MODE
webhooks.deliveries GET /webhooks/{id}/deliveries member (webhook: read). { items, nextCursor }, newest first, kept 30 days; limit 1–100 (default 20) and cursor. Each with status, attempts, nextAttemptAt, the last responseStatus, the first 1 KiB of responseBody, and error NOT_FOUND, BAD_REQUEST (bad cursor)
webhooks.redeliver POST /webhook-deliveries/{id}/redeliver member (webhook: manage). Sends the delivery’s message again as a new delivery, with the same webhook-id. Refused in the public demo NOT_FOUND, PRECONDITION_FAILED (endpoint switched off), WEBHOOKS_DISABLED, DEMO_MODE

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.revokeMachineAccess POST /admin/organizations/{organizationId}/revoke-machine-access. Revokes every key of the organization not revoked yet and switches off every webhook endpoint that is on, each audited in its log (api_key.revoke, webhook.disable) with the admin as the actor. Answers { apiKeysRevoked, webhooksDisabled } NOT_FOUND (no such organization)
admin.auditLog GET /admin/audit-log. Every organization’s entries, newest first, each with organization: { id, name, slug }; organizationId narrows it to one. The filters, limit and cursor of organizations.auditLog UNAUTHORIZED, FORBIDDEN, BAD_REQUEST (bad cursor)
admin.organizations.delete DELETE /admin/organizations/{organizationId}. Body confirmSlug must equal the organization’s slug. Deletes its stored objects and lets the FK cascades take members, invitations, teams, workspaces, documents, jobs, the ledger and the audit log, including the entry this call writes first. What records the deletion afterwards is the API’s platform admin deleted an organization log line. 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. Audited in that organization’s log as flag.override, with the admin as the actor 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.

Six procedures page. Five 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. A value that is not one of our cursors is a BAD_REQUEST.

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. It is off by default because a text.embed result holds every vector it produced
organizations.auditLog limit 1–100, default 50 same cursor / nextCursor pair
admin.auditLog limit 1–100, default 50 same cursor / nextCursor pair
webhooks.deliveries limit 1–100, default 20 same cursor / nextCursor pair
admin.organizations.list limit 1–100, default 25 offset. This one is offset-paged and also returns total

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. A contentType outside DOCUMENT_CONTENT_TYPES, or outside a stored file’s kind, lands here as well; there is no 415. An audit export over 366 days, and a text job a key without workspace: anyTeam creates outside a workspace. apiKeys.create declares its own: data: { reason: 'scope' } for a grant the key may not hold. webhooks.create and update answer a URL they refuse with their own INVALID_URL (400, data: { reason })
UNAUTHORIZED 401 none No session (or an expired one); an API key that is unknown, expired or revoked, or one sent to a procedure that does not take keys
FORBIDDEN 403 none Signed in, but not a member / not permitted / not a platform admin; an API key without the grant a procedure needs, or on another organization’s data
LIMIT_REACHED 403 { kind, limit, plan } A plan limit. kind is one of the catalog’s limitDefinitions: the kit’s workspaces, members, aiCredits and storageBytes, and any the product adds. Only enforced while billing is configured
FEATURE_NOT_IN_PLAN 403 { feature, plan, upgradeTo } The organization’s plan does not include a feature the procedure is gated on (planFeature, requireFeature). upgradeTo is the cheapest plan that does, or null. The kit gates none of its own procedures; a product’s declare it. Only enforced while billing is configured
WEBHOOKS_DISABLED 403 none This server has outbound webhooks switched off (WEBHOOKS_ENABLED=false): webhooks.create, update, test, redeliver and rotateSecret. Listing and deleting endpoints still work
DEMO_MODE 403 none A public demo (PUBLIC_DEMO_MODE) refuses the procedure: apiKeys.create, documents.createUpload, documents.search, files.createUpload, jobs.create, jobs.retry, and webhooks.create, update, test, redeliver and rotateSecret, plus a product’s own in productDemoRefused. The message names what the demo switches off. Better Auth’s checkout and plan-change endpoints answer a demo’s refusal with the same code
NOT_FOUND 404 none No such row. A by-id procedure (/workspaces/{id}, /documents/{id}, /jobs/{id}) derives the tenant from the row it reads, so another organization’s row answers FORBIDDEN there, which tells a signed-in stranger that the id exists. A workspaceId, documentId or teamId passed with an organizationId you belong to is looked up in that organization, so another organization’s answers NOT_FOUND. So does a workspace scoped to a team you are not in, and everything in it, unless you are an owner or admin, and a job outside any workspace that someone else created. admin.organizations.delete also answers it when confirmSlug does not match the organization
CONFLICT 409 none A workspace slug already taken (workspaces.create, workspaces.update); a job that is not queued (jobs.cancel) or not failed (jobs.retry); an API key already revoked
PRECONDITION_FAILED 412 none 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 or arrived at another size than declared (then deleted), a download before complete, an organization still being billed. An audit export of more than 50,000 entries, an organization already holding 50 live API keys or 10 webhook endpoints, and a test event or redelivery to a switched-off endpoint
PAYLOAD_TOO_LARGE 413 { maxBytes } createUpload: the declared size is over MAX_UPLOAD_BYTES, or for a file over its kind’s cap. complete: the stored object is over MAX_UPLOAD_BYTES, or a file over its kind’s cap, and has been deleted
TOO_MANY_REQUESTS 429 { retryAfter } from the limiter, none otherwise The procedure limiter past PROCEDURE_RATE_LIMIT_PER_MINUTE a minute per address, or per user or API key once signed in, with data: { retryAfter } (seconds, as Retry-After); the contact form past five an hour per address, with the same data; documents.search past 30 per minute per user or key; a sixth concurrent jobs.stream for one account; files.createUpload past a kind’s unclaimed files; an API key past API_KEY_RATE_LIMIT requests a minute; an eleventh audit export in an hour
SERVICE_UNAVAILABLE 503 none services/ai could not be reached, or answered something unusable. Generic on purpose: its own message quotes provider URLs and model names, so that goes to the log and never to a tenant. Also the contact form once CONTACT_RATE_LIMIT_PER_DAY messages went out today

Custom codes (LIMIT_REACHED, FEATURE_NOT_IN_PLAN, INVALID_URL, WEBHOOKS_DISABLED, DEMO_MODE) 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. A feature’s procedures can also live in a file of their own next to it, built from the builders in ./procedures and spread into contract, which keeps them out of the file kit releases change. Your first tenant-scoped feature builds one that way.
  2. Implement it in apps/api/src/rpc/ on authed or adminOnly from rpc/base.ts, and for organization data with orgScoped or orgScopedRow from rpc/tenancy.ts.
  1. Call it from any client: api.<group>.<name>(input). The typed client, the REST endpoint and the OpenAPI document update automatically.
  2. 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/*: PROCEDURE_RATE_LIMIT_PER_MINUTE (300) requests a minute per address while the caller is unknown, and per user once a procedure knows who is calling. An IPv6 address counts by its /64, as in Better Auth’s limits: one host is routinely handed a whole /64. A procedure with a limit of its own, such as contact.send, spends it inside the procedure through the same limiter (spendAllowance in apps/api/src/rpc/context.ts), keyed the same way and refused in the same shape, so every path and transport that reaches the procedure counts. An API key counts in a bucket of its own the same way. A signed-in request counts against its address only while it runs, so the address is a ceiling for traffic that is not signed in: an address over it refuses everyone behind it until its minute ends. It is a guardrail against runaway clients, not a defence against determined abuse. Put that at the edge. A refusal is 429 with Retry-After and the error TOO_MANY_REQUESTS, data: { retryAfter }, in the transport’s shape; REST keeps error: 'rate_limited' and retryAfter beside it. RateLimit-Limit, -Remaining and -Reset describe the caller’s bucket once one is known, the address’s otherwise. retryAfterOf(error) from @repo/api-client reads the seconds off a typed-client error. oRPC’s OpenAPILink reads a REST error with keys beyond its own five as a plain HTTP error: code is still TOO_MANY_REQUESTS, from the status, but data holds the raw response (status, headers, body), so a client on that link reads Retry-After there.

The address is, in order: the one the web server vouched for (FORWARDED_CLIENT_SECRET, on its server-side calls only), a single-value x-forwarded-for under TRUST_PROXY, which is what Caddy produces, then the socket’s. Better Auth’s limits and the access log see the same one. The web server signs the address onto its calls to the API alone, but fetch keeps a request’s headers when it follows a redirect, so an answer that redirects a server-side load to another origin hands that origin the signed value too. The value names one address and is honoured for five minutes: whoever holds it can only have requests counted in that visitor’s own bucket, never choose another address, and the secret itself never leaves the two servers.

CORS allows the origins in TRUSTED_ORIGINS (plus WEB_URL) with credentials and the X-CSRF-Token header, 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 (or API key) rather than the IP: documents.search allows 30 per minute, and one account may hold at most five concurrent jobs.stream connections. Each API key may make API_KEY_RATE_LIMIT requests a minute (120), and an organization ten audit exports an hour. All of these counters, and the procedure limiter, are in-process. They are sized for one API container. Running several replicas behind Caddy needs a shared store first.