| title | API Reference & Clients |
|---|---|
| description | The human-readable companion to the committed OpenAPI specs and generated clients. |
| group | General |
| order | 70 |
Audience: developers consuming, extending, or integrating with the HTTP API — from a browser, a script, another service, or any language. This page is the human-readable companion to the committed OpenAPI specs; the route handlers under src/app/api/** and src/lib/api-auth/** are authoritative. Where this prose and the code disagree, the code wins — fix the doc.
Two related references carry the deeper detail this page links to rather than repeats:
- Design: API Keys & Access Tokens — the canonical threat model, issuance, rotation, and revocation design for the machine-credential subsystem (
src/lib/api-auth/**). - Administrator Console — Specification — the canonical
/api/administratorsurface, including the permission catalog (§6.1). The catalog is not reproduced here.
| Group | Base path | Auth | Audience |
|---|---|---|---|
| Better Auth | /api/auth/[...all] |
Better Auth (cookies) | Browser auth flows & OAuth callbacks |
| Account self-service | /api/account/*, /api/preferences/* |
Cookie session (/api/account/* also accepts a bearer credential carrying the matching account.* scope, except the data export GET /api/account/export, which is cookie-only, F-151) |
The signed-in user |
| Invitations | /api/invitations/accept |
Cookie session | Signed-in invitees accepting an organization invitation |
| Navigation | /api/navigation/* |
Cookie session | The web UI |
| SSO handoff | /api/sso/launch, /api/sso/consume |
Cookie session / signed token | Cross-subdomain SSO |
| Docs assets | /api/docs/asset/[...path] |
Cookie session + shell.view |
In-app docs viewer |
| Administrator | /api/administrator/* |
Cookie session (+ Origin on mutations) or Bearer (API key / JWT, scope-bound) + admin.* permission |
The admin console |
| Machine API (v1) | /api/v1/* |
Bearer (API key or JWT) or cookie | Integrations & the user's own self-service |
| MCP agent gateway | /api/mcp, /api/mcp/register |
Bearer (API key or JWT); registration public | AI agents (Model Context Protocol) — dark by default |
| Discovery | /api/v1/openapi.json, /api/v1/jwks.json, /.well-known/oauth-* |
Public | Tooling · MCP / OAuth clients |
All handlers are dynamic = "force-dynamic" (no caching of authorized data). Every mutating route runs a permission check, an origin/CSRF guard, a per-actor rate-limit, Zod validation, and an audit write.
This page focuses on the two surfaces an external caller integrates with — the machine API (/api/v1) and the admin console API (/api/administrator) — plus the cross-cutting auth, envelope, and error model they share.
Authoritative machine spec: both surfaces are described by OpenAPI 3.1 documents — the v1 spec is served live at
GET /api/v1/openapi.jsonand both are committed for offline client generation:docs/openapi.json(/api/v1) anddocs/openapi-admin.json(/api/administrator). They are produced by the builders insrc/lib/api-auth/(openapi.ts,openapi-admin.ts) — the same builders that power the live route and the committed SDK. The builders are written by hand, not derived from the handlers. CI checks that the committed files match the builders (tests/unit/openapi-export.test.ts), that the admin SDK matches its file, and that every route has an operation and every operation a route (tests/unit/api-route-spec-parity.test.ts); a response's shape is checked only where a route test asserts it against the committed document (tests/helpers/openapi-response.ts, F-74). Treat the specs as the source of truth for exact request/response shapes; this page summarizes.
flowchart TB
subgraph You["Your integration"]
V["v1 client (generated)"]
A["admin SDK (sdk/admin)"]
end
V -- "Authorization: Bearer drk_… / JWT" --> V1["/api/v1/*"]
A -- "Cookie (+ Origin on mutations) or Bearer" --> ADM["/api/administrator/*"]
V1 & ADM --> APP["DevResponse app"]
List endpoints across both surfaces share one envelope and one set of query parameters:
- Envelope —
{ items, page, pageSize, total }, plussorton the full list-query endpoints (users, audit).pageSizeis clamped to 1–200. - Query —
page(1-indexed),pageSize,sort(repeatablefield.asc/field.desc, applied in order),q(case-insensitive search), and repeatablefilter[…]exact-match filters. Several values are repeated parameters (?filter[status]=blocked&filter[status]=suspendedmatches either;?sort=created_at.desc&sort=status.asc), never a comma-joined list: a comma is part of the value. The console lists and their CSV export honour a repeated filter too, on every filter the admin spec declares repeatable (all butGET /mcp-agents' derivedfilter[status], which takes one value); most of them used to read only a single value, so a repeated one listed every row (F-74). - Bounds —
pageis at most 1,000,000 on both surfaces: a deeper page is a 400 before any query runs,invalid_requeston v1 andinvalid_queryin the console envelope. The console's filters on an id (filter[organization_id],filter[app_user_id],filter[organization]) take a UUID or that filter's documented keyword, and anything else is the sameinvalid_query. Both mistakes used to reach Postgres, and most lists answered 500 (F-63). - Strict on v1 — every
/api/v1list refuses what it cannot apply with a400 invalid_requestproblem whosedetailnames the accepted values: afilter[…]the operation does not list, an empty filter value or one outside the spec'senum, asorton an unlisted field or with a direction other thanasc/desc, aqon a list that does not search, and a single-valued parameter (page,pageSize,q,status,appUserId) given twice (F-34). Only/userssearches. The credential listings (/admin/api-keys,/admin/oauth-clients) take nosort,qorfilter[…]at all, onlystatus(one of itsenum) and, on api-keys,appUserId. The console API keeps the lenient contract of Admin Manager §7 and drops those instead. - Tenant scoping — an out-of-scope resource returns 404, never 403, so existence is never leaked (see Tenant scoping).
- Wire format — list/detail endpoints return raw snake_case DB rows; create endpoints return small camelCase summaries. Both are modeled in the spec, so a generated client matches the wire format exactly.
There are two auth models. Cookie session is what the browser uses everywhere; bearer credentials are the machine path. Both /api/v1 and /api/administrator accept either (one resolveCaller behind both guards); the account and navigation endpoints are cookie-only except where noted in §1.
Better Auth sets a session cookie on sign-in: __Secure-better-auth.session_token on any https origin (Better Auth's useSecureCookies prefix — production always), and the bare better-auth.session_token only on a plain-http dev origin such as http://localhost:3000. Its value is the signed cookie value exactly as the browser holds it — a session id or a row from the session table is rejected — so a server-side caller obtains it from a real sign-in, never by minting one.
Cross-site mutations (POST/PATCH/PUT/DELETE) are additionally protected by an origin guard: the request's Origin (or Referer) must be a trusted origin. The guard covers every cookie-session mutation — /api/administrator/*, /api/account/*, /api/preferences/* (active-org and locale switches), and /api/invitations/accept — so a non-browser caller must send both the session cookie and a matching Origin header; a miss is 403 untrusted_origin. (A browser sets Origin itself; it is a forbidden header name, so setting it from script is a no-op.)
/api/v1/** and /api/administrator/** accept either credential in an Authorization: Bearer … header. Both paths are disabled by default and enabled per environment (API_KEYS_ENABLED, API_JWT_ENABLED — see Configuration). A bearer caller is exempt from the origin guard (a cross-site page cannot attach one) and is bounded by its scopes — see the authority rule below; on the admin surface that means the credential needs a scope covering the operation's admin.* permission (e.g. admin.users.read or admin.users.*), else 403 forbidden.
| Type | Looks like | Enabled by | At rest |
|---|---|---|---|
| API key | drk_<env>_<rand> (drk_live_… / drk_test_…) |
API_KEYS_ENABLED=1 |
SHA-256 hash only |
| JWT access token | eyJ… (EdDSA) |
API_JWT_ENABLED=1 + signing key |
Stateless; verified via JWKS |
An API key is drk_<env>_<random> (32 base62 chars, ~190 bits of entropy); only its SHA-256 hash is stored, and the plaintext is shown once. A JWT is a short-lived EdDSA token verified by signature against GET /api/v1/jwks.json — no server-side secret. Get one by exchanging a long-lived credential (an API key, or OAuth2 client-credentials) at the token endpoint:
curl -X POST https://app.example.com/api/v1/auth/token \
-H "Content-Type: application/json" \
-d '{"grant_type":"api_key","api_key":"drk_live_xxxxxxxx","scope":"admin.users.read"}'
# → { "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 900, "scope": "admin.users.read" }Then call the API:
curl https://app.example.com/api/v1/users -H "Authorization: Bearer eyJ…"Details of the token endpoint worth knowing:
resource(RFC 8707) selects the audience. Omit it (or passresource=<origin>/api/v1) for a v1 token —audisAPI_JWT_AUDIENCE. Passresource=<origin>/api/mcpfor a token that drives the MCP gateway; such a token is refused at/api/v1(401 invalid_token), and a v1 token is refused at/api/mcp. Any other value is400 invalid_target. The accepted values are listed asresources_supportedin/.well-known/oauth-authorization-server.expires_inis capped by the key. For theapi_keygrant the lifetime is the smaller ofAPI_JWT_ACCESS_TTL_SECONDSand the seconds left until the key'sexpires_at, so a token never outlives its key; a key with under a second left is refused (401 invalid_client).- The body is capped at 16 KiB. It is read only after the per-IP limiter admits the request, and a larger one is refused with
413 payload_too_largewithout being buffered (F-78). A token request is a few hundred bytes; even ascopenaming every scope fits many times over. - Revoking or rotating the credential kills its tokens. Every JWT names the key/client it came from (
cid), and each request re-checks that row: after a revoke or rotation the next call with an outstanding token returns401 credential_revoked— do not retry it, mint a fresh one from the new credential.
Authority rule (the one invariant to remember): a credential's effective access is the intersection of its scopes and its owner's live permissions (src/lib/api-auth/scopes.ts, enforced by requireApiPermission in v1-guard.server.ts). A credential can never be minted with more authority than its creator holds, and GET /api/v1/me reports the resulting effectiveScopes.
Scopes are the permission vocabulary — every admin.* catalog key (see admin-manager.md §6.1) plus a small set of self-service account.* scopes (account.read, account.profile.write, account.preferences.write, account.apikeys.manage). A scope ending in .* (e.g. admin.users.*) matches every key under that prefix.
The self-service write scopes gate the account mutations for bearer callers: PATCH /api/account/profile requires account.profile.write, PUT /api/account/preferences requires account.preferences.write, and the /api/v1/me/api-keys* mutations require account.apikeys.manage. A read-only (account.read) or zero-scope credential gets 403 insufficient_scope on every one of them; a cookie session carries the user's full authority and is unaffected. The data-subject export GET /api/account/export (F-151) refuses every bearer credential, whatever its scopes, with 403 forbidden: it is the whole account, every organization's rows included, and a credential acts in one organization (Admin Manager, Data export and erasure).
For the full threat model, secret storage, issuance, rotation, and revocation, see Design: API Keys & Access Tokens.
Applies to the admin console and the admin.* v1 routes:
- Super Admin (holds
superuser): all organizations. - Org Admin (holds
admin.*, nosuperuser): their single organization only. - An out-of-scope resource returns 404, never 403, so existence is not leaked.
See Architecture → Authorization.
RFC 7807 application/problem+json:
{ "type": "https://devresponse.com/problems/forbidden", "title": "Insufficient scope or permission", "status": 403, "code": "forbidden", "detail": "…", "requestId": "5f3c…" }A JSON envelope with a stable machine code, an i18n message key, and the correlation id (AdminError):
{ "error": "forbidden", "message": "errors.forbidden", "requestId": "5f3c…" }The first-party routes (/api/account, /api/preferences, /api/invitations, /api/navigation and SSO launch and consume) answer the same envelope, and every message names a key under errors in each locale's messages, so a client can localize any code it receives (F-129).
Common statuses on both surfaces: 400 invalid body, 401 unauthenticated, 403 forbidden, 404 not found / out of scope, 409 conflict, 412 stale If-Match ETag (v1, and the admin organization/role/group PATCH), 429 rate-limited (with Retry-After). Every response (success or error) carries an x-request-id header that matches the audit log. That includes an unexpected server fault: a handler that throws answers 500 with the code internal_error in the surface's own envelope, never a bare 500 (on the MCP transport a JSON-RPC -32603 error, on MCP registration an RFC 7591 server_error, A-12). The two exceptions are the public, cacheable GET /api/v1/jwks.json and GET /api/v1/openapi.json, because a shared cache would hand one request's id to every later caller.
Every /api response is sent with Cache-Control: private, no-store, so no browser or intermediary keeps a copy of cookie-authenticated JSON (I-07). The exceptions are the routes that set their own caching: the two key sets (/api/sso/jwks.json, /api/v1/jwks.json) and /api/v1/openapi.json (public, max-age=300), and the docs and help images under /api/docs/asset/ and /api/help/asset/ (private, max-age=300).
| Endpoint | Methods | Auth (scope) | Purpose |
|---|---|---|---|
/api/v1/auth/token |
POST | API key or OAuth client credentials | Exchange long-lived credentials for a short-lived JWT |
/api/v1/me |
GET | account.read |
Caller identity, permissions, effective scopes |
/api/v1/me/api-keys |
GET / POST | account.read / account.apikeys.manage |
List / mint the caller's own keys (plaintext once). A bearer caller sees only keys in the org its credential acts in |
/api/v1/me/api-keys/[id] |
DELETE; …/rotate POST |
account.apikeys.manage |
Revoke / rotate one of the caller's own keys. A bearer caller reaches only keys in its own org (404 otherwise) and may rotate only a key whose scopes its own credential carries (403 invalid_scope) |
/api/v1/users |
GET / POST | admin.users.read / .create |
User administration. POST with the Better Auth role: "admin" also needs cross-org reach, which only a superadmin's cookie session has: every API key and JWT is bound to one org (MACHINE-2), so a bearer caller always gets 403 forbidden for it (F-13). A caller without cross-org reach (every API key and JWT, and an org admin's session) enrols the new user in the org it acts in, in the same transaction, with a membership whose status is the user's initialAppStatus (default pending_approval; approving the user activates both). A user's tenant is its membership, so without it that caller got 404 on every follow-up on /users/{id}. A superadmin's cookie session still creates the user in no org; place it with POST /api/administrator/users/{id}/memberships. The MCP createUser tool calls this route, so an agent's user joins the agent's org. That enrolment is a membership add, and an active one an approval, so such a caller also needs admin.users.update or admin.orgs.manage (F-69), plus admin.users.manage to pass initialAppStatus: "active", each held as a permission and granted as a scope. A key or agent scoped to admin.users.create alone gets 403 forbidden, with a detail naming what is missing and an admin.user.create_denied audit row, before anything is created; so does an address whose email domain a superadmin bound to another organization (F-480, Admin Manager §8.1). docs/openapi.json lists the two scope sets as createUser's security alternatives and states the rest in the operation's description, which the MCP tool carries. A pending user created this way is not activated by the org's sign-up policy when it signs in: approve it with POST /api/v1/users/{id}/status approve, which activates the user and the membership. Behaviour change: a client that used to create the user and then enrol it with POST /api/administrator/users/{id}/memberships in the same org now gets 409 membership_exists from the second call, since the create already holds that membership; approve the user instead (or change the membership's status with PATCH …/memberships). An optional name must be at most 200 characters with no control, line-break or invisible formatting characters, or the answer is 400 invalid_request (F-21, Form Validation). An address that already has an account is 409 conflict, including one the identity provider holds with no user row and the loser of two concurrent creates; a failure to store the new user is 502 (F-30) |
/api/v1/users/[id] |
GET; …/status POST |
admin.users.read / .manage |
Read a user (emits a weak ETag); apply a status transition — a non-superadmin principal gets 403 forbidden (audited admin.user.action_denied) for a target who outranks them, exactly as the console does (Admin Manager §8.1), and a soft-deleted user is 409 use_restore: only the console's restore brings one back (F-57, Soft-delete and restore). approve or reactivate of an MCP agent's service account also needs admin.clients.manage, as permission and scope: 403 forbidden otherwise, audited admin.user.action_denied (F-77, Admin Manager §8.13); setUserStatus's description in docs/openapi.json says so, and the MCP tool carries it. A block or suspend that changes the account-wide status also ends the user's sessions; a bearer caller is confined to one org, so that is only for a user in no other org (F-147). If ending them fails, the status stays applied and the answer is 502 internal_error: re-read the user and retry, with the new ETag if you send If-Match |
/api/v1/admin/api-keys |
GET; …/[id] DELETE |
admin.apikeys.read / .manage |
API-key governance (list / revoke any key) |
/api/v1/admin/oauth-clients |
GET / POST; …/[id] GET/PATCH/DELETE; …/rotate-secret POST |
admin.clients.read / .manage |
OAuth client (client-credentials) management. A PATCH that narrows scopes also narrows the client's outstanding tokens, from their next request (F-71, API Security §6) |
/api/v1/audit-events |
GET | admin.audit.read |
Read the audit log |
/api/v1/jwks.json |
GET | public | Public keys for verifying issued JWTs |
/api/v1/openapi.json |
GET | public | OpenAPI 3.1 spec |
The token endpoint accepts grant_type of api_key or client_credentials, with an optional scope to down-scope the token to a subset of the credential's scopes. Mutations are mutating-rate-limited per credential (the bucket keys on the credential id, so one noisy key can't exhaust the principal's whole budget). A token is charged to the key or client it was minted from, so minting a fresh token does not reset the bucket (F-73). The self-service key routes (/api/v1/me/api-keys and its revoke and rotate) are the exception: create, revoke and rotate share one bucket per principal, so a newly minted key never brings a fresh budget for minting the next. Every v1 429 is a rate_limited problem whose retryAfter and Retry-After header are the refusing bucket's own wait, and each is counted in devresponsekit_rate_limit_denials_total (F-130).
curl https://app.example.com/api/v1/me -H "Authorization: Bearer eyJ…"{
"betterAuthUserId": "…", "appUserId": "…", "email": "svc@example.com",
"status": "active", "organizationId": "…", "preferredLocale": "en",
"authentication": { "kind": "api_key", "credentialId": "…" },
"permissions": ["admin.users.read"], "grantedScopes": ["admin.users.read"], "effectiveScopes": ["admin.users.read"]
}The v1 surface is designed for generated SDKs — use it for anything outside the browser (integrations, cron jobs, other services, or letting a user manage their own resources). Every operation has a stable operationId (listUsers, createUser, issueToken, …) so generated method names are predictable.
# 1. Get the spec — committed, so no running server is needed:
# docs/openapi.json (…or fetch the live one)
curl https://app.example.com/api/v1/openapi.json -o openapi.json
# 2. Generate a client for your stack:
npx openapi-typescript docs/openapi.json -o api.d.ts # TS types only
npx @openapitools/openapi-generator-cli generate \ # full typed client
-i docs/openapi.json -g typescript-fetch -o ./clients/v1
npx @openapitools/openapi-generator-cli generate \ # any other language
-i docs/openapi.json -g python -o ./clients/python// 3. Call it — authenticate with a bearer token (API key or JWT).
import { Configuration, UsersApi } from "./clients/v1";
const api = new UsersApi(
new Configuration({
basePath: "https://app.example.com/api/v1",
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
}),
);
const page = await api.listUsers({ page: 1, pageSize: 25, q: "acme" });
console.log(page.items, page.total);This is the console surface (users, roles, permissions, groups, organizations, memberships, sign-up policy, invitations, enterprise apps, API keys, email, MCP agents, audit, CSV export). It is internal tooling that mirrors the admin console — not a public/integration API; prefer the v1 surface for integrations. Every endpoint accepts a cookie session (+ Origin on mutations) or a scope-bound bearer credential and requires the noted permission; mutations are rate-limited (429 with Retry-After / retryAfter) and audited. That includes the user actions backed by Better Auth (create, ban, unban, soft-delete, restore, password, sessions), which a bearer caller could not complete before F-13 (they answered 502). The exceptions are cookie-session only:
- The Better Auth platform role.
POST /users/[id]/role, andPOST /userswithrole: "admin", need cross-org reach. Every API key and JWT is bound to one org (MACHINE-2), so a bearer caller gets403there (the role route refused one before F-13 too); only a superadmin's cookie session may set the role. - The two impersonation operations.
POST /users/[id]/impersonateacts on the caller's own session cookie and hands back a new one, so a caller without that cookie session gets403;DELETE /users/[id]/impersonatebypasses the permission guard by design.
The table below is generated from the spec by pnpm docs:admin-table (one row per tag; tests/unit/docs-admin-api-table.test.ts fails when it is stale) — edit openapi-admin.ts, not the table:
| Resource | Methods & paths | Permissions |
|---|---|---|
| Users | GET/POST /users; GET/PATCH/DELETE /users/[id]; POST /users/[id]/status; POST /users/[id]/password; POST /users/[id]/role; POST /users/[id]/ban; POST /users/[id]/unban; POST /users/[id]/restore; GET /users/[id]/export; POST /users/[id]/erase; POST/DELETE /users/[id]/impersonate; GET/DELETE /users/[id]/sessions; DELETE /users/[id]/sessions/[sessionId]; GET/POST/PATCH/DELETE /users/[id]/memberships; GET/POST/DELETE /users/[id]/app-roles; GET /users/[id]/roles; GET /users/[id]/audit; GET/POST/DELETE /users/[id]/groups; POST /users/bulk |
admin.users.* (per action; POST …/erase: admin.users.delete + superadmin) |
| Roles | GET/POST /roles; GET/PATCH/DELETE /roles/[id]; POST /roles/[id]/duplicate; GET/POST/PATCH/DELETE /roles/[id]/permissions; GET /roles/[id]/members |
admin.roles.* |
| Permissions | GET/POST /permissions; PATCH/DELETE /permissions/[id] |
admin.roles.read, admin.permissions.manage |
| Groups | GET/POST /groups; GET/PATCH/DELETE /groups/[id]; GET/POST/DELETE /groups/[id]/members; GET/POST/PATCH/DELETE /groups/[id]/roles |
admin.groups.* (.assign for members and roles) |
| Organizations | GET/POST /organizations; GET/PATCH/DELETE /organizations/[id]; GET/POST/PATCH/DELETE /organizations/[id]/members; GET/POST/DELETE /organizations/[id]/provider-bindings; GET/POST /organizations/[id]/invitations; DELETE /organizations/[id]/invitations/[invitationId]; POST /organizations/[id]/invitations/[invitationId]/resend; GET/PATCH/DELETE /organizations/[id]/auth-settings; GET/PATCH /auth-settings/defaults |
admin.orgs.* (.manage for members, invitations and provider bindings; /auth-settings/defaults, POST …/provider-bindings: + superadmin) |
| Memberships | GET /memberships |
admin.orgs.read |
| Enterprise apps | GET/POST /enterprise-apps; GET/PATCH/DELETE /enterprise-apps/[id] |
admin.apps.* |
| API keys | GET/POST /api-keys; GET/DELETE /api-keys/[id]; POST /api-keys/[id]/rotate |
admin.apikeys.* |
GET /email/outbox; GET /email/outbox/[id]; GET /email/templates; GET/PUT /email/templates/[id]; POST /email/test |
admin.email.* |
|
| MCP agents | GET /mcp-agents; PATCH/DELETE /mcp-agents/[id]; POST /mcp-agents/[id]/approve |
admin.clients.read / admin.clients.manage |
| Audit | GET /audit |
admin.audit.read |
| Export | GET /export/[resource] |
the exported resource's read permission |
The exact permission per action and request/response shapes live in
docs/openapi-admin.jsonandadmin-manager.md.GET /api/administrator/metricsexists but is intentionally excluded from the spec/SDK — it backs the console home dashboard only; it is the only exclusion (tests/unit/api-route-spec-parity.test.tsfails for any other admin route missing from the spec). A user's sessions are returned as aSessionItemprojection (never the session token) and revoked byid.
Unlike the v1 client, the admin SDK is already generated and committed at sdk/admin/ (openapi-generator typescript-fetch, zero runtime dependencies — it uses the global fetch). Import it directly, and build the Configuration with the hand-written createAdminClient — it sets basePath from your origin and the right credential shape for each mode (the generated apiKey/username/password knobs are unused by this API):
import { createAdminClient } from "../sdk/admin/client";
import { UsersApi, OrganizationsApi, MCPAgentsApi } from "../sdk/admin";
// Browser: the session cookie rides along (credentials: "include"); the
// browser sets Origin on mutations itself — it cannot be set from script.
const browser = createAdminClient({ origin: "https://app.example.com" });
// Server-side with a session: forwards the Cookie header and adds Origin.
// `cookie` is the SIGNED value of the __Secure-better-auth.session_token
// cookie from a real sign-in — `<token>.<44-char base64 signature>`, whose
// trailing "=" pad is why the SDK matches a leading "name=" rather than just
// looking for an "=" — or the full "name=value; …" header.
const server = createAdminClient({ origin: "https://app.example.com", cookie: signedCookieValue });
// Bearer: an API key or JWT — scope-bound, exempt from the Origin guard.
const machine = createAdminClient({ origin: "https://app.example.com", bearerToken: apiKey });
const users = new UsersApi(browser);
const page = await users.listUsers({ page: 1, pageSize: 25, filterStatus: ["active"] });
const created = await users.createUser({
createUserRequest: { email: "new.user@example.com", password: "<temp>", name: "New User" },
});
const orgs = await new OrganizationsApi(server).listOrganizations({ q: "acme" });
const agents = await new MCPAgentsApi(machine).listMcpAgents({ filterStatus: "pending" });Failed requests reject with a ResponseError carrying the AdminError envelope (message is an i18n key; a 429 body is RateLimitedError, a 422 scope grant is UnprocessableError). Regenerate after editing the admin API — a drift-guard test fails otherwise:
pnpm sdk:admin:typecheck # type-check the committed client (sdk/admin/tsconfig.json)
pnpm sdk:admin:generate # re-export docs/openapi-admin.json, verify the generator JAR, regenerate sdk/admin
pnpm docs:admin-table # refresh the resource table above from the specRegenerating needs Java + network. openapi-generator runs on a JVM: the JAR version is pinned in
openapitools.json, its SHA-256 is verified byscripts/verify-openapi-generator-jar.tsbefore it runs (the cache lives in the gitignored.cache/openapi-generator/), and the npm wrapper is an exact-pinned devDependency run viapnpm exec. The committed client itself has no dependencies. Seesdk/admin/README.md.
| If you're… | Use |
|---|---|
| Integrating from another service / script / language | v1 machine API (bearer auth, generate from docs/openapi.json) |
| Letting a user manage their own resources programmatically | v1 machine API (account.* scopes, /api/v1/me/*) |
| Building internal tooling that mirrors the admin console | Admin SDK (sdk/admin/, cookie + Origin, or a scope-bound bearer) |
| Unsure | v1 — it's the supported integration surface; the admin SDK is an internal convenience |
The two surfaces overlap (both can manage users, both take a bearer) but differ in the browser path (only the admin surface is cookie + Origin), error format (RFC 7807 vs { error, message, requestId }) and stability (v1 is the supported contract).
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/api/sso/launch |
GET | Cookie session (not impersonated) | Verify access to a registered app, mint a one-time handoff token, redirect to the destination |
/api/sso/consume |
GET | Signed token | Verify the token and redirect to the confirmation interstitial (no nonce burn, no session) |
/api/sso/consume |
POST | Signed token + trusted origin | Burn the token, establish the destination session, redirect to the dashboard |
/api/sso/jwks.json |
GET | Public | The issuer's public handoff keys (Ed25519 JWKS, Cache-Control: public, max-age=300). Always mounted; { "keys": [] } when this deployment issues no handoffs |
Query parameters for launch: applicationId (required; must match the app-id shape ^[a-z0-9][a-z0-9._-]{0,127}$ — anything else is a 400 invalid_application_id with no database work), locale (optional). The token is an EdDSA (Ed25519) JWT signed by the issuer's SSO_HANDOFF_PRIVATE_KEY and verified by the consumer against the issuer's /api/sso/jwks.json — single-use, valid ≤60s (enforced on both sides), with an audience bound to the destination application. Its claims are minimal — sub, email, locale, targetApplicationId, jti plus iss/aud/iat/exp; no roles, organization or app-user ids — because the token rides in a query string. A deployment without a signing key, or one holding the key without being the issuer SSO_HANDOFF_ISSUER names (F-80), answers 503 sso_not_configured on launch. See Architecture → SSO and Configuration.
Contract details that matter to a caller:
- Impersonated sessions cannot launch. A session with
impersonatedByset gets403 forbidden_while_impersonating(audited against the impersonating admin). The satellite session a handoff would mint carries no impersonation marker, outlives the impersonation cap, and is attributed to the target — so it is never minted. - Application-id binding on consume. Besides the
audcheck, both consume methods require the token'stargetApplicationIdclaim to equal the consumer's ownSSO_HANDOFF_APPLICATION_ID, and the nonce burn is predicated on that id too. A token minted for another registered app is rejected (401 invalid_token, audit reasontarget_application_mismatch) even if the two apps' audiences collide. The catalog refuses a duplicatesso_audienceat registration (409 audience_taken), and never registers the deployment itself: its ownSSO_HANDOFF_APPLICATION_ID(409 id_taken), its own audience (409 audience_taken) or its own origin (400 origin_not_allowed) (F-83). - The session consume opens is bounded. It ends
SSO_SESSION_LIFETIME_HOURS(default 8) after the handoff however active it is, after which the consumer's pages,/api/v1cookie calls and Better Auth endpoints treat it as signed out (401 SESSION_EXPIREDon/api/auth/*) and a new launch re-checks access. Disabling (PATCH … {"status":"disabled"}) or deleting an enterprise app on the issuer also deletes the handoff sessions for it that the issuer's own session table holds, and expires its handoffs in flight (F-82). Both hold for a consumer that runs this consume route; a satellite fork's only once it ports F-82. - Consume failures: a page for a browser, JSON for everyone else. A refused consume answers the
{ error, message, requestId }envelope with its status (400 missing_token;401 invalid_token,token_expired,token_already_usedorsession_establishment_failed;403 forbidden;413 payload_too_large;429 rate_limited;500 audience_not_configured) — unless the request'sAcceptnamestext/htmland notapplication/json, as a browser navigation and the confirm form's POST do. That caller gets a303to/{locale}/sso/confirm?error=<code>&requestId=<id>, which shows the failure in the user's language (F-85).token_expiredmeans the token is pastexpor the 60 s age ceiling, or its nonce row expired first (audit reasonnonce_expired);token_already_usedmeans only a nonce already burned (nonce_replay). A verified token with no nonce row for this application isinvalid_token(nonce_unknown). - Rate limits.
launchis throttled per principal (session user id, or trusted client IP while signed out);consumeGET and POST are throttled per trusted client IP. Both use the standard 30-burst / 1 per second budget and answer429 rate_limitedwith aRetry-Afterheader before anysso.*audit row is written. The per-IP budgets (consume, and a signed-out launch) are shared by every instance, with no deployment-wide floor behind them, so traffic from other client IPs cannot refuse your handoff (F-19; Architecture → Rate limiting).
- API-key and OAuth-client secrets are returned once at creation/rotation and stored only as hashes. There is no endpoint to retrieve them again.
- Never log or echo a plaintext credential; the audit log records metadata only.
| Symptom | Cause / fix |
|---|---|
401 from /api/v1/* |
Missing/invalid bearer; or the path is disabled (API_KEYS_ENABLED / API_JWT_ENABLED are off by default). |
403 with an unexpected scope error |
The credential's scope doesn't cover the endpoint, or exceeds the owner's permissions (it's capped to the creator). |
401/403 from /api/administrator/* |
401 unauthenticated: no session cookie and no bearer (or a revoked/expired one). 403 untrusted_origin: a cookie-session mutation without a trusted Origin header — add it (CSRF guard). 403 forbidden: missing permission, or a bearer whose scopes don't cover it. |
404 for a resource you know exists |
Tenant scoping — an org admin can't see other orgs; out-of-scope is 404 by design. |
412 from a v1 mutation or an admin organization/role/group PATCH |
Stale If-Match ETag — re-GET the resource and retry with the fresh ETag. |
429 |
Rate-limited; respect the Retry-After header. |
| Generated client types don't match responses | Regenerate from the current spec; list/detail endpoints return snake_case rows, create endpoints camelCase summaries (both modeled). |
pnpm sdk:admin:generate fails |
It needs Java and network access (openapi-generator). The committed client doesn't. |
- The committed OpenAPI 3.1 specs are authoritative for exact request/response shapes —
docs/openapi.json(/api/v1) anddocs/openapi-admin.json(/api/administrator). They are written by hand in the builders, not regenerated from the handlers. CI checks that the committed files and the admin SDK match the builders; the fix when that check fails ispnpm openapi:export(both specs) and, for the admin surface,pnpm sdk:admin:generate, then commit. A handler that answers a shape its operation does not declare passes that check: pin the answer in the route's test withexpectResponseMatchesSpec(tests/helpers/openapi-response.ts), as the create and revoke routes do (F-74). - The
account.*scopes are also enumerated inx-account-scopesofdocs/openapi.json; the supportedexportresources/formats are described by the/api/administrator/export/[resource]operation indocs/openapi-admin.json. - Regenerate your downstream client whenever the relevant spec changes — watch
docs/openapi.json/docs/openapi-admin.json, or theversionin the spec'sinfo.
Dark by default (MCP_ENABLED), like the machine API it fronts. Flags: Configuration → AI agent gateway (MCP). Full design: Design: MCP Agent Gateway.
/api/mcp exposes the v1 machine API to AI agents over the Model Context Protocol — a stateless Streamable HTTP JSON-RPC 2.0 endpoint. It is a translation layer, not a second API: every tool call funnels through the same permission ∩ scope guard as /api/v1, so the MCP surface can never exceed the machine API's authority.
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/api/mcp |
POST | Bearer (API key, or a JWT minted with resource=<origin>/api/mcp) |
JSON-RPC: initialize, tools/list, tools/call, ping |
/api/mcp |
GET | — | 405 — no server-initiated stream |
/api/mcp/register |
POST | Public (rate-limited) | RFC 7591 agent self-registration; gated by MCP_REGISTRATION_ENABLED |
/.well-known/oauth-protected-resource/api/mcp |
GET | Public | RFC 9728 protected-resource metadata, at the path-suffixed location the 401 challenge names |
/.well-known/oauth-protected-resource |
GET | Public | The same document, for clients of older MCP revisions that look only here |
/.well-known/oauth-authorization-server |
GET | Public | RFC 8414 authorization-server metadata |
- Bearer-only. A cookie session is rejected (it is not an audience-bound OAuth token). An unauthenticated call returns
401withWWW-Authenticate: Bearer …, resource_metadata="…/.well-known/oauth-protected-resource/api/mcp", so a compliant client auto-discovers the token endpoint (/api/v1/auth/token) and JWKS. A token that was presented and refused (expired, unknown, revoked) also getserror="invalid_token", so the client knows to fetch a new one (I-04). A valid credential whose owner is suspended, deactivated or pending, or whose membership in the credential's org is not active, gets403with JSON-RPC error-32003for every method, as/api/v1refuses it (I-15). A JSON-RPC request must carry a string or integerid: MCP forbidsnull, which is-32600. - Audience-bound (RFC 8707). A JWT must have been requested with
resource=<origin>/api/mcp— the identifier advertised asresourcein the protected-resource metadata. A token minted for/api/v1gets401witherror="invalid_token"and anerror_descriptionnaming the resource to request, unless the operator hasMCP_AUDIENCE_GRACE=1on for a migration window. API keys are not audience-bound. - Tools are generated from the OpenAPI spec. Each scoped
/api/v1operation becomes one MCP tool (~18 today), carrying every scope itssecurityrequires (all of one set, or any one of several:createUserneedsadmin.users.createwithadmin.users.updateoradmin.orgs.manage, F-480) and the operation's description when it has one; a new scoped endpoint becomes a tool for free.issueToken,getJwks, andgetOpenApiare excluded. An array argument (sort,filter[…]) is sent as one query parameter per value, and a value outside the spec'senumis refused (-32602) before the API is called (F-34). Header parameters are not tool arguments:setUserStatusruns withoutIf-Match, and its description says so.tools/listreturns only the tools the credential's scopes allow (anadmin.*scope must also be held as a permission), so a zero-scope agent is offered none (I-04).createMyApiKey,rotateMyApiKey,createOauthClientandrotateOauthClientSecretreturn a plaintext secret, which lands in the model's context and the host's transcript; their descriptions say so. - Tool calls are rate-limited, reads are not audited. Each
tools/callis charged to a bucket per source credential (60-call burst, then 1 per second; every token minted from one client shares it). A spent bucket answers429withRetry-Afterand JSON-RPC error-32029. The v1 route behind a tool audits an admin permission/scope denial (api.access.denied, naming the credential) and every mutation. It writes no audit row for a successful read, a refused token, anaccount.*scope denial, a status or membership refusal or a429, and the gateway's own refusals (an unknown tool, a bad argument, a spent bucket) are not audited either (F-76). - Every request is throttled per IP and size-capped before it is authenticated. The body is parsed before the bearer check, so a per-IP bucket (300 burst, then 5 a second, shared across instances) runs first, then the body is read through a 1 MiB cap: a spent bucket is
429withRetry-Afterand-32029, a larger body is413with-32600./api/mcp/registercaps its body at 64 KiB (413 invalid_client_metadata) behind its own limiter (F-78), whose429 temporarily_unavailablecarriesRetry-Aftertoo (F-130). - Every response carries an
x-request-id, on the transport and on registration, including the500a fault answers: a JSON-RPC-32603"Internal error" with the id inerror.data.requestId, or an RFC 7591{ "error": "server_error" }. Quote it when reporting a failure; the server log line (mcp.internal_error/mcp.register.internal_error) carries it (A-12). - Self-registration is safe by construction. A newly registered agent gets a machine service account + a zero-scope OAuth client. In
approvalmode (default) it cannot mint a token until an admin activates it; inopenmode it is active but every tool403s until scopes are granted. Admins approve, scope, and revoke agents from Administrator → Agents (Admin Manager §8.13), and a scheduled reaper expires registrations left pending longer thanMCP_REGISTRATION_PENDING_TTL_DAYS. A request may only name anorganizationthe operator opened (the default org orMCP_REGISTRATION_ALLOWED_ORGS); the per-org quota counts self-registered agents only and is enforced atomically.
The MCP surface is not modeled in the OpenAPI specs (it is JSON-RPC, not REST) — the design doc is authoritative.
See also: Design: API Keys & Access Tokens · Administrator Console — Specification · Configuration for the environment variables these endpoints depend on.