API Reference

Complete reference for the Headless Auth Platform HTTP API. It contains everything an external project needs to call the service — addressing, authentication, every endpoint, and the error model — without reading the source. For a guided tutorial see the Integration guide; for a first run see Getting Started.

  • API style: REST over HTTPS, JSON request/response (OAuth token/SCIM use form/JSON as noted). OpenAPI: 3.1.0. Version: 0.1.0.
  • Machine-readable spec / SDK generation: the running service serves its own document at {HAP_ISSUER_BASE_URL}/openapi.json (for the hosted instance, https://hap.wbsp.ai/openapi.json). That is the authoritative inventory of paths and operations, and the one to check when you need to know whether a route exists — it is generated by the service, so it cannot lag it. Every other copy, including this documentation set as published on the wiki, is a manual snapshot and can be older than the service; a route absent from a copy is evidence about the copy, not about the service. In this repository the same document is target/openapi/openapi.json (regenerate with scripts/export_openapi.py); count from one of the two rather than hand-maintaining a number here. Import either into Postman/Insomnia or generate typed clients. Note: the spec does not declare OpenAPI securitySchemes, so generated SDKs will not enforce auth on their own — attach the OAuth 2.0 bearer token yourself.
  • Accuracy: every operation below exists in the running service today. Planned features (LDAP login, device-code grant, SAML SP-initiated AuthnRequest/SLO) are not included.
  • Secrets: all examples use placeholders like <TENANT_ID>, <CLIENT_ID>, <ACCESS_TOKEN> — never put real secrets in shared commands. Examples assume a base URL of http://localhost:8000.

1. Overview: base URL & addressing model

The API has two address spaces:

SpacePatternExamples
Per-tenant/t/{tenant_slug}/…discovery, JWKS, all oauth2/* endpoints
Global/v1/…, /scim/v2/…, /healthz, /readyzadmin, auth flows, sessions, MFA, etc.
  • Each tenant is an isolated environment with its own users, signing keys, and policies. A tenant's issuer is {HAP_ISSUER_BASE_URL}/t/{tenant_slug} (e.g. http://localhost:8000/t/acme).
  • Global /v1/* endpoints identify the tenant via a header (X-Tenant-Id) or, for OAuth endpoints, via the tenant slug in the path.
  • The platform ships with no tenants; create one with POST /v1/tenants (see §6.9).

Content types: JSON (application/json) for most endpoints; the OAuth token, introspect, revoke, authorize (POST), and SAML ACS endpoints accept form-encoded bodies (application/x-www-form-urlencoded), per the relevant standards.

In-cluster consumers: split the network path from the issuer identity

If your app calls this service from inside the same Kubernetes/EKS cluster, do not use the public ingress URL (e.g. https://auth.wbsp-demo.com) for the HTTP calls. Pods generally cannot reach their own cluster's public load balancer (LB hair-pinning is not supported on EKS by default) — requests to the public URL time out.

Use a split-horizon configuration:

  • Network path → the in-cluster Service. Make every HTTP call (discovery, JWKS, OTP, authorize, token, userinfo, admin) to the cluster-internal Service address. The platform injects it when you declare a uses: dependency on this app (feature 034): the exposed auth port surfaces as HEADLESS_AUTH_AUTH_HOST / HEADLESS_AUTH_AUTH_PORT (i.e. test-headless-auth.<namespace>.svc.cluster.local:8080).
  • Identity → the public issuer. Keep validating ID/access tokens against the public issuer ({HAP_ISSUER_BASE_URL}/t/{tenant_slug}, e.g. https://auth.wbsp-demo.com/t/wbsp) for iss/aud. Tokens are signed with the issuer baked in, so the trusted identity is always the public URL regardless of which network path fetched them. If your OIDC library derives endpoints from the discovery document (which advertises public URLs), override the endpoint host with the in-cluster Service for the actual calls while leaving iss/aud checks pointed at the public issuer.

Apps reached from outside the cluster (browsers, server-to-server over the internet) use the public URL for everything as normal.


2. Authentication

There are five ways a request proves who it is. Each endpoint below states which it needs.

SchemeHow to send itUsed forHow to obtain
Admin keyX-Admin-Key: <key> (+ X-Tenant-Id: <uuid> for tenant-scoped admin)Provisioning & admin APIsSet as HAP_ADMIN_API_KEY when deploying
Tenant headerX-Tenant-Id: <tenant uuid>Public end-user flows (register/login/verify/reset, MFA, WebAuthn, federation, SCIM)Returned by POST /v1/tenants
End-user bearerAuthorization: Bearer <access_token>userinfo, /v1/sessionsFrom the OAuth token endpoint
OAuth client authclient_id (+ client_secret or HTTP Basic) in the token/introspect/revoke requestConfidential clients on token endpointsPOST /v1/applications (secret shown once)
SCIM bearerAuthorization: Bearer <scim_token>/scim/v2/*Returned when creating a SCIM-enabled provider
Login tokenlogin_token field (a short-lived session token)/oauth2/authorize, MFA, WebAuthn register, saml/issueReturned by POST /v1/auth/login (or a WebAuthn login)

Public clients (SPA/native) use PKCE only (no client secret). Outbound webhook deliveries are HMAC-signed with X-HAP-Signature (see §6.12) — that is for your receiver to verify, not an inbound scheme.

Auth quick reference by group:

GroupAuth
Health, Discovery, JWKSnone
OAuth authorizelogin_token (the authenticated user)
OAuth token/introspect/revokeOAuth client auth (PKCE for public clients)
OAuth userinfoend-user bearer
Auth flows (register/login/verify/reset)X-Tenant-Id
MFA, WebAuthnX-Tenant-Id + login_token (in body)
Federation (providers/saml)X-Tenant-Id
Sessions (own)X-Tenant-Id + end-user bearer
Identities, RBAC, Sessions (admin), Webhooks, AI, applications, providersadmin key (+ X-Tenant-Id)
Tenantsadmin key (platform)
SCIMSCIM bearer + X-Tenant-Id
Sign-up settings (§6.21), tenant-issued invitations (§6.13)admin key with the narrow scope, or a tenant-admin session bearer — both with X-Tenant-Id

3. Errors

OAuth/OIDC endpoints return the RFC 6749 §5.2 JSON shape; other endpoints return a similar body. The HTTP status conveys the category.

{ "error": "invalid_request", "error_description": "human-readable detail" }
StatusTypical errorMeaning / action
400invalid_request, invalid_grant, invalid_scope, unsupported_grant_typeMalformed/invalid request; fix parameters.
401invalid_client, invalid_token, login_requiredMissing/invalid credentials or session; authenticate.
403access_deniedAuthenticated but not permitted (scope/tenant).
403access_denied + error_code: "application_access_denied"Authenticated, but this identity is not permitted to use this application (see §6.9). Distinct from a credentials failure; on /oauth2/authorize the same fields are returned as redirect query params.
404invalid_requestUnknown tenant/resource.
409invalid_request (conflict)Duplicate (e.g. email already exists).
422invalid_request + failed_rulesValidation failed (e.g. password policy).
429temporarily_unavailableRate limited; honour Retry-After.

Interaction signals on /oauth2/authorize (login_required, consent_required, interaction_required) are normal flow, returned as 401 JSON for your client to act on (log the user in, show consent, or run step-up), not hard errors.


4. Health

GET /healthz

Liveness. Auth: none. 200{"status":"ok"}.

GET /readyz

Readiness (checks database + cache). Auth: none. 200{"status":"ok","checks":{"database":"ok","redis":"ok"}}; 503 if a dependency is down.

curl -s http://localhost:8000/readyz

5. Discovery & OAuth 2.0 / OpenID Connect (per tenant)

All paths are under /t/{tenant_slug}/.

GET /t/{tenant_slug}/.well-known/openid-configuration

GET /t/{tenant_slug}/.well-known/oauth-authorization-server

OIDC Discovery / OAuth AS metadata. Auth: none. 200 → JSON with issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, introspection_endpoint, revocation_endpoint, grant_types_supported (authorization_code, refresh_token, client_credentials), code_challenge_methods_supported (["S256"]), and id_token_signing_alg_values_supported.

curl -s http://localhost:8000/t/acme/.well-known/openid-configuration

GET /t/{tenant_slug}/oauth2/jwks

Public signing keys (JWKS) for verifying issued tokens. Auth: none. 200{"keys":[{"kty","use":"sig","alg","kid",…}]}.

GET|POST /t/{tenant_slug}/oauth2/authorize

Authorization-code request with PKCE. Auth: login_token (the authenticated user). Query (GET) or form (POST) params: response_type=code (required), client_id, redirect_uri (exact match; loopback exception below), scope (e.g. openid profile email), state, code_challenge, code_challenge_method=S256, nonce (optional), login_token, consent (granted), acr_values (optional, e.g. aal2). Success: 302 redirect to redirect_uri?code=<code>&state=<state>. Signals: 401 login_required (authenticate first), consent_required (+ scopes), interaction_required (+ required_acr — run step-up).

On the Hosted Login surface these signals are resolved for you, each by the thing that can actually resolve it (hosted_login_enabled tenants only; the headless behaviour above is unchanged):

SignalHosted behaviour
login_required, not signed inRedirect to the hosted sign-in page, resuming afterwards
consent_required, signed inRedirect to the hosted consent screen; Allow resumes, Deny returns error=access_denied
interaction_required, signed inTerminal page (200, no redirect) — hosted step-up is not offered, so this now explains rather than looping
any signal with prompt=noneSilent failure back to redirect_uri?error=…, checked first; no screen is ever rendered

consent=granted remains supported and unchanged for every client, public and confidential alike — send it and no screen appears. It is no longer required: omitting it on the hosted surface asks the person instead. (Before 2026-08, omitting it caused an infinite sign-in loop with no error.)

curl -si "http://localhost:8000/t/acme/oauth2/authorize?response_type=code\
&client_id=<CLIENT_ID>&redirect_uri=https://app.example/callback\
&scope=openid%20profile%20email&code_challenge=<CHALLENGE>&code_challenge_method=S256\
&login_token=<LOGIN_TOKEN>&consent=granted" | grep -i location

Loopback redirect URIs — any port (RFC 8252 §7.3)

A native or command-line app binds a temporary listener to an OS-assigned ephemeral port that can't be known at registration time. So if an application registers a loopback redirect_uri — host 127.0.0.1, [::1], or localhost — the request may use any TCP port on that URI:

  • Register once with no port (http://127.0.0.1/callback) or any placeholder port. Every run may then present a different port (http://127.0.0.1:54213/callback, http://[::1]:49912/callback, http://localhost:3000/callback) with no re-registration. An app already registered with a specific loopback port also gains any-port matching (the registered port is disregarded).
  • Only the port is ignored. Scheme, host literal, path, and query must still match the registered URI exactly, so a different path/scheme, a look-alike host (127.0.0.1.attacker.example, localhost.attacker.example), a non-loopback host, or an out-of-range port (:0, :99999) is rejected with invalid_request (no redirect). http is allowed on loopback (TLS isn't required there).
  • The redirect_uri sent to /oauth2/token must still be identical (port included) to the one used at /oauth2/authorize — the two legs describe the same running listener.

Non-loopback redirect URIs are unaffected and continue to require an exact match.

POST /t/{tenant_slug}/oauth2/token

Exchange a code / refresh token / client credentials for tokens. Auth: OAuth client auth (confidential clients send client_secret or HTTP Basic; public clients use PKCE). Body (form):

  • grant_type=authorization_code: code, redirect_uri, code_verifier, client_id.
  • grant_type=refresh_token: refresh_token, client_id.
  • grant_type=client_credentials: client_id, client_secret.

200

{ "access_token":"<jwt>", "token_type":"Bearer", "expires_in":3600,
  "scope":"openid profile email", "id_token":"<jwt>", "refresh_token":"<opaque>" }

(id_token only when openid scope was granted; refresh_token when enabled.) Reusing a rotated refresh token revokes the whole token family.

curl -s -X POST http://localhost:8000/t/acme/oauth2/token \
  -d grant_type=authorization_code -d code=<CODE> \
  -d redirect_uri=https://app.example/callback -d code_verifier=<VERIFIER> -d client_id=<CLIENT_ID>

Administrator claims

The id token and userinfo both carry two independent booleans. They are always present — never omitted when false — so a relying party can distinguish "HAP says no" from "HAP said nothing". Neither requires a scope.

  • is_app_admin — the user is a designated administrator of the application this token was issued to (an application_admins row).
  • is_tenant_admin — the user carries the tenant-wide administrator flag.

HAP asserts no implication between them. A tenant admin may in fact manage every application in the tenant, but is_app_admin reports only an explicit per-application designation, so a tenant admin who was not designated for this application gets is_app_admin: false. A relying party that wants "either one counts" must OR the two itself. Both claims are false while the identity is not active, so a suspended administrator's still-valid access token reports no authority.

An id token is only issued at sign-in (a refresh does not mint one), so read userinfo with the user's access token to observe a promotion or revocation without a fresh sign-in.

GET /t/{tenant_slug}/oauth2/userinfo

Claims for the access token's user. Auth: end-user bearer (header only; query-string tokens are rejected). 200{"sub","email","email_verified","name",…} (claims per granted scopes) plus is_app_admin / is_tenant_admin (always present; see above).

curl -s http://localhost:8000/t/acme/oauth2/userinfo -H "Authorization: Bearer <ACCESS_TOKEN>"

POST /t/{tenant_slug}/oauth2/introspect

Token introspection (RFC 7662). Auth: OAuth client auth. Body (form): token, client_id (+ secret). 200{"active":true,"scope","client_id","sub","exp","token_type"} or {"active":false}.

POST /t/{tenant_slug}/oauth2/revoke

Revoke an access or refresh token (RFC 7009). Auth: OAuth client auth. Body (form): token, client_id (+ secret). 200{"status":"ok"} (revoking a refresh token revokes its session/family; an access token's jti is denied until expiry).


6. Endpoint catalogue (global /v1 and /scim/v2)

6.1 Auth flows — X-Tenant-Id

Method & pathBodySuccess
POST /v1/auth/register{email, password}201 IdentityResponse
POST /v1/auth/login{email, password}200 {login_token, session_id, token_type}
POST /v1/auth/verify-email/request{email}202 (no body; no enumeration)
POST /v1/auth/verify-email/confirm{token}200 {status:"verified"}
POST /v1/auth/password-reset/request{email}202 (always, no enumeration)
POST /v1/auth/password-reset/confirm{token, new_password}200 {status:"reset"} (revokes sessions)

Passwordless primary login (no password; returns the same login_token). Request steps are uniform 202 (no enumeration); verify failures are 401. Subject to per-tenant rate limiting and, at the subsequent /authorize, the application access gate (§6.9).

Method & pathBodySuccess
POST /v1/auth/otp/request{channel:"email"|"sms", email?, phone?}202 (code sent; uniform)
POST /v1/auth/otp/verify{channel, email?|phone?, code}200 {login_token, session_id, token_type:"login"}
POST /v1/auth/magic-link/request{email, client_id, redirect_uri} (redirect_uri must be a registered URI of the app)202 (link emailed to {redirect_uri}?magic_token=…)
POST /v1/auth/magic-link/verify{token}200 {login_token, session_id, token_type:"login"}

Codes/links are single-use, short-lived, attempt-limited. Sessions record the method (email_otp / sms_otp / magic_link) at assurance level 1. A per-tenant passwordless_jit_provisioning flag (default off) controls whether a first passwordless contact with an unknown identifier creates the identity. (TOTP/authenticator stays a second factor; passkeys under §6.5 already cover passwordless-primary.)

IdentityResponse = {id, email, email_verified, name, given_name, family_name, username, status, is_tenant_admin, mfa_enabled, custom_claims, created_at} — never includes credential material.

is_tenant_admin is the designation as stored, not the authority as effective. The is_tenant_admin claim in an issued token is status == "active" AND is_tenant_admin, because a relying party must not treat a suspended administrator as an administrator. This field is the flag alone, so a caller asking "did my grant land?" still gets true for someone who was granted the designation and later suspended — otherwise it would re-grant something that was never missing. For effective authority, read status from the same object.

curl -s -X POST http://localhost:8000/v1/auth/login -H "X-Tenant-Id: <TENANT_ID>" \
  -H "Content-Type: application/json" -d '{"email":"user@acme.example","password":"<PW>"}'

6.2 Identities (admin) — X-Admin-Key + X-Tenant-Id (scope identities:*)

Method & pathNotesSuccess
POST /v1/identities{email, email_verified?, password?, name?, given_name?, family_name?, username?, custom_claims?}201 IdentityResponse (409 on duplicate)
GET /v1/identities/{identity_id}200 IdentityResponse (404 if absent)
GET /v1/identities?email=&status=&tenant_admin=&cursor=&limit=cursor pagination; tenant_admin=true returns only tenant administrators (omit for all). Filters on the designation, not on status — a suspended administrator is listed, with is_tenant_admin: true; combine with status=active for the people who can actually act200 {items:[…], next_cursor}
PATCH /v1/identities/{identity_id}{email?, email_verified?, name?, given_name?, family_name?, username?, custom_claims?, status?}200 IdentityResponse
DELETE /v1/identities/{identity_id}soft-delete + PII erasure204

Email verification (email_verified) — the flag carried in ID tokens and read by relying parties. It means "this inbox has answered": it becomes true when the user completes a passwordless email sign-in (one-time code or magic link — including through Hosted Login), accepts an invitation, confirms via POST /v1/auth/verify-email/confirm, or when an operator asserts it. On the admin surface it is optional and tri-state: omit it to leave the state alone, true to assert an address you know is good (e.g. provisioned out-of-band), false to withdraw. Changing email to a different address resets the flag to false (the old proof belonged to the old inbox) unless the same request asserts email_verified: true; a casing-only rewrite keeps it. Asserting true for an identity with no email address fails 400 email_verified_requires_email. Every change is audited (identity.email_verified for user-earned proof; the changes payload of identity.created/identity.updated for operator assertions and resets).

6.3 Sessions

Method & pathAuthSuccess
GET /v1/sessionsX-Tenant-Id + end-user bearer200 {items:[SessionResponse]}
DELETE /v1/sessions/{session_id}X-Tenant-Id + end-user bearer (must own)204 (403 if not yours)
DELETE /v1/sessionsX-Tenant-Id + end-user bearer204 (log out everywhere)
GET /v1/identities/{identity_id}/sessionsadmin (sessions:read)200 {items:[…]}
POST /v1/identities/{identity_id}/sessions:revoke-alladmin (sessions:write)200 {revoked: <n>}

SessionResponse = {id, auth_method, aal, mfa_completed, ip_address, user_agent, geo_country, geo_city, status, last_activity_at, created_at}.

6.4 MFA — X-Tenant-Id, with login_token in body

Method & pathBodySuccess
POST /v1/mfa/totp/enroll{login_token}200 {secret, otpauth_uri}
POST /v1/mfa/totp/confirm{login_token, code}200 {ok}
POST /v1/mfa/totp/verify{login_token, code}200 {ok, aal} (elevates to AAL2)
POST /v1/mfa/otp/send{login_token, channel} (email|sms)200 {ok} (429 if rate-limited)
POST /v1/mfa/otp/verify{login_token, channel, code}200 {ok, aal}

6.5 Passkeys / WebAuthn — X-Tenant-Id

Method & pathBodySuccess
POST /v1/webauthn/register/begin{login_token}200 creation options (JSON)
POST /v1/webauthn/register/finish{login_token, credential}200 {ok}
POST /v1/webauthn/login/begin{email?} (omit for usernameless)200 {options, handle}
POST /v1/webauthn/login/finish{handle, credential}200 {login_token, session_id, aal}

credential is the standard WebAuthn JSON from the browser ceremony.

6.6 Federation: social/OIDC & SAML — X-Tenant-Id

Method & pathAuth / bodySuccess
GET /v1/providers/{provider_id}/loginX-Tenant-Id200 {redirect_url} (send user there)
GET /v1/providers/{provider_id}/callback?code=&state=X-Tenant-Id200 {login_token, session_id} (401 registration_required if JIT off)
POST /v1/saml/{provider_id}/acsX-Tenant-Id, form SAMLResponse=<base64>200 {login_token, session_id} (401 on bad/replayed assertion)
GET /v1/saml/{provider_id}/metadataX-Tenant-Id200 SP metadata XML
POST /v1/saml/issueX-Tenant-Id, {login_token, client_id}200 {saml_response, acs_url} (platform as IdP)

6.7 RBAC — admin (X-Admin-Key + X-Tenant-Id)

Method & pathBodySuccess
POST /v1/rbac/roles{name, permissions:[…], is_default?}201 {id, name, permissions, is_default}
GET /v1/rbac/roles200 {items:[…]}
POST /v1/identities/{identity_id}/roles{role_id, expires_at?}201 {status:"assigned"}
DELETE /v1/identities/{identity_id}/roles/{role_id}204

Effective permissions are embedded in the access token's permissions claim.

6.8 Admin / provisioning

Method & pathAuthBodySuccess
POST /v1/tenantsadmin key (platform){name, slug, password_policy?}201 {id, slug, jwks_algorithm}
GET /v1/tenants?cursor=&limit=platform key, scope tenants:read200 {items:[TenantSummary], next_cursor} (see §6.8a)
GET /v1/tenants/{tenant_id}platform key, scope tenants:read200 TenantConfiguration (see §6.8a)
POST /v1/applicationsadmin key + X-Tenant-Id{name, client_id?, application_type, redirect_uris?, scopes?, confidential?, access_mode?, access_decision_endpoint?, access_decision_fail_open?, access_decision_timeout_ms?, access_decision_cache_ttl_seconds?, home_url?, self_enrollment_enabled?, show_in_menu?, label?, description?, icon_url?}201 {id, client_id, client_secret?, access_mode, access_decision_secret?} (secrets shown once). client_id is optional — supply your own or omit it and one is generated; see Choosing your own client identifier below. It is create-only: PATCH has no client_id and an identifier can never be changed.
PATCH /v1/applications/{application_id}admin key + X-Tenant-Id (scope application_access:write){access_mode?, access_decision_endpoint?, access_decision_fail_open?, access_decision_timeout_ms?, access_decision_cache_ttl_seconds?, home_url?, self_enrollment_enabled?, show_in_menu?, label?, description?, icon_url?}200 {id, client_id, access_mode, access_decision_secret?}
GET /v1/applicationsadmin key + X-Tenant-Id (scope identities:read)200 [{id, client_id, name, application_type, redirect_uris, post_logout_redirect_uris, scopes, confidential, access_mode, enabled, disabled_at, secret_rotated_at, home_url, self_enrollment_enabled, show_in_menu, label, description, icon_url, is_builtin}] (deleted apps excluded; never returns a secret). scopes is the set the client was registered with — it is create-only (no PATCH), so this read is how you find a client minted with too few before deciding to replace it.

Choosing your own client identifier

A client identifier is unique within a tenant, not across the platform. You may supply your own when you register an application, so one application can carry the same identifier in every tenant that runs it — and in a separate installation of the platform too. Each registration is otherwise completely independent: its own secret, its own settings, its own access decisions.

Rule
OptionalOmit client_id and one is generated, exactly as before
CharactersLetters, digits, hyphen, period, underscore, tilde — nothing else
Length8–128 characters, with at least one letter or digit
ReservedIt may not begin hap_, which is reserved for identifiers the platform issues
UniqueOnly among live applications in the same tenant; a case-variant of an existing one is refused too
PermanentIt cannot be changed after registration
RefusalStatusBody
Breaks the character or length rules400error_code: client_id_invalid, plus rule: "charset" or "length"
Begins hap_400error_code: client_id_reserved
Already held by a live application in this tenant409error_code: client_id_unavailable

Nothing is created when a request is refused.

A client identifier is not a secret. It travels in browser addresses and is visible to your users. What protects an application is its registered redirect addresses, PKCE, consent and (for confidential clients) its secret.

If you validate tokens yourself, check who issued them — not just the audience. A token's audience is the client identifier, and that value no longer distinguishes one tenant or one installation from another. Verify the iss claim against the issuer you expect, and verify the signature against that issuer's keys.

A deleted application's tokens stay valid until they expire. Deleting a registration stops the platform issuing anything new and causes the platform to refuse the tokens it already issued — but a token you validate locally, without asking the platform, will still look valid until its natural expiry. If you validate locally, treat a deletion as taking full effect after one access-token lifetime.

| POST /v1/providers | admin key + X-Tenant-Id | {name, provider_type, client_id?, client_secret?, authorization_url?, token_url?, userinfo_url?, saml_certificate?, scim_enabled?, jit_provisioning?, verified_domains?} | 201 {id, name, provider_type, scim_bearer_token?} |

application_type: web, spa, native, machine_to_machine, saml_sp. Use spa/native for public (PKCE) clients; web/machine_to_machine for confidential clients.

home_url is where a person is sent when they choose this application from their application selector — an absolute http(s) URL, administrator-supplied only (it is never accepted as a request parameter). An application without one is listed in the selector with an error and cannot be opened, so set it on every application you register. self_enrollment_enabled (default false) advertises the application to everyone in the tenant who passes the sign-up domain rules, and lets them start using it without an administrator granting access first. See §6.22.

show_in_menu decides whether the application appears on the application menu at all — the page people see at /t/{slug}, and GET /v1/me/applications. It defaults to on, and it is settable at creation and afterwards, so a host that registers clients programmatically can declare it once and re-assert it on every deploy.

  • Omit it at creation and the platform decides: a registration whose redirect URIs are all loopback addresses (127.0.0.1, [::1], localhost) — a command-line or machine client — is registered not shown, and everything else is registered shown. An explicit value always wins. This is a creation-time default only: changing the redirect URIs later never revisits it.
  • Omit it on PATCH, or send null, and the stored value is left unchanged. A partial update naming only redirect_uris cannot reset it.
  • It hides; it never denies. An unlisted application admits exactly the people it admitted before, still issues tokens, still asks for consent, and signing in through it still records the usual registration. It is not an access control — use access_mode for that.

The platform's own built-in CLI client (hap_cli, one per tenant) is registered not shown, because nobody opens a command-line tool from a menu.

label, description and icon_url decide how the application is presented on a person's application menu. All three are optional and all three are presentation only — none of them affects access, sign-in, tokens or consent.

  • label — the plain-English name people see, at most 60 characters, on one line. Leave it unset and the menu shows name, the name you registered the application under. Set it when that name is an internal identifier: a person choosing between hr-payroll-2 and crm-svc-prod is guessing. name itself never changes and stays what every other system knows the application by.
  • description — one line, at most 200 characters, shown under the label.
  • icon_url — the absolute http(s) address of an image shown beside it. HAP stores the address and never fetches it; the person's browser loads it. Use https, or browsers will block the image on the menu and show the label alone.

On PATCH, the three follow the same partial-update rule as home_url: omitted or null leaves the stored value alone, "" clears it. This matters if you redeploy applications automatically — a body naming only redirect_uris will not disturb a label an administrator set.

GET /v1/applications returns the stored label, which is null when none is set, so you can find the applications that still need one. GET /v1/me/applications returns the resolved label instead, which is never null — see §6.22.

Application lifecycle (scope applications:write) — self-service remediation of a tenant's own applications (e.g. a leaked client secret), no platform-operator ticket needed. Also available to a tenant admin in the Hosted Admin console (/t/{slug}/hosted/admin/applications), with identical effects. Disabling, deleting, or immediate-rotating runs an app-scoped revocation cascade — the client's access tokens, refresh-token sessions, and per-app access grants stop working within seconds — while leaving the user's tenant-wide SSO session intact. See the application-lifecycle guide.

Method & pathAuthBodySuccess
POST /v1/applications/{application_id}/disableapplications:write200 {id, client_id, enabled:false, disabled_at} (idempotent; cascade)
POST /v1/applications/{application_id}/enableapplications:write200 {id, client_id, enabled:true, disabled_at:null} (idempotent; deleted apps → 404)
POST /v1/applications/{application_id}/rotate-secretapplications:write{overlap_seconds?} (absent/0 = immediate + cascade; >0 = bounded overlap window, max 2h, no cascade)200 {id, client_id, client_secret, mode, secret_rotated_at, overlap_expires_at?} (secret shown once; public clients → 400 secret_rotation_not_applicable)
DELETE /v1/applications/{application_id}applications:write204 (terminal soft-delete; cascade; the record is kept for audit but its client_id becomes available again — you may register the application in this tenant a second time under the same identifier; second delete → 404)

Unknown / other-tenant / deleted ids return a non-enumerating 404 application_not_found.

Application administrators (feature 018) — delegated, app-scoped control. A tenant admin can designate a user as an administrator of a single application. An application admin gets full control of that one application — the lifecycle actions above, the access entitlements in §6.9, and co-administrator management — without any tenant-wide authority (they cannot create applications, manage other applications, change tenant settings, or grant tenant administration). The application lifecycle and per-user access endpoints therefore accept either an admin key carrying applications:write (the control plane, unchanged) or a session token of a tenant admin / administrator of that application; the latter is how an application admin acts headlessly.

Method & pathAuthSuccess
PUT /v1/applications/{application_id}/admins/{identity_id}admin key applications:write, a tenant-admin session, or an app admin of this app204 (idempotent; deleted/unknown identity → 404)
DELETE /v1/applications/{application_id}/admins/{identity_id}same204 (idempotent; no last-admin lockout — a tenant admin always retains control)
GET /v1/applications/{application_id}/adminssame200 {items:[{identity_id, email, granted_by, created_at}]}
GET /v1/identities/{identity_id}/administered-applicationsadmin key application_access:read200 {items:[{application_id, client_id, name}]}

A tenant admin (or app admin) can also create and fully manage an application — including creating one (POST /v1/applications) — and manage all of the above from the Hosted Admin console (/t/{slug}/hosted/admin/applications). Being an application admin is administrative authority, not end-user access: an app admin who also needs to use a restricted app still needs a normal access grant.

Application access control (access_mode): open (default — any authenticated identity may obtain tokens, unchanged behaviour) or restricted (issuance is gated; see §6.9). When an access_decision_endpoint is set, the platform calls that HTTPS endpoint at authorize/refresh time and the returned decision governs (the signing secret is returned once as access_decision_secret, and the request is HMAC-signed exactly like webhooks). Fail-closed by default (access_decision_fail_open=false).

6.8a Platform-operator tenant view — read-only (tenants:read)

Answers "which tenants exist?" and "how is this one configured?" without database access. Before this existed, GET /v1/tenants returned 405 — the path was registered for POST only — so every platform driving HAP kept its own copy of what it had created.

Who may call these. A platform operator: the root bootstrap key, a tenant-unbound admin key holding tenants:read, or a signed-in identity carrying the platform-operator designation (Authorization: Bearer <session token> + X-Tenant-Id: <the operator's HOME tenant> — the tenant the session lives in, never the tenant being read). A tenant-bound admin key is refused 401 whatever its scopes: a scope never widens a credential's tenant reach.

Method & pathSuccess
GET /v1/tenants?cursor=&limit=200 {items:[TenantSummary], next_cursor}
GET /v1/tenants/{tenant_id}200 TenantConfiguration (404 unknown, 422 malformed id)

TenantSummary = {id, name, slug, status, hosted_login_enabled, provisioned_by_caller, user_count, application_count, created_at}.

  • Every lifecycle status is returnedactive, suspended, cancelled. There is no status filter and non-active tenants are never hidden; they are the ones an operator most needs.

  • user_count counts identities whose status is not deleted; application_count counts applications that are not soft-deleted. Both read as the live population.

  • provisioned_by_caller is the caller label recorded at provisioning, or null when none was recorded. Reading it grants nothing: the handover rule and its non-enumerating 404 are unchanged.

    It does not, on its own, predict handover authority. The value is null for two whole classes of tenant, not just old ones: every tenant created before provenance was tracked, and every tenant provisioned with the bootstrap credential — that principal carries no caller label, so provisioning records null by construction. Handover matching requires a non-null label on both sides, so for a bootstrap-credential caller provenance can never match; handover works for it solely through the separate tenant_admins:any scope. A consumer that infers "I can hand this tenant over" from a matching provisioned_by_caller will be wrong for every bootstrap-provisioned tenant, and the failure looks like a 404 that is deliberately indistinguishable from "no such tenant".

  • Paging is an opaque keyset cursor over (created_at, id)the same convention as GET /v1/identities — so traversal is gap-free and repeat-free even while tenants are created.

TenantConfiguration adds the tenant's full effective configuration: mfa_policy, password_policy, session/refresh lifetimes and rotation, lockout thresholds, registration_policy, passwordless_jit_provisioning, hosted_login_enabled, group_creation_policy, revoke_tokens_on_identity_suspend, audit_retention_days, username_quarantine_days, rate_limit_policy, branding, issuer, jwks_uri, jwks_algorithm, signing_key_configured, and administrators[].

  • No secret is ever returned. The signing key appears only as signing_key_configured plus the public jwks_uri; the registration decision-service secret appears only as registration_policy.webhook.secret_configured. The response is an allowlist projection, so a field the platform does not explicitly publish cannot appear here.
  • Settings with platform defaults are objects: {"value": 30, "explicitly_set": false} means the platform default of 30 is in force and this tenant did not choose it. Applies to audit_retention_days, username_quarantine_days, rate_limit_policy, password_policy.
  • Reading a tenant's configuration writes a tenant.read audit row attributed to the caller — to the named individual where the operator signed in as a person. Listing is not audited.

Administering the designation (platform key only — never a session, so an operator cannot appoint operators and a tenant administrator can never self-escalate):

Method & pathScopeSuccess
PUT /v1/platform/operators/{identity_id}platform_operators:write204 (idempotent)
DELETE /v1/platform/operators/{identity_id}platform_operators:write204 (idempotent)
GET /v1/platform/operators?cursor=&limit=platform_operators:read200 {items:[{identity_id, tenant_id, tenant_slug, email, status}], next_cursor}

The designation grants platform-wide read and nothing else; it changes nothing about what that person can do inside their own tenant. It is inert whenever the identity is not active, so suspending a designated operator removes their platform authority with no separate revocation step. Revoking the last operator is allowed — the root credential and tenant-unbound keys remain independent routes to platform authority.

In the browser, an operator reaches the view from their own tenant's admin console (Platform/platform/tenants); the link appears only for designated operators.

6.9 Application access entitlements — admin (application_access:*)

For a restricted application, the external control plane grants/revokes which identities may use it. Stored entitlements are the fallback when no decision endpoint is configured (the endpoint "wins" when present).

Method & pathAuth scopeSuccess
PUT /v1/applications/{application_id}/access/{identity_id}application_access:write204 (idempotent grant)
DELETE /v1/applications/{application_id}/access/{identity_id}application_access:write204 (idempotent revoke)
GET /v1/applications/{application_id}/access?cursor=&limit=application_access:read200 {items:[{identity_id, granted_by, created_at}], next_cursor}
GET /v1/identities/{identity_id}/applicationsapplication_access:read200 {items:[{application_id, client_id, access_mode}]}

A non-entitled but fully authenticated identity is refused at …/oauth2/authorize and …/oauth2/token with the distinct access_denied / application_access_denied outcome (see §3) — never confused with an authentication failure. Revoking access blocks future refresh and the next authorize; already-issued short-lived access tokens expire naturally (revoke sessions to cut access immediately).

The same per-user grants are also manageable by a logged-in tenant admin in the Hosted Admin console at /t/{slug}/hosted/admin/users/{identity_id}/access (feature 017), with identical effect (audited as the acting admin). The console surfaces each application's access_mode because a direct grant only changes access for restricted applications — it is inert for open apps and for apps governed by an external decision endpoint.

Scoped admin keys — admin (admin_keys:write)

Least-privilege keys (e.g. one limited to application_access:write for the control plane). Presented in the same X-Admin-Key header.

Method & pathBodySuccess
POST /v1/admin-keys{name, scopes:[...]}201 {id, key, key_prefix, scopes} (raw key shown once)
GET /v1/admin-keys200 [{id, key_prefix, scopes, revoked_at}]
DELETE /v1/admin-keys/{key_id}204 (revoke)
curl -s -X POST http://localhost:8000/v1/tenants -H "X-Admin-Key: <ADMIN_KEY>" \
  -H "Content-Type: application/json" -d '{"name":"Acme","slug":"acme","password_policy":{"min_length":8}}'

6.10 SCIM 2.0 — SCIM bearer + X-Tenant-Id

Method & pathBodySuccess
GET /scim/v2/ServiceProviderConfig200 SCIM config
POST /scim/v2/UsersSCIM User (userName, name, emails, externalId)201 SCIM User
GET /scim/v2/Users/{user_id}200 SCIM User (404 if absent)
PATCH /scim/v2/Users/{user_id}{Operations:[{op,path,value}]} (e.g. active=false)200 SCIM User
DELETE /scim/v2/Users/{user_id}204 (soft delete)

Authenticate with the provider's SCIM bearer token (returned when creating a SCIM-enabled provider) plus X-Tenant-Id.

6.11 AI suggestions — admin (X-Admin-Key + X-Tenant-Id)

Method & pathNotesSuccess
GET /v1/ai/suggestions?status=filter by pending/accepted/dismissed200 {items:[SuggestionResponse]}
POST /v1/ai/suggestions/{suggestion_id}:accept200 SuggestionResponse (status:"accepted")
POST /v1/ai/suggestions/{suggestion_id}:dismiss200 SuggestionResponse (status:"dismissed")

6.12 Webhooks — admin (X-Admin-Key + X-Tenant-Id)

Method & pathBodySuccess
POST /v1/webhooks{url, event_types:[…]}201 {id, url, event_types, is_active, failure_count, created_at, signing_secret} (secret shown once)
GET /v1/webhooks200 {items:[…]} (no secret)
DELETE /v1/webhooks/{webhook_id}204

Verifying deliveries: each POST to your endpoint carries X-HAP-Signature: t=<ts>,v1=<hmac_sha256(secret, "<ts>.<body>")> and X-HAP-Event: <event_type>. Recompute the HMAC with your signing_secret and compare.

Event types: identity.created, identity.updated, identity.email_changed, identity.suspended, identity.reinstated, identity.deleted, session.created, session.revoked, mfa.enrolled, credential.compromised, login.blocked, invitation.accepted, invitation.rejected. Identity-lifecycle deliveries carry {event, data:{event_id, occurred_at, identity_id, email, [reason]}} — de-duplicate on event_id (delivery is at-least-once) and join on identity_id. identity.email_changed carries the new email only. Invitation deliveries carry {event, data:{event_id, occurred_at, invitation_id, application_id, identity_id, email, outcome, [reason]}}.


6.13 Invitations — application-authenticated (own client credentials)

Invite a person by email to join the platform and gain access to the inviting application. The app calls the create endpoint with its own OAuth2 client credentials (HTTP Basic client_id:client_secret); the tenant and inviting application are derived from the credential. The return URL is configured on the application (invitation_return_url, set via PATCH /v1/applications/{id}), not passed per request — an app without one cannot invite (409).

Method & pathAuthBodySuccess
POST /v1/invitationsBasic client creds or tenant admin (see below){email, welcome_message, [send_email, email_text_body, email_html_body, application_id]}201 {invitation_url, already_registered, expires_at, email_delivery}. Send X-Tenant-Id — this is the one endpoint where you authenticate with client credentials and name no tenant, so it has to find you by identifier alone. The header takes your tenant slug or its UUID. Without it, an identifier used by more than one tenant is refused as ambiguous (401 invalid_client) rather than guessed at; a unique identifier still works without it.
GET /invitations/{token}— (browser)200 hosted welcome page (pending) / 302 to the return URL (auto-accept or rejected)
POST /invitations/{token}/accept— (browser/headless)302 to the return URL with an accepted result token
POST /invitations/{token}/reject— (browser/headless)302 to the return URL with a rejected result token
  • already_registered is true only when an active identity already exists; otherwise a pending identity is created (or an existing pending one reused). Validity window defaults to 5 days (HAP_INVITATION_TTL_SECONDS).
  • send_email=true requires both email_text_body and email_html_body; the platform substitutes {{invitation_url}} and {{email}} and reports email_delivery as sent/failed (best-effort). Otherwise email_delivery is not_sent and the app emails the invitation_url itself.
  • Accepting activates a pending identity (pending → active, email marked verified), signs the invitee in (an hap_session cookie; auth_method=invitation, aal=1), and — for a restricted application — grants access. Required MFA is not bypassed; step-up still applies downstream.
  • Result token: every redirect to the return URL carries ?invitation_result=<JWT>. Verify it server-side with the tenant JWKS (/t/{slug}/oauth2/jwks); aud=urn:hap:invitation-result, claims {invitation_id, application_id, outcome: accepted|rejected, [reason: declined|expired|invalid|ineligible], [identity_id]}.
  • Creation is rate-limited per (tenant, application), stricter on the platform-sends-email path.
  • Inviting as the tenant (feature 024): when no Basic credential is presented, the request is authorised as a tenant administrator instead — X-Tenant-Id plus either an X-Admin-Key carrying the scope invitations:write or a tenant-admin session (Authorization: Bearer <session token>). On this path application_id is required (an application credential implies its own application; a tenant admin must say which one), and email_text_body/email_html_body are optional — omitted, the platform's default invitation bodies are used. An unknown, other-tenant or deleted application_id is a non-enumerating 404. The client-credentials path is unchanged in every respect.

6.14 Personal / CLI access tokens — admin (X-Admin-Key + X-Tenant-Id)

Long-lived, opaque, identity-bound bearer tokens for non-browser clients (e.g. a git CLI presenting username:token over HTTP Basic). A relying application mints one on a signed-in identity's behalf with a narrow scope and a gateway validates a presented token via introspection. Tokens are opaque (hap_pat_…), hashed at rest, and the secret is shown once.

Method & pathScopeBodySuccess
POST /v1/identities/{identity_id}/tokenspersonal_tokens:write{label?, scope?, audience?, expires_in_days?}201 {token_id, token (once), label, scope, audience, created_at, expires_at}
GET /v1/identities/{identity_id}/tokenspersonal_tokens:read200 [{token_id, label, scope, audience, created_at, last_used_at, expires_at, revoked}] (no secret)
DELETE /v1/identities/{identity_id}/tokens/{token_id}personal_tokens:write204 (irreversible)
POST /v1/tokens/introspectpersonal_tokens:readtoken=<secret> (form)200 {active, identity_id, sub, scope, audience?, exp?}
  • Issuance requires the identity to be active (else 409); expires_in_days is clamped to the platform maximum (HAP_PAT_MAX_TTL_SECONDS, default 365 d). The two scopes can be held by a least-privilege admin key, so the minting/validation path holds no broad admin rights.
  • Introspection is a live check: it returns active:false (and no identity) for any token that is unknown, malformed, expired, revoked, or whose identity is not active — so suspending or deleting an identity makes its personal tokens stop validating within seconds. identity_id equals the OIDC sub. last_used_at is updated on an active result.
  • Revocation cascade: revoking a token, or suspending/deleting its identity, invalidates it. Personal tokens always cascade. A per-tenant flag revoke_tokens_on_identity_suspend (set via PATCH /v1/tenants/{id}, default off) extends the same suspend/delete cascade to the identity's interactive sessions and OAuth access/refresh tokens.

6.15 Groups & tenant administration — mixed principals

User-owned groups (organisations/collectives/clubs) plus the tenant-administrator attribute that backs the Hosted Admin Console. Two principals are used: identity self-service (Authorization: Bearer <session token> + X-Tenant-Id) for running your own group, and tenant-admin oversight (X-Admin-Key + X-Tenant-Id) for tenant-wide actions.

Self-service — Authorization: Bearer <session token> + X-Tenant-Id

Method & pathBodySuccess
POST /v1/groups{name, description?, owner_identity_id?}201 group summary; caller becomes sole owner+admin. 403 group_creation_forbidden if the tenant policy disallows it. Feature 018: a tenant admin may pass owner_identity_id to create the group owned by another active user, bypassing the tenant's group-creation policy (a non-admin passing it → 403; a non-active owner → 409 invalid_owner)
GET /v1/me/groups200 [{group, role}] — the groups you belong to
GET /v1/groups/{id}200 group detail (members + roles); members only (or a tenant admin)
POST /v1/groups/{id}/members{identity_id}200 detail; adds an existing tenant user (idempotent). Disabled users may be added but gain no access until re-enabled
DELETE /v1/groups/{id}/members/{identity_id}204; removing a member who is an admin is owner-only
POST /v1/groups/{id}/leave204; the owner must transfer ownership first
PUT /v1/groups/{id}/members/{identity_id}/admin204 grant admin role
DELETE /v1/groups/{id}/members/{identity_id}/admin204 revoke admin role (never the owner)
POST /v1/groups/{id}/transfer-ownership{new_owner_identity_id}200; target must be an active admin
GET/PUT/DELETE /v1/groups/{id}/applications/{application_id}list / grant / revoke group→application access (a group owner/admin, or any tenant admin — even a non-member — feature 017)

Tenant-admin oversight — X-Admin-Key + X-Tenant-Id

Method & pathScopeBodySuccess
GET /v1/groupsgroups:read200 {items, next_cursor} — all groups in the tenant
POST /v1/groups/{id}/reassign-ownergroups:write{new_owner_identity_id}200; emergency owner reassignment (also unblocks a sole-owner disable)
PUT /v1/identities/{id}/tenant-adminidentities:write204 grant tenant-administrator
DELETE /v1/identities/{id}/tenant-adminidentities:write204; 409 last_tenant_admin if it would remove the last one
PUT/DELETE /v1/identities/{id}/group-create-grantidentities:write204 grant/revoke the "may create groups" capability
  • Group creation policy is per-tenant via PATCH /v1/tenants/{id} with group_creation_policy = {mode: "disabled" | "any_active_user" | "capability"} (default disabled). In capability mode, an identity also needs a group-create-grant.
  • Invariants (all refusals carry a machine error_code): exactly one owner per group; the owner is always an admin; at least one admin always remains; the last login-capable tenant admin (last_tenant_admin) and the sole owner of any group (sole_group_owner) can never be disabled/deleted until resolved. Codes: last_tenant_admin, sole_group_owner, group_must_have_owner, group_must_have_admin, owner_is_always_admin, transfer_target_not_admin, admin_member_owner_only, group_creation_forbidden (403 for authorization-style codes, 409 for state/lockout).
  • Group → application access is additive: a member of a group that grants an application is allowed, unioned with any direct per-identity grant (an active member only — a disabled member gets no group-derived access). A group's application grants are managed by its owner/admins and, for oversight, by any tenant admin (via the session-token endpoints above or the Hosted group page) without needing group membership (feature 017).
  • Hosted UI (opt-in, tenants with hosted_login_enabled): /t/{slug}/hosted/admin (tenant Admin Console), /t/{slug}/hosted/groups and /t/{slug}/hosted/groups/{id} (group self-service) — a thin client over exactly these endpoints.

6.16 SSH keys for git access — mixed principals

Identity-bound SSH public keys the platform owns, plus a lookup that resolves a presented key (or its fingerprint) to its owning identity — the SSH counterpart to personal access tokens (6.14): a git gateway resolves an SSH key the same way it introspects a PAT. Public keys are not secrets, so the key is stored in clear and a SHA256:… fingerprint is the lookup key. The platform holds no repository knowledge — per-repo authorization stays in the gateway. Accepted key types: Ed25519 and RSA ≥ 2048; ECDSA, DSA, and shorter RSA are rejected.

Admin — X-Admin-Key + X-Tenant-Id

Method & pathScopeBodySuccess
POST /v1/identities/{identity_id}/ssh-keysssh_keys:write{public_key, label?, scope?, audience?, expires_in_days?}201 {key_id, key_type, fingerprint, public_key, comment?, label?, scope, audience?, created_at, expires_at}
GET /v1/identities/{identity_id}/ssh-keysssh_keys:read200 [{key_id, key_type, fingerprint, …, last_used_at, expires_at, revoked}]
DELETE /v1/identities/{identity_id}/ssh-keys/{key_id}ssh_keys:write204 (idempotent)
POST /v1/ssh-keys/lookupssh_keys:read{fingerprint} or {public_key}200 {active, identity_id, sub, key_id, scope, audience?, exp?}

Self-service — Authorization: Bearer <session token> + X-Tenant-Id (acts on your own identity)

Method & pathBodySuccess
GET /v1/me/ssh-keys200 your registered keys (metadata only)
POST /v1/me/ssh-keys{public_key, label?, scope?, audience?, expires_in_days?}201 the registered key
DELETE /v1/me/ssh-keys/{key_id}204; 404 if the key is not yours
  • Validation (registration): unsupported/weak or malformed keys are refused with a machine error_codessh_key_invalid (unparseable) or ssh_key_unsupported_type (ECDSA/DSA/short RSA), both 422. A key whose fingerprint is already active in the tenant is refused 409 ssh_key_already_registered; the same key may exist under a different tenant, and a removed key may be registered again.
  • Lookup is a live check: it returns active:false (and no identity) for any key that is unknown, removed, expired, or whose identity is not active — so suspending or deleting an identity makes its SSH keys stop resolving within seconds, with no separate revocation step. identity_id equals the OIDC sub; last_used_at is updated on an active result. Provide a fingerprint (SHA256:…) or a full public_key. A gateway that caches results should use a TTL under 5 s so caching never defeats the revocation budget.
  • Expiry is optional and defaults to none (HAP_SSH_KEY_MAX_TTL_SECONDS, default 0 = unlimited; the RSA floor is HAP_SSH_RSA_MIN_BITS, default 2048). scope/audience are opaque — stored and echoed, never interpreted.

6.17 Email aliases for git-commit attribution — admin (X-Admin-Key + X-Tenant-Id)

Identity-bound verified alias email addresses the platform owns, plus a lookup that resolves a commit-author address to its owning identity — the email counterpart to personal access tokens (6.14) and SSH keys (6.16). A user attaches an extra address, proves control of it via a 6-digit code emailed to that address (same 5-minute TTL / 3-attempt / rate-limit posture as login OTP), and a git gateway then credits commits under that address to the right user. The platform holds no repository knowledge.

Address exclusivity (per tenant): every address is either one identity's primary or one identity's verified alias — never both, never two owners. Consequently every account-creation path (self-register, admin create, invitation, passwordless JIT, SCIM, primary-email change) rejects an address that is already a verified alias, and alias verification rejects an address that is any non-deleted identity's primary or another identity's verified alias.

Method & pathScopeBodySuccess
POST /v1/identities/{identity_id}/email-aliasesemail_aliases:write{alias_email}202 {status:"pending"} (code emailed) · 200 {status:"already_verified", alias_id}
POST /v1/identities/{identity_id}/email-aliases/verifyemail_aliases:write{alias_email, code}200 {status:"verified", alias_id}
POST /v1/identities/{identity_id}/email-aliases/registeremail_aliases:register{alias_email}201 {status:"verified", alias_id, verified_at} (no email) · 200 {status:"already_verified", alias_id}
GET /v1/identities/{identity_id}/email-aliasesemail_aliases:read200 [{alias_id, alias_email, verified_at}] (verified only)
DELETE /v1/identities/{identity_id}/email-aliases/{alias_id}email_aliases:write204; 404 if the alias is not this identity's
POST /v1/email-aliases/lookupemail_aliases:read{email}200 {found, identity_id?, primary_email?}
  • Operator-asserted registration (…/register): attaches an alias as immediately verified with no email sent — for a site operator attaching a member's known historical commit addresses on their behalf (the member can't read a code sent to an old address). It enforces the same availability/hygiene/uniqueness rules as the emailed-code path (no override) and the resulting alias is indistinguishable from a code-verified one in lookup/list. It sits behind a separate least-privilege scope email_aliases:register (its own key) so the member self-service :write key can never mint a verified alias without the emailed-code proof. Because no email is sent, register validates the address for syntax only — a local-part@domain with a dotted domain — so reserved / non-routable TLDs (.local, .internal, .lan, .home, …) and non-resolvable domains are accepted, including Git's auto-stamped user@host.local on an unconfigured machine. Only genuinely malformed input (empty, no @, whitespace, single-label domain) is email_alias_invalid. The emailed-code path (…/email-aliases) keeps full deliverability-style validation — you can't email a code to an address that fails it. To match, lookup accepts any string (unresolvable → {found:false}, never a 422), so a registered .local alias resolves like any other.

  • Errors carry a machine error_code in the standard envelope (§3): email_alias_unavailable (409 — address is another identity's primary/alias), email_alias_blocked (422 — fails the tenant's email-hygiene rules), email_alias_invalid (422 — malformed, or your own primary), email_alias_invalid_code (422 — wrong/expired code; note this is 422, not 401). Rate-limit trips return 429 temporarily_unavailable with Retry-After.

  • Pending pairings are transient (a verification token, not an alias row): they expire, never appear in the list, and reserve nothing — if two identities race for one address, the first to verify wins and the other's verify returns 409.

  • Lookup is a live check: it returns {found:false} for any address that is unknown, still pending, removed, or whose identity is not active — so suspending or deleting an identity makes its aliases stop resolving within seconds, with no separate revocation step. An active identity's primary resolves to itself. identity_id equals the OIDC sub. A gateway that caches results should use a TTL under 5 s so caching never defeats the revocation budget.

  • Lifecycle: suspension holds aliases (they stop resolving but stay owned; reinstatement restores them); deletion revokes them (their addresses become claimable again). Addresses are normalized to lowercase, so lookup and all cross-checks are case-insensitive.

  • Hygiene: alias addresses honour only the tenant registration policy's domain deny/allowlist and disposable-domain block — not its registration window, decision webhook, or disabled mode (attaching an alias is not a registration).

6.18 UI appearance & branding — mixed principals

Per-tenant appearance applied consistently across every platform-rendered screen (Hosted Login and signed-out pages, the invitation welcome page, and the Admin Console). One config — palette (accent_color, background_color, surround_color, text_color, error_color), font_family, logo_url, favicon_url, product_name — settable by both a platform operator (admin API) and a tenant admin (self-service Hosted page). See the appearance customization guide for the full field reference and worked examples.

Admin — X-Admin-Key + X-Tenant-Id

Method & pathBodySuccess
PATCH /v1/tenants/{tenant_id}{branding: {accent_color?, background_color?, surround_color?, text_color?, error_color?, font_family?, logo_url?, favicon_url?, product_name?}} ({} clears back to defaults)200 {…, branding, branding_warnings?}

Self-service (tenant admin) — Hosted UI

Method & pathNotes
GET /t/{slug}/hosted/admin/brandingrenders the current appearance form (tenant-admin only)
POST /t/{slug}/hosted/admin/brandingsaves it; non-blocking warnings shown inline
  • Validation (single source of truth, domain/branding.py): font_family is a family name validated against an allowlisted web-font provider (the platform builds the Google-Fonts link — arbitrary font URLs are rejected); logo_url/favicon_url must be absolute HTTPS (no data:, relative, or non-HTTPS). Unsafe or unknown input is refused 422 invalid_request.
  • Low colour contrast warns but does not block (WCAG AA 4.5:1): such cases return branding_warnings on the admin response (and inline on the Hosted page) without failing the save.
  • Backward compatible: unbranded tenants and legacy accent_color/logo_url-only tenants render unchanged. Self-service saves emit a branding.updated audit row (the admin-API path rides tenant.updated).

6.19 Tenant provisioning & administrator handover — platform-scoped X-Admin-Key

Create a tenant together with its first administrator in one atomic call, and hand a tenant over to a different administrator under controls. Driven by a machine client (e.g. a website signup form) holding a platform-scoped admin key — one with no tenant binding — so it never needs the root credential.

Every endpoint in this section refuses a tenant-bound key with 401, before its scopes are even considered. A scope must never widen a credential's tenant reach.

ScopeGrants
tenants:provisionCreate a tenant with its first administrator. Confers nothing over any existing tenant, identity, group, application or credential.
tenant_admins:writePreview/initiate a handover on a tenant this caller provisioned.
tenant_admins:anyHandover on any tenant, ignoring provenance. A distinct, strictly stronger scope — holding tenant_admins:write does not confer it.
admin_keys:writeIssue platform-scoped keys (below). Deliberately not held by a provisioning credential.

Issue the platform-scoped credential (new — the existing POST /v1/admin-keys requires X-Tenant-Id and binds the key to that tenant, so it cannot produce one):

Method & pathBodySuccess
POST /v1/platform/admin-keys{name, scopes[], caller_id?}201 {id, key, key_prefix, scopes, caller_id, tenant_id:null} (raw key shown once)

caller_id is the stable logical caller label (e.g. wbsp-website). Provisioning stamps it on every tenant it creates, and handover matches against it. Reuse the same caller_id when rotating a key, or the replacement loses authority over every tenant the old one provisioned. A key with no caller_id may provision but can never hand over — it has no provenance to match.

Provision a tenant

Method & pathBodySuccess
POST /v1/tenants{name, slug, admin_email?, send_welcome_email?, password_policy?}201 {id, slug, jwks_algorithm, admin_identity_id, admin_email, hosted_login_enabled, provisioned_by_caller, welcome_email}
  • admin_email omitted ⇒ byte-identical legacy behaviour (tenant only, no administrator, sign-in surface untouched), so existing callers are unaffected.
  • admin_email present ⇒ one transaction creating the tenant, its signing key, its default role, the administrator identity, that identity's tenant-admin designation, and an enabled sign-in surface. On any failure nothing persists — not even the slug — so the identical request can be retried. (Previously this was four uncoordinated calls; a failure between any two left a tenant nobody could administer.)
  • The administrator signs in with no password: send them to /t/{slug}/hosted/login?return_to=/t/{slug}/hosted/admin and they receive an emailed code. Self-enrolment is not switched on — passwordless_jit_provisioning stays off, so provisioning a tenant does not open it to strangers.
  • welcome_email is sent | failed | not_sent. failed is not an error: delivery is best-effort and never fails or rolls back a provisioned tenant.
  • Send Idempotency-Key from any form a user can double-submit. An identical repeat returns 200 with the original body; the same key with different content is 409 idempotency_key_reused (a caller bug, surfaced rather than hidden).
  • Throttled per calling credential (10/60 s), because there is no tenant yet to key a limit to.

Tenant short-name (slug) rules — validated, never silently corrected, so the caller always knows what the tenant's URLs will be. Refusals carry a machine-readable rule a signup form can act on:

InputResult
Acme Corp!, ACME, acme_corp422 tenant_slug_invalid, rule: charset (lowercase letters, digits, hyphens only)
ab, 41+ chars422 rule: length (3–40)
1acme422 rule: leading_character (must start with a letter)
acme-422 rule: trailing_hyphen
ac--me422 rule: consecutive_hyphen
admin, api, support, …422 rule: reserved
already taken409 tenant_slug_unavailable

Reserved names guard against impersonation and future platform use, not routing — tenant URLs nest under /t/, so no slug can shadow a platform route.

Hand a tenant over — three steps, deliberately. A handover adds an administrator and removes nobody.

Method & pathBodySuccess
POST /v1/tenants/{id}/admin-handovers/preview{incoming_email}200 {tenant, current_administrators[], incoming_email, incoming_identity_exists, effect:"grant_only", note, confirmation_token, confirmation_token_expires_at} — persists nothing
POST /v1/tenants/{id}/admin-handovers{incoming_email, confirmation_token}201 {handover_id, status:"pending", expires_at, proof_email, administrators_notified, effect:"grant_only"}
GET /v1/tenants/{id}/admin-handovers[/{handover_id}]200 handover state (never the proof token)
POST /tenant-handovers/{proof_token}/accept— (unauthenticated)200 {status:"accepted", tenant, identity_id, administrators_after, administrators_removed:0}
POST /tenant-handovers/{proof_token}/decline— (unauthenticated)200 {status:"declined"}
GET /tenant-handovers/{proof_token}— (browser)200 thin Accept/Decline page over the POSTs above

Four independent brakes, each holding on its own:

  1. Provenance — a caller may act only on tenants it provisioned. Any other tenant returns 404, identical to a tenant that does not exist, so the check cannot be used to discover which tenants exist. Tenants created before this feature have no provenance and need tenant_admins:any.
  2. Preview → confirm — initiation requires the single-use confirmation_token from a preview (300 s), so a handover cannot complete in one unattended call. A token previewed for a different tenant or address will not authorise this one.
  3. Emailed proof of mailbox control — initiation grants nothing. The incoming person receives a single-use link (48 h) and gains authority only by presenting it, so without control of the target mailbox a leaked credential transfers nothing.
  4. Grant-only — acceptance adds an administrator; administrators_removed is always 0. Retiring the previous administrator is the tenant's own act in its own console: the provisioning credential holds no identities:write, so it structurally cannot remove anyone's access.

Every current administrator is emailed when authority is requested and again when it is granted — delivery is best-effort, and the safety of a handover never depends on it.

Statuserror_codeWhen
404Tenant not found or not provisioned by this caller (indistinguishable)
409handover_already_administratorAddress already administers this tenant
409handover_pendingA live handover already exists for this tenant
409tenant_unavailableTenant suspended/deleted since initiation
422handover_confirmation_invalidConfirmation token missing, expired, used, or mismatched
422handover_email_invalidAddress invalid or disposable
422handover_target_unavailableAddress belongs to a suspended/deleted user in that tenant
410handover_expiredProof expired or already used/declined

Audit: tenant.provisioned, tenant.handover_initiated/.accepted/.declined/.expired, plus the existing identity.tenant_admin_granted. Pre-tenant failures (throttling, invalid slug or email) emit a structured log line rather than an audit row — the audit table requires a tenant, and at that point there is none.

6.20 Per-tenant session cookies

The hosted browser session is scoped per tenant: hap_session_{slug} at Path=/t/{slug}. A person may hold live sessions in several tenants at once in one browser; signing into one no longer displaces another, and signing out of one leaves the others untouched.

  • The name is per-tenant, not only the path. Path-scoping alone would leave two cookies with the same name at different paths during rollout, and the request cookie jar is a flat name→value mapping — so which one the server saw would be arbitrary.
  • Migration is automatic. A pre-existing host-wide hap_session cookie still authenticates until the next sign-in, which replaces it with the per-tenant cookie and clears the old one. Nobody is signed out by the deployment.
  • Attributes are unchanged: HttpOnly, Secure when the issuer is https, SameSite=Lax. Within-tenant SSO across applications is unaffected — the authorize endpoint lives under the cookie's path.
  • Sign-in also accepts return_to=/t/{slug}/hosted/admin, not only the tenant's authorize endpoint, so a freshly provisioned tenant's administrator can reach their console before any application is registered. return_to is reduced to a same-origin relative target, so the page cannot be used as an open redirector.
  • An OMITTED or empty return_to now means "no application sent me" and lands on the application selector (/t/{slug}, §6.22) instead of being refused. A return_to that is supplied but is not on the allow-list is still a 400 — only the empty case gets a default, so the open-redirect guard above is unchanged.

6.21 Tenant sign-up settings — mixed principals (scope registration_policy:*)

Who may self-register in a tenant: the invitation-only switch and the two email-domain lists. The policy itself is feature 009 and is enforced at one gate for every self-service arrival path (password sign-up, email/SMS one-time code, magic link, first arrival at Hosted Login). What is new is that a tenant administrator can set it — before this, the only writer was PATCH /v1/tenants/{id}, which requires the root bootstrap credential.

Method & pathAuthBodySuccess
GET /v1/registration-policyregistration_policy:read or tenant-admin session200 settings + version
PUT /v1/registration-policyregistration_policy:write or tenant-admin session{invitation_only, allowlist:{enabled,domains}, denylist:{enabled,domains}, [version]}200 the stored settings
{
  "invitation_only": false,
  "allowlist": { "enabled": true,  "domains": ["acme.com", "acme.co.uk"] },
  "denylist":  { "enabled": false, "domains": ["example.net"] },
  "other_rules": {
    "block_disposable": true,
    "window_start": null,
    "window_end": null,
    "decision_service_configured": true
  },
  "version": "9f2b1c7d4e6a0b83"
}
  • invitation_only: true closes every self-service path. Invitations, administrator-created users and directory-synchronised provisioning are unaffected, and nobody who already has an account is affected — it governs arrival, not sign-in.
  • Precedence: invitation-only wins; while it is on, both lists are stored but not consulted. A blocked domain beats an allowed one.
  • Each list is enabled separately from being non-empty, so a list can be switched off without losing its entries. A policy stored before this feature has no flag and keeps its exact previous meaning (in force when non-empty).
  • Preservation: a write here changes only those five fields. Any registration window, throwaway-email block, or decision service configured through PATCH /v1/tenants/{id} is left untouched. other_rules reports their presence; the decision service's secret is never returned in any form.
  • Domains are bare domains — no @, no scheme or path, no wildcard, at least one dot — stored lower-cased, de-duplicated and sorted, and matched exactly (acme.com does not match mail.acme.com). One bad entry fails the whole write with 422 registration_domain_invalid naming it.
  • 409 registration_allowlist_empty — an allow-list cannot be switched on with no domains; that is what invitation_only is for.
  • 409 registration_policy_conflictversion is optional, but when supplied it must match the current state. The Hosted console always sends it, so two administrators cannot silently overwrite each other.
  • Not covered: first-arrival account creation through an external identity provider keeps its own per-provider control and is not closed by invitation_only.
  • An enabled list also constrains email-alias addresses (feature 021), including while invitation-only is on — attaching an alias is not a registration, so the mode does not apply but the domain hygiene does. Switch the list off (entries are kept) to opt out.
  • Console equivalent: /t/{slug}/hosted/admin/registration.

6.22 Application selector — the signed-in person's own applications

A person who signs in at HAP itself — from a bookmark, or the "Sign in again" link on the signed-out page — has no application behind them. They land on the application selector: the applications they may use, from which choosing one registers them (if needed) and forwards them to that application's home_url.

Method & pathAuthBodySuccess
GET /v1/me/applicationsX-Tenant-Id + Authorization: Bearer <session token>200 {applications:[{application_id, client_id, name, label, description, icon_url, home_url, launchable, source}], selector_relevant, destinations}
  • label is what to SHOW the person: the administrator-set label if there is one, otherwise name. It is always present. description and icon_url are null when unset. Entries come back ordered by label, case-insensitively — the same order the hosted menu uses.
  • launchable is false exactly when home_url is null. Do not forward to a non-launchable application — show the same "ask an administrator" message the hosted page shows.
  • source is registered (a grant this person holds), group (a grant one of their groups holds), or self_enrollment (advertised to them, not yet used).
  • Ordered by name; an empty list is a 200 with {"applications": []}, never a 404.
  • selector_relevant answers "does this person have a choice to make?" — true when they have two or more applications or a non-empty destinations. It is the same answer the hosted exit page branches on, so you can build the same behaviour without reading a page.
  • destinations is an ordered subset of ["tenant_admin", "platform"]: places this person can go that are not applications. Sign-out is never listed — everyone has it, so counting it would make selector_relevant true for everybody.

Hosted equivalents (opt-in Hosted UI, tenants with hosted login enabled):

PathPurpose
GET /t/{slug}GET /t/{slug}/hosted/appsThe selector — one handler, two addresses. Parameter-free; hard-code it as a "choose another application" link. Signed out → sign in, then back to the address you used. Always renders, even for one qualifying application — the page also carries sign-out and the console link. /t/{slug}/hosted/apps is being retired; point new links at /t/{slug}. A trailing slash on the root is tolerated.
GET /t/{slug}/menuGET /menu/{slug}Short aliases. Both 307 to /t/{slug} — for a link that has to be read out, typed from memory, or printed. They target the root, not the address being retired, so neither changes when it goes. They authorize nothing: signed out at an alias means sign in, and an unknown tenant is refused, both exactly as at the canonical address.
POST /t/{slug}/hosted/apps/{application_id}/goChoose an application. A POST because it writes an entitlement. The list is re-derived server-side, so a forged id is a 403.
GET /t/{slug}/exitLeave an application. Parameter-free — hard-code it on your "leave" or "switch application" control. Forwards to the selector when selector_relevant, and otherwise signs the person out (their only application is the one they just left). No session → the signed-out page, no error. It accepts no destination parameter; for a post-logout return address of your own, keep using the end-session endpoint. Moved from /t/{slug}/hosted/exit, which still answers as a 307 to this path — point new links here; the old spelling has no removal date.

/t/{slug}/selector is not an address here. It appeared for a day in the WBSP platform's client guidelines, was briefly served as a redirect, and was withdrawn on 2026-09-04 without ever reaching a deployed server — so no link that ever worked was taken away. It answers 404. If you hold a link to it, point it at /t/{slug}.

Registration is recorded, then ignored. Signing in to an application that admits any authenticated person records a (person, application) grant. It changes nothing while the application stays open — the access decision returns before it reads grants. It exists so that switching that application to restricted later does not lock out the people already using it. For the same reason it is never recorded when access came from a group or from the application's own decision hook: both are revocable, and a direct grant would outlive the revocation.


7. Worked example — login to token (end to end)

BASE=http://localhost:8000

# (admin) create a tenant and a public application
TENANT=$(curl -s -X POST $BASE/v1/tenants -H "X-Admin-Key: <ADMIN_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme","slug":"acme","password_policy":{"min_length":8}}')
TENANT_ID=$(echo "$TENANT" | python -c "import sys,json;print(json.load(sys.stdin)['id'])")

APP=$(curl -s -X POST $BASE/v1/applications -H "X-Admin-Key: <ADMIN_KEY>" \
  -H "X-Tenant-Id: $TENANT_ID" -H "Content-Type: application/json" \
  -d '{"name":"Acme Web","application_type":"spa","redirect_uris":["https://app.example/cb"]}')
CLIENT_ID=$(echo "$APP" | python -c "import sys,json;print(json.load(sys.stdin)['client_id'])")

# (end user) register + login
curl -s -X POST $BASE/v1/auth/register -H "X-Tenant-Id: $TENANT_ID" \
  -H "Content-Type: application/json" -d '{"email":"u@acme.example","password":"a strong passphrase"}'
LT=$(curl -s -X POST $BASE/v1/auth/login -H "X-Tenant-Id: $TENANT_ID" \
  -H "Content-Type: application/json" -d '{"email":"u@acme.example","password":"a strong passphrase"}' \
  | python -c "import sys,json;print(json.load(sys.stdin)['login_token'])")

# PKCE
read V C < <(python -c "import os,base64,hashlib;v=base64.urlsafe_b64encode(os.urandom(32)).rstrip(b'=').decode();print(v, base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).rstrip(b'=').decode())")

# authorize -> code
CODE=$(curl -si "$BASE/t/acme/oauth2/authorize?response_type=code&client_id=$CLIENT_ID\
&redirect_uri=https://app.example/cb&scope=openid%20profile%20email&code_challenge=$C\
&code_challenge_method=S256&login_token=$LT&consent=granted" | grep -i location | sed -E 's/.*code=([^&]+).*/\1/')

# token -> tokens, then userinfo
TOKENS=$(curl -s -X POST $BASE/t/acme/oauth2/token -d grant_type=authorization_code \
  -d code=$CODE -d redirect_uri=https://app.example/cb -d code_verifier=$V -d client_id=$CLIENT_ID)
AT=$(echo "$TOKENS" | python -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
curl -s $BASE/t/acme/oauth2/userinfo -H "Authorization: Bearer $AT"

8. Generating clients & next steps

  • Generate a typed SDK from the OpenAPI document — available here in both formats: openapi.yaml (published spec) and target/openapi/openapi.json (OpenAPI 3.1). Feed either to openapi-generator, Postman/Insomnia, etc. Any standards-compliant OIDC client library also works — point it at the discovery URL …/t/{tenant_slug}/.well-known/openid-configuration.
  • Tutorials & context: Integration guide (step-by-step), Getting Started (first run + glossary), Technical overview (architecture & security model).
  • Regenerate this inventory if the service changes: cd target && uv run python scripts/export_openapi.py.

Coverage: this reference documents the operations across all paths in the OpenAPI spec (target/openapi/openapi.json) exposed by the service (including the feature-005 application-access/entitlement/admin-key endpoints, the feature-012 groups + tenant-administration endpoints, the feature-014 SSH-key endpoints, and the feature-015 appearance/branding surface). Not included (not implemented today): LDAP/AD login, OAuth device-code grant, and SAML SP-initiated AuthnRequest / Single Logout.