← Documentation

Accounts API

Manage tenant accounts — each one provisions its own Keycloak realm on creation.

All routes live under /api/v1/accounts and accept a vendor session (Authorization: Bearer <token> or the realmsso_token cookie). Every route that names a target account also checks that the caller belongs to it and holds at least the role that route requires — viewer for the two reads below, member to update or delete an account and to claim or verify a domain. The list route needs no target: it returns only the accounts the caller is a member of. Roles rank viewer < member < admin, so an admin passes every check on this page — see Access Control for the tiers in full, and Accounts (Tenants) for the realm-per-account tenancy model these endpoints provision.

One route also accepts an API key, and only one: POST /api/v1/accounts, the bootstrap-from-zero call. It takes a platform-level key — one belonging to no account — carrying the platform:accounts:create scope, which the wildcard * does not substitute for. Every other route in this module refuses a key at authentication with 401, before any scope or membership is examined, and that refusal is structural: the bootstrap route is registered as its own plugin so no route added beside the others can pick up API-key authentication by accident. So a platform-level key can create an account and then cannot read, update or delete it — not even the account it just created. Reaching an account's data afterwards takes a separately granted credential — and for the routes on this page, that means a vendor session with membership on it. The Accounts module is vendor-session only: an ordinary account-scoped key reaches the other modules' routes, not these.

Create account

POST/api/v1/accounts
FieldTypeNotes
namestringRequired, 1–255 characters
slugstringRequired, 3–63 characters. It is used as a DNS label, so lowercase letters, digits and internal hyphens only — no leading or trailing hyphen, no xn-- prefix, and not a reserved label. Must be unique.
domainstringOptional. Recorded as a claim and left unverified — see below.
logostring (URL)Optional
brandColorstringOptional, hex color (e.g. #1a2b3c)

The caller is recorded as an admin member of the new account in the same database transaction — otherwise the creator would have no access to the account they just made. After the transaction commits, RealmSSO attempts to provision a dedicated Keycloak realm named from the slug. If Keycloak provisioning fails, the account row is still returned (201) with keycloakRealm: null — the realm is created lazily on a later connection or retried by an admin, rather than failing the whole request.

The slug is a hostname label, not just a URL segment

The slug becomes the leftmost label of this account's SSO hostname and part of a certificate subject. That is why the rules above are stricter than “URL-safe”: a slug that is not a valid DNS label produces a hostname that cannot resolve and a certificate the CA refuses. A rejected slug returns 400 and names the specific problem.

The edge does not bind a host to a realm yet

The hostname is stored on the account, but nothing at the edge ties it to that account's realm. ADR 0013 (Accepted 2026-09-02) decides the mechanism — a static host-to-realm map generated from the stored columns, applied as ordinary edge configuration — and that map does not exist on server main. Until it is built, Keycloak serves any realm on any tenant host: the hostname is a label, not a boundary. Do not treat a per-account hostname as tenant isolation. The realm is the tenant boundary, and it stays the tenant boundary.

List accounts

GET/api/v1/accounts

Returns accounts the authenticated user belongs to (not every account on the server), paginated via ?page and ?limit (max 100). Each item includes a _count.connections field.

Get account

GET/api/v1/accounts/:id

Returns the account plus its connection count, or 404 if it doesn't exist.

Update account

PATCH/api/v1/accounts/:id

Accepts a partial body of name, domain, logo, and brandColor (each nullable to clear it). Does not touch the Keycloak realm. Setting domain here runs the same claim logic as the claim endpoint below, so a new domain string never inherits the previous domain's verified status.

Delete account

DELETE/api/v1/accounts/:id

Destructive

Deletes the account row (cascading to its connections) and then tears down the associated Keycloak objects — the dedicated realm if one was provisioned for this account, or just this account's identity providers and client if it is still pinned to the legacy shared realm. Keycloak cleanup runs after the database delete commits and is best-effort: a cleanup failure is logged and recorded but does not fail the request, since the row is already gone. Returns 204 on success.

List an account's connections

GET/api/v1/accounts/:id/connections

A convenience summary endpoint — returns every Connection row for the account, unpaginated. See Connections API for the full connections resource, including create/update/delete and per-connection health checks.

Claim a domain

POST/api/v1/accounts/:id/domain/claim

Records an email domain for this account and issues a fresh DNS TXT challenge. The body is { "domain": "acme.com" }. The response is 202, not 200: the claim is stored, but ownership is not yet proven. Publish the returned TXT record, then call the verify endpoint. Two accounts cannot hold the same domain — a collision returns 409.

Verify a claimed domain

POST/api/v1/accounts/:id/domain/verify

Checks the claimed domain's TXT challenge against real DNS now, and records verification on a match. A non-match is a normal 200 with verified: false, not an error, because DNS propagation takes time. This endpoint is safe to poll.

Verification is what makes a domain usable for login routing. GET /api/v1/sso/resolve matches a verified domain only, and treats a claimed-but-unverified domain exactly like a domain nobody has claimed. See Accounts (Tenants).