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:
target/openapi/openapi.json(108 paths, 130 operations) — import into Postman/Insomnia or generate typed clients. - 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 ofhttp://localhost:8000.
1. Overview: base URL & addressing model
The API has two address spaces:
| Space | Pattern | Examples |
|---|---|---|
| Per-tenant | /t/{tenant_slug}/… | discovery, JWKS, all oauth2/* endpoints |
| Global | /v1/…, /scim/v2/…, /healthz, /readyz | admin, 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 auses:dependency on this app (feature 034): the exposedauthport surfaces asHEADLESS_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) foriss/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 leavingiss/audchecks 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.
| Scheme | How to send it | Used for | How to obtain |
|---|---|---|---|
| Admin key | X-Admin-Key: <key> (+ X-Tenant-Id: <uuid> for tenant-scoped admin) | Provisioning & admin APIs | Set as HAP_ADMIN_API_KEY when deploying |
| Tenant header | X-Tenant-Id: <tenant uuid> | Public end-user flows (register/login/verify/reset, MFA, WebAuthn, federation, SCIM) | Returned by POST /v1/tenants |
| End-user bearer | Authorization: Bearer <access_token> | userinfo, /v1/sessions | From the OAuth token endpoint |
| OAuth client auth | client_id (+ client_secret or HTTP Basic) in the token/introspect/revoke request | Confidential clients on token endpoints | POST /v1/applications (secret shown once) |
| SCIM bearer | Authorization: Bearer <scim_token> | /scim/v2/* | Returned when creating a SCIM-enabled provider |
| Login token | login_token field (a short-lived session token) | /oauth2/authorize, MFA, WebAuthn register, saml/issue | Returned 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:
| Group | Auth |
|---|---|
| Health, Discovery, JWKS | none |
| OAuth authorize | login_token (the authenticated user) |
| OAuth token/introspect/revoke | OAuth client auth (PKCE for public clients) |
| OAuth userinfo | end-user bearer |
| Auth flows (register/login/verify/reset) | X-Tenant-Id |
| MFA, WebAuthn | X-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, providers | admin key (+ X-Tenant-Id) |
| Tenants | admin key (platform) |
| SCIM | SCIM bearer + 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" }| Status | Typical error | Meaning / action |
|---|---|---|
| 400 | invalid_request, invalid_grant, invalid_scope, unsupported_grant_type | Malformed/invalid request; fix parameters. |
| 401 | invalid_client, invalid_token, login_required | Missing/invalid credentials or session; authenticate. |
| 403 | access_denied | Authenticated but not permitted (scope/tenant). |
| 403 | access_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. |
| 404 | invalid_request | Unknown tenant/resource. |
| 409 | invalid_request (conflict) | Duplicate (e.g. email already exists). |
| 422 | invalid_request + failed_rules | Validation failed (e.g. password policy). |
| 429 | temporarily_unavailable | Rate 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/readyz5. 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-configurationGET /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), 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).
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 locationPOST /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>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).
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 & path | Body | Success |
|---|---|---|
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 & path | Body | Success |
|---|---|---|
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, status, mfa_enabled, custom_claims, created_at} — never includes credential material.
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 & path | Notes | Success |
|---|---|---|
POST /v1/identities | {email, password?, name?, given_name?, family_name?, custom_claims?} | 201 IdentityResponse (409 on duplicate) |
GET /v1/identities/{identity_id} | — | 200 IdentityResponse (404 if absent) |
GET /v1/identities?email=&status=&cursor=&limit= | cursor pagination | 200 {items:[…], next_cursor} |
PATCH /v1/identities/{identity_id} | {name?, given_name?, family_name?, custom_claims?, status?} | 200 IdentityResponse |
DELETE /v1/identities/{identity_id} | soft-delete + PII erasure | 204 |
6.3 Sessions
| Method & path | Auth | Success |
|---|---|---|
GET /v1/sessions | X-Tenant-Id + end-user bearer | 200 {items:[SessionResponse]} |
DELETE /v1/sessions/{session_id} | X-Tenant-Id + end-user bearer (must own) | 204 (403 if not yours) |
DELETE /v1/sessions | X-Tenant-Id + end-user bearer | 204 (log out everywhere) |
GET /v1/identities/{identity_id}/sessions | admin (sessions:read) | 200 {items:[…]} |
POST /v1/identities/{identity_id}/sessions:revoke-all | admin (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 & path | Body | Success |
|---|---|---|
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 & path | Body | Success |
|---|---|---|
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 & path | Auth / body | Success |
|---|---|---|
GET /v1/providers/{provider_id}/login | X-Tenant-Id | 200 {redirect_url} (send user there) |
GET /v1/providers/{provider_id}/callback?code=&state= | X-Tenant-Id | 200 {login_token, session_id} (401 registration_required if JIT off) |
POST /v1/saml/{provider_id}/acs | X-Tenant-Id, form SAMLResponse=<base64> | 200 {login_token, session_id} (401 on bad/replayed assertion) |
GET /v1/saml/{provider_id}/metadata | X-Tenant-Id | 200 SP metadata XML |
POST /v1/saml/issue | X-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 & path | Body | Success |
|---|---|---|
POST /v1/rbac/roles | {name, permissions:[…], is_default?} | 201 {id, name, permissions, is_default} |
GET /v1/rbac/roles | — | 200 {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 & path | Auth | Body | Success |
|---|---|---|---|
POST /v1/tenants | admin key (platform) | {name, slug, password_policy?} | 201 {id, slug, jwks_algorithm} |
POST /v1/applications | admin key + X-Tenant-Id | {name, application_type, redirect_uris?, scopes?, confidential?, access_mode?, access_decision_endpoint?, access_decision_fail_open?, access_decision_timeout_ms?, access_decision_cache_ttl_seconds?} | 201 {id, client_id, client_secret?, access_mode, access_decision_secret?} (secrets shown once) |
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?} | 200 {id, client_id, access_mode, access_decision_secret?} |
GET /v1/applications | admin key + X-Tenant-Id (scope identities:read) | — | 200 [{id, client_id, name, application_type, redirect_uris, confidential, access_mode, enabled, disabled_at, secret_rotated_at}] (deleted apps excluded; never returns a secret) |
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.
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 & path | Auth | Body | Success |
|---|---|---|---|
POST /v1/applications/{application_id}/disable | applications:write | — | 200 {id, client_id, enabled:false, disabled_at} (idempotent; cascade) |
POST /v1/applications/{application_id}/enable | applications:write | — | 200 {id, client_id, enabled:true, disabled_at:null} (idempotent; deleted apps → 404) |
POST /v1/applications/{application_id}/rotate-secret | applications: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:write | — | 204 (terminal soft-delete; cascade; client_id never reused; 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 & path | Auth | Success |
|---|---|---|
PUT /v1/applications/{application_id}/admins/{identity_id} | admin key applications:write, a tenant-admin session, or an app admin of this app | 204 (idempotent; deleted/unknown identity → 404) |
DELETE /v1/applications/{application_id}/admins/{identity_id} | same | 204 (idempotent; no last-admin lockout — a tenant admin always retains control) |
GET /v1/applications/{application_id}/admins | same | 200 {items:[{identity_id, email, granted_by, created_at}]} |
GET /v1/identities/{identity_id}/administered-applications | admin key application_access:read | 200 {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.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 & path | Auth scope | Success |
|---|---|---|
PUT /v1/applications/{application_id}/access/{identity_id} | application_access:write | 204 (idempotent grant) |
DELETE /v1/applications/{application_id}/access/{identity_id} | application_access:write | 204 (idempotent revoke) |
GET /v1/applications/{application_id}/access?cursor=&limit= | application_access:read | 200 {items:[{identity_id, granted_by, created_at}], next_cursor} |
GET /v1/identities/{identity_id}/applications | application_access:read | 200 {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 & path | Body | Success |
|---|---|---|
POST /v1/admin-keys | {name, scopes:[...]} | 201 {id, key, key_prefix, scopes} (raw key shown once) |
GET /v1/admin-keys | — | 200 [{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 & path | Body | Success |
|---|---|---|
GET /scim/v2/ServiceProviderConfig | — | 200 SCIM config |
POST /scim/v2/Users | SCIM 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 & path | Notes | Success |
|---|---|---|
GET /v1/ai/suggestions?status= | filter by pending/accepted/dismissed | 200 {items:[SuggestionResponse]} |
POST /v1/ai/suggestions/{suggestion_id}:accept | — | 200 SuggestionResponse (status:"accepted") |
POST /v1/ai/suggestions/{suggestion_id}:dismiss | — | 200 SuggestionResponse (status:"dismissed") |
6.12 Webhooks — admin (X-Admin-Key + X-Tenant-Id)
| Method & path | Body | Success |
|---|---|---|
POST /v1/webhooks | {url, event_types:[…]} | 201 {id, url, event_types, is_active, failure_count, created_at, signing_secret} (secret shown once) |
GET /v1/webhooks | — | 200 {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 & path | Auth | Body | Success |
|---|---|---|---|
POST /v1/invitations | Basic client creds | {email, welcome_message, [send_email, email_text_body, email_html_body]} | 201 {invitation_url, already_registered, expires_at, email_delivery} |
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_registeredis true only when anactiveidentity already exists; otherwise apendingidentity is created (or an existing pending one reused). Validity window defaults to 5 days (HAP_INVITATION_TTL_SECONDS).send_email=truerequires bothemail_text_bodyandemail_html_body; the platform substitutes{{invitation_url}}and{{email}}and reportsemail_deliveryassent/failed(best-effort). Otherwiseemail_deliveryisnot_sentand the app emails theinvitation_urlitself.- Accepting activates a
pendingidentity (pending → active, email marked verified), signs the invitee in (anhap_sessioncookie;auth_method=invitation,aal=1), and — for arestrictedapplication — 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.
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 & path | Scope | Body | Success |
|---|---|---|---|
POST /v1/identities/{identity_id}/tokens | personal_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}/tokens | personal_tokens:read | — | 200 [{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:write | — | 204 (irreversible) |
POST /v1/tokens/introspect | personal_tokens:read | token=<secret> (form) | 200 {active, identity_id, sub, scope, audience?, exp?} |
- Issuance requires the identity to be
active(else 409);expires_in_daysis 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 notactive— so suspending or deleting an identity makes its personal tokens stop validating within seconds.identity_idequals the OIDCsub.last_used_atis 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 viaPATCH /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 & path | Body | Success |
|---|---|---|
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/groups | — | 200 [{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}/leave | — | 204; the owner must transfer ownership first |
PUT /v1/groups/{id}/members/{identity_id}/admin | — | 204 grant admin role |
DELETE /v1/groups/{id}/members/{identity_id}/admin | — | 204 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 & path | Scope | Body | Success |
|---|---|---|---|
GET /v1/groups | groups:read | — | 200 {items, next_cursor} — all groups in the tenant |
POST /v1/groups/{id}/reassign-owner | groups:write | {new_owner_identity_id} | 200; emergency owner reassignment (also unblocks a sole-owner disable) |
PUT /v1/identities/{id}/tenant-admin | identities:write | — | 204 grant tenant-administrator |
DELETE /v1/identities/{id}/tenant-admin | identities:write | — | 204; 409 last_tenant_admin if it would remove the last one |
PUT/DELETE /v1/identities/{id}/group-create-grant | identities:write | — | 204 grant/revoke the "may create groups" capability |
- Group creation policy is per-tenant via
PATCH /v1/tenants/{id}withgroup_creation_policy = {mode: "disabled" | "any_active_user" | "capability"}(defaultdisabled). Incapabilitymode, an identity also needs agroup-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/groupsand/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 & path | Scope | Body | Success |
|---|---|---|---|
POST /v1/identities/{identity_id}/ssh-keys | ssh_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-keys | ssh_keys:read | — | 200 [{key_id, key_type, fingerprint, …, last_used_at, expires_at, revoked}] |
DELETE /v1/identities/{identity_id}/ssh-keys/{key_id} | ssh_keys:write | — | 204 (idempotent) |
POST /v1/ssh-keys/lookup | ssh_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 & path | Body | Success |
|---|---|---|
GET /v1/me/ssh-keys | — | 200 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_code—ssh_key_invalid(unparseable) orssh_key_unsupported_type(ECDSA/DSA/short RSA), both 422. A key whose fingerprint is already active in the tenant is refused 409ssh_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 notactive— so suspending or deleting an identity makes its SSH keys stop resolving within seconds, with no separate revocation step.identity_idequals the OIDCsub;last_used_atis updated on an active result. Provide afingerprint(SHA256:…) or a fullpublic_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, default0= unlimited; the RSA floor isHAP_SSH_RSA_MIN_BITS, default 2048).scope/audienceare opaque — stored and echoed, never interpreted.
6.17 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, 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 & path | Body | Success |
|---|---|---|
PATCH /v1/tenants/{tenant_id} | {branding: {accent_color?, background_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 & path | Notes |
|---|---|
GET /t/{slug}/hosted/admin/branding | renders the current appearance form (tenant-admin only) |
POST /t/{slug}/hosted/admin/branding | saves it; non-blocking warnings shown inline |
- Validation (single source of truth,
domain/branding.py):font_familyis 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_urlmust be absolute HTTPS (nodata:, relative, or non-HTTPS). Unsafe or unknown input is refused 422invalid_request. - Low colour contrast warns but does not block (WCAG AA 4.5:1): such cases return
branding_warningson 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 abranding.updatedaudit row (the admin-API path ridestenant.updated).
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(this folder) andtarget/openapi/openapi.json(OpenAPI 3.1). Feed either toopenapi-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 108 paths 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.