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
/api/v1/accounts| Field | Type | Notes |
|---|---|---|
| name | string | Required, 1–255 characters |
| slug | string | Required, 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. |
| domain | string | Optional. Recorded as a claim and left unverified — see below. |
| logo | string (URL) | Optional |
| brandColor | string | Optional, 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
400 and names the specific problem.The edge does not bind a host to a realm yet
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
/api/v1/accountsReturns 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
/api/v1/accounts/:idReturns the account plus its connection count, or 404 if it doesn't exist.
Update account
/api/v1/accounts/:idAccepts 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
/api/v1/accounts/:idDestructive
204 on success.List an account's connections
/api/v1/accounts/:id/connectionsA 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
/api/v1/accounts/:id/domain/claimRecords 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
/api/v1/accounts/:id/domain/verifyChecks 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).