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 istarget/openapi/openapi.json(regenerate withscripts/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 OpenAPIsecuritySchemes, 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 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 |
| 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" }| 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; 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):
| Signal | Hosted behaviour |
|---|---|
login_required, not signed in | Redirect to the hosted sign-in page, resuming afterwards |
consent_required, signed in | Redirect to the hosted consent screen; Allow resumes, Deny returns error=access_denied |
interaction_required, signed in | Terminal page (200, no redirect) — hosted step-up is not offered, so this now explains rather than looping |
any signal with prompt=none | Silent 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 locationLoopback 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 withinvalid_request(no redirect).httpis allowed on loopback (TLS isn't required there). - The
redirect_urisent to/oauth2/tokenmust 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 (anapplication_adminsrow).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 & 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, username, status, is_tenant_admin, mfa_enabled, custom_claims, created_at} — never includes credential material.
is_tenant_adminis the designation as stored, not the authority as effective. Theis_tenant_adminclaim in an issued token isstatus == "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 getstruefor someone who was granted the designation and later suspended — otherwise it would re-grant something that was never missing. For effective authority, readstatusfrom 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 & path | Notes | Success |
|---|---|---|
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 act | 200 {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 erasure | 204 |
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 & 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} |
GET /v1/tenants?cursor=&limit= | platform key, scope tenants:read | — | 200 {items:[TenantSummary], next_cursor} (see §6.8a) |
GET /v1/tenants/{tenant_id} | platform key, scope tenants:read | — | 200 TenantConfiguration (see §6.8a) |
POST /v1/applications | admin 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/applications | admin 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 | |
|---|---|
| Optional | Omit client_id and one is generated, exactly as before |
| Characters | Letters, digits, hyphen, period, underscore, tilde — nothing else |
| Length | 8–128 characters, with at least one letter or digit |
| Reserved | It may not begin hap_, which is reserved for identifiers the platform issues |
| Unique | Only among live applications in the same tenant; a case-variant of an existing one is refused too |
| Permanent | It cannot be changed after registration |
| Refusal | Status | Body |
|---|---|---|
| Breaks the character or length rules | 400 | error_code: client_id_invalid, plus rule: "charset" or "length" |
Begins hap_ | 400 | error_code: client_id_reserved |
| Already held by a live application in this tenant | 409 | error_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 sendnull, and the stored value is left unchanged. A partial update naming onlyredirect_uriscannot 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_modefor 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 showsname, the name you registered the application under. Set it when that name is an internal identifier: a person choosing betweenhr-payroll-2andcrm-svc-prodis guessing.nameitself 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 absolutehttp(s)address of an image shown beside it. HAP stores the address and never fetches it; the person's browser loads it. Usehttps, 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 & 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; 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 & 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.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 & path | Success |
|---|---|
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 returned —
active,suspended,cancelled. There is no status filter and non-active tenants are never hidden; they are the ones an operator most needs. -
user_countcounts identities whose status is notdeleted;application_countcounts applications that are not soft-deleted. Both read as the live population. -
provisioned_by_calleris the caller label recorded at provisioning, ornullwhen 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
nullfor 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 recordsnullby 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 separatetenant_admins:anyscope. A consumer that infers "I can hand this tenant over" from a matchingprovisioned_by_callerwill 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 asGET /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_configuredplus the publicjwks_uri; the registration decision-service secret appears only asregistration_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 toaudit_retention_days,username_quarantine_days,rate_limit_policy,password_policy. - Reading a tenant's configuration writes a
tenant.readaudit 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 & path | Scope | Success |
|---|---|---|
PUT /v1/platform/operators/{identity_id} | platform_operators:write | 204 (idempotent) |
DELETE /v1/platform/operators/{identity_id} | platform_operators:write | 204 (idempotent) |
GET /v1/platform/operators?cursor=&limit= | platform_operators:read | 200 {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 & 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 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_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.
- Inviting as the tenant (feature 024): when no Basic credential is presented, the request is
authorised as a tenant administrator instead —
X-Tenant-Idplus either anX-Admin-Keycarrying the scopeinvitations:writeor a tenant-admin session (Authorization: Bearer <session token>). On this pathapplication_idis required (an application credential implies its own application; a tenant admin must say which one), andemail_text_body/email_html_bodyare optional — omitted, the platform's default invitation bodies are used. An unknown, other-tenant or deletedapplication_idis 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 & 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 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 & path | Scope | Body | Success |
|---|---|---|---|
POST /v1/identities/{identity_id}/email-aliases | email_aliases:write | {alias_email} | 202 {status:"pending"} (code emailed) · 200 {status:"already_verified", alias_id} |
POST /v1/identities/{identity_id}/email-aliases/verify | email_aliases:write | {alias_email, code} | 200 {status:"verified", alias_id} |
POST /v1/identities/{identity_id}/email-aliases/register | email_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-aliases | email_aliases:read | — | 200 [{alias_id, alias_email, verified_at}] (verified only) |
DELETE /v1/identities/{identity_id}/email-aliases/{alias_id} | email_aliases:write | — | 204; 404 if the alias is not this identity's |
POST /v1/email-aliases/lookup | email_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 scopeemail_aliases:register(its own key) so the member self-service:writekey can never mint a verified alias without the emailed-code proof. Because no email is sent,registervalidates the address for syntax only — alocal-part@domainwith a dotted domain — so reserved / non-routable TLDs (.local,.internal,.lan,.home, …) and non-resolvable domains are accepted, including Git's auto-stampeduser@host.localon an unconfigured machine. Only genuinely malformed input (empty, no@, whitespace, single-label domain) isemail_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 a422), so a registered.localalias resolves like any other. -
Errors carry a machine
error_codein 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 429temporarily_unavailablewithRetry-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 notactive— 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_idequals the OIDCsub. 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
disabledmode (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 & path | Body | Success |
|---|---|---|
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 & 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).
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.
| Scope | Grants |
|---|---|
tenants:provision | Create a tenant with its first administrator. Confers nothing over any existing tenant, identity, group, application or credential. |
tenant_admins:write | Preview/initiate a handover on a tenant this caller provisioned. |
tenant_admins:any | Handover on any tenant, ignoring provenance. A distinct, strictly stronger scope — holding tenant_admins:write does not confer it. |
admin_keys:write | Issue 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 & path | Body | Success |
|---|---|---|
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 & path | Body | Success |
|---|---|---|
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_emailomitted ⇒ byte-identical legacy behaviour (tenant only, no administrator, sign-in surface untouched), so existing callers are unaffected.admin_emailpresent ⇒ 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/adminand they receive an emailed code. Self-enrolment is not switched on —passwordless_jit_provisioningstays off, so provisioning a tenant does not open it to strangers. welcome_emailissent|failed|not_sent.failedis not an error: delivery is best-effort and never fails or rolls back a provisioned tenant.- Send
Idempotency-Keyfrom any form a user can double-submit. An identical repeat returns 200 with the original body; the same key with different content is 409idempotency_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:
| Input | Result |
|---|---|
Acme Corp!, ACME, acme_corp | 422 tenant_slug_invalid, rule: charset (lowercase letters, digits, hyphens only) |
ab, 41+ chars | 422 rule: length (3–40) |
1acme | 422 rule: leading_character (must start with a letter) |
acme- | 422 rule: trailing_hyphen |
ac--me | 422 rule: consecutive_hyphen |
admin, api, support, … | 422 rule: reserved |
| already taken | 409 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 & path | Body | Success |
|---|---|---|
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:
- 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. - Preview → confirm — initiation requires the single-use
confirmation_tokenfrom 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. - 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.
- Grant-only — acceptance adds an administrator;
administrators_removedis always0. Retiring the previous administrator is the tenant's own act in its own console: the provisioning credential holds noidentities: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.
| Status | error_code | When |
|---|---|---|
| 404 | — | Tenant not found or not provisioned by this caller (indistinguishable) |
| 409 | handover_already_administrator | Address already administers this tenant |
| 409 | handover_pending | A live handover already exists for this tenant |
| 409 | tenant_unavailable | Tenant suspended/deleted since initiation |
| 422 | handover_confirmation_invalid | Confirmation token missing, expired, used, or mismatched |
| 422 | handover_email_invalid | Address invalid or disposable |
| 422 | handover_target_unavailable | Address belongs to a suspended/deleted user in that tenant |
| 410 | handover_expired | Proof 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_sessioncookie 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,Securewhen the issuer ishttps,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_tois reduced to a same-origin relative target, so the page cannot be used as an open redirector. - An OMITTED or empty
return_tonow means "no application sent me" and lands on the application selector (/t/{slug}, §6.22) instead of being refused. Areturn_tothat 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 & path | Auth | Body | Success |
|---|---|---|---|
GET /v1/registration-policy | registration_policy:read or tenant-admin session | — | 200 settings + version |
PUT /v1/registration-policy | registration_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: truecloses 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_rulesreports 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.comdoes not matchmail.acme.com). One bad entry fails the whole write with 422registration_domain_invalidnaming it. - 409
registration_allowlist_empty— an allow-list cannot be switched on with no domains; that is whatinvitation_onlyis for. - 409
registration_policy_conflict—versionis 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 & path | Auth | Body | Success |
|---|---|---|---|
GET /v1/me/applications | X-Tenant-Id + Authorization: Bearer <session token> | — | 200 {applications:[{application_id, client_id, name, label, description, icon_url, home_url, launchable, source}], selector_relevant, destinations} |
labelis what to SHOW the person: the administrator-set label if there is one, otherwisename. It is always present.descriptionandicon_urlarenullwhen unset. Entries come back ordered bylabel, case-insensitively — the same order the hosted menu uses.launchableisfalseexactly whenhome_urlisnull. Do not forward to a non-launchable application — show the same "ask an administrator" message the hosted page shows.sourceisregistered(a grant this person holds),group(a grant one of their groups holds), orself_enrollment(advertised to them, not yet used).- Ordered by name; an empty list is a
200with{"applications": []}, never a 404. selector_relevantanswers "does this person have a choice to make?" —truewhen they have two or more applications or a non-emptydestinations. It is the same answer the hosted exit page branches on, so you can build the same behaviour without reading a page.destinationsis 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 makeselector_relevanttrue for everybody.
Hosted equivalents (opt-in Hosted UI, tenants with hosted login enabled):
| Path | Purpose |
|---|---|
GET /t/{slug}GET /t/{slug}/hosted/apps | The 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}/go | Choose 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}/exit | Leave 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) 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 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.