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.
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
getToken, and the client addsAuthorization: Bearer …. - Every call carries
x-csrf-token: orpc(oRPC’sSimpleCsrfProtectionLinkPlugin), and the API refuses an/rpcrequest without it:403with the codeCSRF_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 thatWEB_URLandTRUSTED_ORIGINSdecide, so the session cookie alone never calls a procedure. A client that talks to/rpcwithoutcreateApiClientmust send the header itself. - 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.js exports a ready client (api, apiFor(fetch)), and #lib/query.js
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"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:
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.
Procedures
Section titled “Procedures”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.
System and public
Section titled “System and public”| 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 |
Organizations, billing, workspaces
Section titled “Organizations, billing, workspaces”| 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 |
Documents
Section titled “Documents”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) |
API keys
Section titled “API keys”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) |
Webhooks
Section titled “Webhooks”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 |
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.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.
Paging and cursors
Section titled “Paging and cursors”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 |
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. 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.
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. A feature’s procedures can also live in a file of their own next to it, built from the builders in./proceduresand spread intocontract, which keeps them out of the file kit releases change. Your first tenant-scoped feature builds one that way. - Implement it in
apps/api/src/rpc/onauthedoradminOnlyfromrpc/base.ts, and for organization data withorgScopedororgScopedRowfromrpc/tenancy.ts.
- 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/*: 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.