API Overview
Conventions shared by every RealmSSO REST endpoint: base paths, authentication, pagination, and error format.
Base paths
The RealmSSO server (Fastify) exposes each module under its own versioned prefix, mounted in src/app.ts. There is no single catch-all API prefix — clients target the module path directly:
| Module | Prefix |
|---|---|
| Accounts | /api/v1/accounts |
| Connections | /api/v1/connections |
| SCIM | /api/scim/v2 |
| API Keys | /api/v1/api-keys |
| Auth | /api/v1/auth |
| Admin Portal | /api/v1/admin-portal |
| SSO Integration | /api/v1/sso |
| Observability | /api/v1/observability |
| Audit | /api/v1/audit |
| Webhooks | /api/v1/webhooks |
| Agent Clients | /api/v1/agent-clients |
| Products | /api/v1/products |
Liveness and readiness checks are mounted at the server root — GET /health and GET /readyz — not under /api/v1, since they exist for the load balancer and orchestrator, not API clients.
Interactive Swagger UI — non-production only
@fastify/swagger and @fastify/swagger-ui and serves a live, interactive OpenAPI explorer at /docs on the server itself (e.g. http://localhost:4000/docs) — a different origin than this documentation site. Use it to try requests directly against a running instance. The UI is only mounted when NODE_ENV is not production, so on a production deployment /docs returns 404 and is not a way to explore a live install.Authentication
Most of the API accepts one of two credential types, and which one a route accepts depends on the module. Connections, SSO Integration, Agent Clients, Webhooks, Observability and Audit accept either — though two routes are deliberately unauthenticated, because their caller has no credential to present: GET /api/v1/sso/resolve, which a login page calls before anyone has signed in, and GET /api/v1/connections/:id/initiate, the IdP-initiated sign-on URL an admin reaches from their identity provider's app tile. Observability accepts either on its four account-scoped reads, but not on GET /api/v1/observability/realm-co-residency, which is platform-operator authority and refuses a key whatever it holds. API Keys and Products accept only a vendor session, and minting a key is vendor-session only by design. Accounts accepts only a vendor session on every route but one — POST /api/v1/accounts, the bootstrap route, also accepts a platform-level key. Two modules use their own separate scheme instead: SCIM authenticates with a per-account bearer token (see SCIM API), and the Admin Portal accepts its own token via an X-Admin-Token header — then a short-lived session via X-Admin-Session or a cookie — rather than a vendor session or API key (see Magic Link Authentication).
- Vendor session (JWT). Issued by
/api/v1/authand verified by@fastify/jwt, either as aBearerheader or therealmsso_tokencookie. Tokens expire after 24 hours. This is what the vendor dashboard uses on behalf of a logged-in user. - API key. Sent as an
X-API-Keyheader — never as aBearertoken. The server hashes the key with SHA-256 and looks up theApiKeyrow by that hash — the raw key is never stored and is only ever returned once, in the create response. A missing header isMISSING_API_KEY; a key that is disabled, expired, or simply not found isINVALID_API_KEY. Both are401.
Every API key carries a scopes array (e.g. connections:read, webhooks:write, or the wildcard *) recorded on the key. Every scope string is validated against an allow-list when the key is created. On every account-scoped route, authorization for the target account (requireOrgAccess) is checked as well.
That check is not the same for the two credentials. A vendor session must hold an AccountMembership row on the account and a role at least as high as the minimum the route names — viewer on every account-scoped GET, member on the mutating routes, and admin on setting an account's connection-error policy and on the whole API-key lifecycle — minting, re-scoping and revoking. An API key has no membership row and is not mapped onto a role: it is checked against the one account it was minted for, and then against its scopes. See Access Control for the tiers in full.
Every scope in the registry is enforced on the module it names
A shared requireApiKeyScopes() guard runs at the request boundary and returns 403 when a key lacks the scope a route requires. It is wired into 36 routes across seven modules: Connections (12), Webhooks (8), SSO Integration (4), Agent Clients (4), Observability (4), Audit (3), and the platform POST /api/v1/accounts bootstrap path (1). Connections and SSO routes require connections:read, connections:write, or connections:delete; Agent Clients routes require agent-clients:read or agent-clients:write; webhook reads require webhooks:read and every webhook mutation requires webhooks:write, including test and redeliver, both of which cause an outbound send; Observability requires observability:read and Audit audit:read; the bootstrap path requires platform:accounts:create, and does not accept * as a substitute. The guard applies to API keys only — a vendor JWT carries no scopes.
Until 2026-09-07 this page listed nine further scope strings as declared intent. Five of them — accounts:read, accounts:write, scim:read, scim:write and admin-portal:generate — were deleted from the registry rather than wired up, because nothing consulted them and nothing was going to: SCIM uses its own per-account bearer token and the Admin Portal its own session token, so neither is reachable with an API key. Naming one of the five when creating or updating a key is now a 400. The other four — webhooks:read, webhooks:write, observability:read and audit:read — became real grants on the modules above. Minting a key remains vendor-JWT only.
Products is vendor-session-only for a different reason. Its mutating routes go through a platform-operator check that refuses an API key outright, whatever scopes the key holds, and the product handout is a human-authority read. See Access Control for both authorities.
Request envelope & pagination
Single-resource responses are wrapped as { data: ... }. Paginated list endpoints add a meta block:
{
"data": [ /* ... */ ],
"meta": { "page": 1, "limit": 20, "total": 42, "totalPages": 3 }
}page and limit are optional query parameters, and limit is capped server-side regardless of what the caller requests: 100 on Accounts, Connections, and webhook deliveries; 200 on Audit and the observability event feed.
Not every list is paginated
{ data: [...] } with no meta and no page/limit support: GET /api/v1/accounts/:id/connections, GET /api/v1/api-keys, GET /api/v1/webhooks, GET /api/v1/admin-portal/connections, and GET /api/v1/observability/connection-health. Do not write a client that assumes meta is always present. GET /api/v1/observability/timeline is a third shape again — it returns granularity as a sibling of data rather than inside meta. SCIM responds in SCIM's own envelope; see SCIM API.Error format
Every error on the /api/v1 surface — thrown application errors, Zod validation failures, Fastify-level parsing errors, and rate-limit rejections — is normalized by a single Fastify error handler into the same shape. SCIM is the exception: it answers in SCIM's own error envelope (see SCIM API).
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [ { "path": "slug", "message": "Slug is used as a DNS label in this account's SSO hostname, and this one starts or ends with a hyphen, which no DNS label may do." } ]
}
}| Code | Status | Meaning |
|---|---|---|
| VALIDATION_ERROR | 400 | Request body/query failed Zod (or Fastify) schema validation. Only the Zod branch carries a details array — a hand-thrown validation error has message only |
| UNAUTHORIZED | 401 | Missing or invalid vendor token, or an invalid admin-portal session |
| MISSING_API_KEY | 401 | A route that accepts an API key was called without an X-API-Key header |
| INVALID_API_KEY | 401 | The X-API-Key header did not match an enabled, unexpired key |
| MISSING_TOKEN | 401 | An admin-portal route was called with no portal token |
| FORBIDDEN | 403 | Authenticated, but not a member of the target account — or an API key missing a required connections:* scope |
| NOT_FOUND | 404 | Resource id does not exist |
| CONFLICT | 409 | Unique constraint violation (e.g. account slug already taken) |
| RATE_LIMITED | 429 | Too many requests from this client |
| EXTERNAL_SERVICE_ERROR | 502 | A downstream dependency (e.g. Keycloak) failed |
| INTERNAL_ERROR | 500 | Unhandled server error |
Rate limiting & CORS
@fastify/rate-limit caps every client to RATE_LIMIT_MAX requests (default 100) per RATE_LIMIT_WINDOW_MS (default 60 seconds), keyed on the request's resolved IP. The app trusts exactly one proxy hop — the ingress in front of the pod — as a predicate, trustProxy: (_address, hop) => hop === 0, so a client cannot spoof its own rate-limit bucket by setting X-Forwarded-For itself. Not trustProxy: 1: fastify 5.12.1 removed the numeric form from the type union and compiles a number to () => false at runtime (GHSA-3m5p-2c4r-xxw2), so the number fails closed and buckets every caller as the ingress address. Allowed browser origins are configured via the CORS_ORIGINS environment variable (comma-separated), and @fastify/helmet plus a small set of custom headers (X-Frame-Options, X-Content-Type-Options, HSTS) are applied to every response.
See Accounts API, Connections API, SCIM API, and Webhooks API for the endpoints themselves, or Accounts (Tenants) for the realm-per-account model those endpoints operate on.