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 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

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), 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 location

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>

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 & 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, 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 & pathNotesSuccess
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 pagination200 {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 erasure204

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}
POST /v1/applicationsadmin 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/applicationsadmin 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/providersadmin 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 & 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; 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 & 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.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{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_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.

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 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 & pathBodySuccess
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 & 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).

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) 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 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.