← Documentation

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:

ModulePrefix
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

The server registers @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/auth and verified by @fastify/jwt, either as a Bearer header or the realmsso_token cookie. 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-Key header — never as a Bearer token. The server hashes the key with SHA-256 and looks up the ApiKey row by that hash — the raw key is never stored and is only ever returned once, in the create response. A missing header is MISSING_API_KEY; a key that is disabled, expired, or simply not found is INVALID_API_KEY. Both are 401.

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 minting an API key. 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, including the two API-key management routes whose tier is still being decided.

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

Five list endpoints return a bare { 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." } ]
  }
}
CodeStatusMeaning
VALIDATION_ERROR400Request body/query failed Zod (or Fastify) schema validation. Only the Zod branch carries a details array — a hand-thrown validation error has message only
UNAUTHORIZED401Missing or invalid vendor token, or an invalid admin-portal session
MISSING_API_KEY401A route that accepts an API key was called without an X-API-Key header
INVALID_API_KEY401The X-API-Key header did not match an enabled, unexpired key
MISSING_TOKEN401An admin-portal route was called with no portal token
FORBIDDEN403Authenticated, but not a member of the target account — or an API key missing a required connections:* scope
NOT_FOUND404Resource id does not exist
CONFLICT409Unique constraint violation (e.g. account slug already taken)
RATE_LIMITED429Too many requests from this client
EXTERNAL_SERVICE_ERROR502A downstream dependency (e.g. Keycloak) failed
INTERNAL_ERROR500Unhandled 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.