Heimdall docs
API reference

Heimdall Consumer API.

Sign in your end-users, verify their tokens, and check permissions. Every endpoint is generated from the live OpenAPI spec.

Replace {app_slug} in every URL with the slug of your Heimdall app. The slug is shown when you open the app in the console.

Base URL

https://api.heimdall.productcraft.co

Auth

Authorization: Bearer …

Spec version

OpenAPI 3.0.0 · v1.0.0

Consumer · Discovery

get/{app_slug}/v1/.well-known/jwks.json

Per-app JWKS

Returns the JSON Web Key Set used to sign EndUser and M2M access tokens for this app. Cached at the edge for 1 hour; verifiers should re-fetch on `kid` miss.

Path parameters

app_slug*string

Response · 200

keys*array

Example

Request

GET /{app_slug}/v1/.well-known/jwks.json

Response

{
  "keys": [
    {
      "kid": "rs256-moc6k64k-b2214480",
      "kty": "RSA",
      "alg": "RS256",
      "use": "sig",
      "n": "string",
      "e": "AQAB"
    }
  ]
}
get/{app_slug}/v1/.well-known/openid-configuration

OAuth 2.0 / OIDC discovery document (subset)

Returns `issuer`, `jwks_uri`, `token_endpoint`, `introspection_endpoint`, `userinfo_endpoint` (= `/me`), and the supported grant + signing options. Use this from SDKs / verifiers instead of hardcoding paths.

Path parameters

app_slug*string

Response · 200

issuer*string

Token `iss` claim. Verifier libraries should accept this literal — we don't issue tokens with a URL-shaped issuer because tokens are per-app, not per-host.

Example: "heimdall"

jwks_uri*string

Example: "https://api.heimdall.productcraft.co/<slug>/v1/.well-known/jwks.json"

token_endpoint*string

Example: "https://api.heimdall.productcraft.co/<slug>/v1/oauth/token"

introspection_endpoint*string

Example: "https://api.heimdall.productcraft.co/<slug>/v1/oauth/introspect"

userinfo_endpoint*string

OIDC userinfo equivalent — returns the signed-in EndUser profile.

Example: "https://api.heimdall.productcraft.co/<slug>/v1/me"

heimdall_verify_endpoint*string

Heimdall-specific verify endpoint. Not part of standard OIDC.

Example: "https://api.heimdall.productcraft.co/<slug>/v1/verify"

heimdall_authorize_endpoint*string

Heimdall-specific authorize endpoint. Not part of standard OIDC.

Example: "https://api.heimdall.productcraft.co/<slug>/v1/authorize"

heimdall_admin_users_endpoint*string

Customer-product admin lane root. M2M-callable user CRUD.

Example: "https://api.heimdall.productcraft.co/<slug>/v1/admin/users"

grant_types_supported*array

Example: ["client_credentials"]

response_types_supported*array

Example: ["token"]

token_endpoint_auth_methods_supported*array

Example: ["client_secret_post"]

id_token_signing_alg_values_supported*array

Example: ["RS256"]

subject_types_supported*array

Example: ["public"]

Example

Request

GET /{app_slug}/v1/.well-known/openid-configuration

Response

{
  "issuer": "heimdall",
  "jwks_uri": "https://api.heimdall.productcraft.co/<slug>/v1/.well-known/jwks.json",
  "token_endpoint": "https://api.heimdall.productcraft.co/<slug>/v1/oauth/token",
  "introspection_endpoint": "https://api.heimdall.productcraft.co/<slug>/v1/oauth/introspect",
  "userinfo_endpoint": "https://api.heimdall.productcraft.co/<slug>/v1/me",
  "heimdall_verify_endpoint": "https://api.heimdall.productcraft.co/<slug>/v1/verify",
  "heimdall_authorize_endpoint": "https://api.heimdall.productcraft.co/<slug>/v1/authorize",
  "heimdall_admin_users_endpoint": "https://api.heimdall.productcraft.co/<slug>/v1/admin/users",
  "grant_types_supported": [
    "client_credentials"
  ],
  "response_types_supported": [
    "token"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_post"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "subject_types_supported": [
    "public"
  ]
}

Consumer · Auth

post/{app_slug}/v1/auth/signup

Sign up an EndUser

Create an EndUser account inside the app and immediately mint a session. The supplied email becomes the primary email contact (unverified at signup; the customer triggers verification via /auth/request-verification or /auth/send-verification-email). Subject to the app-configured signup policy (signup enabled, password policy, default role, optional `signup_requires_pak`).

Path parameters

app_slug*string

Headers

authorization*string

Request body

username*string

Unique username inside the app. 3+ chars.

Example: "jane_doe"

email*string

EndUser email address. Becomes the primary email contact and the default verification target.

Example: "jane@example.com"

password*string

Password. Must satisfy the app-configured policy (default: 8+ chars).

Example: "CorrectHorseBatteryStaple"

display_namestring

Optional display name shown back to the user.

Example: "Jane Doe"

Response · 200

access_token*string

Short-lived JWT signed with the app-scoped JWKS key. Carries sub, role, permissions[], type=end_user.

refresh_token*string

Long-lived opaque-ish JWT used to mint a new access token via /auth/refresh. Rotated on use.

token_type*string

Example: "Bearer"

expires_in*number

Access-token lifetime in seconds.

Example: 3600

Example

Request

POST /{app_slug}/v1/auth/signup
Content-Type: application/json

{
  "username": "jane_doe",
  "email": "jane@example.com",
  "password": "CorrectHorseBatteryStaple",
  "display_name": "Jane Doe"
}

Response

{
  "access_token": "string",
  "refresh_token": "string",
  "token_type": "Bearer",
  "expires_in": 3600
}
post/{app_slug}/v1/auth/signin

Sign in an EndUser

Authenticate with username or verified primary email + password. When the account has no enabled MFA factors, returns a fresh access + refresh token pair (`ConsumerTokenResponseDto` shape). When the account has any enabled factor, returns an MFA challenge instead (`ConsumerMfaChallengeResponseDto` shape, `mfa_required: true`). In that case no session has been created yet — complete the challenge via `POST /auth/mfa/verify` (or `/auth/mfa/recover`) to receive the token pair.

Path parameters

app_slug*string

Request body

identifier*string

Username or verified primary email — either accepted. Secondary or unverified emails do not authenticate.

Example: "jane_doe"

password*string

EndUser password.

tenant_idstring

Optional tenant UUID. When set, the issued JWT carries `tid` + `trole` claims and the user must already be a member of that tenant — non-membership returns 403.

Response · 200 Either a normal token response or an MFA challenge — distinguish by `mfa_required`.

object

Example

Request

POST /{app_slug}/v1/auth/signin
Content-Type: application/json

{
  "identifier": "jane_doe",
  "password": "string",
  "tenant_id": "string"
}

Response

{}
post/{app_slug}/v1/auth/mfa/verify

Complete an MFA challenge with a TOTP code

Submit the `mfa_token` from a `mfa_required: true` signin response, plus a current TOTP code from the user's authenticator app. Mints session + tokens with `amr: ["pwd","totp"]` and `mfa_at` set to now. The challenge is single-use; 5 wrong codes locks it.

Path parameters

app_slug*string

Request body

mfa_token*string

The `mfa_token` returned by `/auth/signin`. UUID.

code*string

A current TOTP code (6 digits).

Example: "123456"

Response · 200

access_token*string

Short-lived JWT signed with the app-scoped JWKS key. Carries sub, role, permissions[], type=end_user.

refresh_token*string

Long-lived opaque-ish JWT used to mint a new access token via /auth/refresh. Rotated on use.

token_type*string

Example: "Bearer"

expires_in*number

Access-token lifetime in seconds.

Example: 3600

Example

Request

POST /{app_slug}/v1/auth/mfa/verify
Content-Type: application/json

{
  "mfa_token": "string",
  "code": "123456"
}

Response

{
  "access_token": "string",
  "refresh_token": "string",
  "token_type": "Bearer",
  "expires_in": 3600
}
post/{app_slug}/v1/auth/mfa/recover

Complete an MFA challenge with a recovery code

Same flow as `/auth/mfa/verify` but consumes a single-use recovery code (16 hex chars; hyphens optional). Mints tokens with `amr: ["pwd","recovery_code"]`. After successful recovery, the user should regenerate their recovery codes via `/me/mfa/recovery-codes/regenerate`.

Path parameters

app_slug*string

Request body

mfa_token*string

The `mfa_token` returned by `/auth/signin`. UUID.

recovery_code*string

A single-use recovery code (16 hex chars, hyphens optional). Consumes the code on success — the user should regenerate the set afterwards via `/me/mfa/recovery-codes/regenerate`.

Example: "1a2b-3c4d-5e6f-7890"

Response · 200

access_token*string

Short-lived JWT signed with the app-scoped JWKS key. Carries sub, role, permissions[], type=end_user.

refresh_token*string

Long-lived opaque-ish JWT used to mint a new access token via /auth/refresh. Rotated on use.

token_type*string

Example: "Bearer"

expires_in*number

Access-token lifetime in seconds.

Example: 3600

Example

Request

POST /{app_slug}/v1/auth/mfa/recover
Content-Type: application/json

{
  "mfa_token": "string",
  "recovery_code": "1a2b-3c4d-5e6f-7890"
}

Response

{
  "access_token": "string",
  "refresh_token": "string",
  "token_type": "Bearer",
  "expires_in": 3600
}
post/{app_slug}/v1/auth/refresh

Exchange a refresh token for a new access token

Rotates the refresh token on every call — the previous refresh token is revoked, and re-using it triggers session revocation.

Path parameters

app_slug*string

Request body

refresh_token*string

Refresh token returned from signin/signup/previous refresh. Each refresh rotates the token; the previous one is revoked.

Response · 200

access_token*string

Short-lived JWT signed with the app-scoped JWKS key. Carries sub, role, permissions[], type=end_user.

refresh_token*string

Long-lived opaque-ish JWT used to mint a new access token via /auth/refresh. Rotated on use.

token_type*string

Example: "Bearer"

expires_in*number

Access-token lifetime in seconds.

Example: 3600

Example

Request

POST /{app_slug}/v1/auth/refresh
Content-Type: application/json

{
  "refresh_token": "string"
}

Response

{
  "access_token": "string",
  "refresh_token": "string",
  "token_type": "Bearer",
  "expires_in": 3600
}
post/{app_slug}/v1/auth/logout

Revoke a refresh token

Destroys the session that owns the refresh token. The matching access token continues to verify until its TTL expires.

Path parameters

app_slug*string

Request body

refresh_token*string

Refresh token to revoke. The matching session is destroyed; the access token continues to verify until its TTL expires.

Response · 204

No content.

Example

Request

POST /{app_slug}/v1/auth/logout
Content-Type: application/json

{
  "refresh_token": "string"
}
post/{app_slug}/v1/auth/request-verificationAuth

Mint a contact-verification code (PAK-required)

Mints a fresh 6-digit verification code bound to the email or phone contact named in the body. Body must contain exactly one of `email` / `phone`. PAK-required — `Authorization: Bearer <pcft_live_*>` carrying `heimdall.user.verify.create` narrowed to this app's URN. Public mint flows are refused with 401 — any anonymous mint endpoint is a spam-forwarder vector. Returns `{ code, expires_at }` for delivery via the customer's own SMTP / SMS provider. Returns `{}` (no code) when the contact doesn't match an account in this app or is already verified — uniform shape prevents enumeration.

Path parameters

app_slug*string

Headers

authorization*string

Request body

emailstring

Email contact value to target. Set this OR `phone`, not both.

Example: "jane@example.com"

phonestring

Phone contact value (any format the customer normalised; recommended E.164). Set this OR `email`, not both.

Example: "+15551234567"

Response · 201 `{ code, expires_at }` when the contact exists and is unverified; `{}` otherwise.

code*string

Plaintext 6-digit code. Returned when the contact matches an EndUser in this app and the precondition is satisfied (contact unverified, for verification; contact verified, for reset). Otherwise the response is `{}` (no code) — same shape on both branches to prevent enumeration.

Example: "123456"

expires_at*string

Code expiry, ISO 8601. Codes live for 10 minutes.

Example: "2026-05-09T17:30:00.000Z"

Example

Request

POST /{app_slug}/v1/auth/request-verification
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "email": "jane@example.com",
  "phone": "+15551234567"
}

Response

{
  "code": "123456",
  "expires_at": "2026-05-09T17:30:00.000Z"
}
post/{app_slug}/v1/auth/send-verification-emailAuth

Mint + dispatch a verification email via Envoi (PAK-required)

Mints a fresh 6-digit verification code AND dispatches it to the supplied email address via the workspace's verified Envoi sender, in one call. Email-only — the customer integrates SMS delivery themselves through `request-verification`. PAK-required — distinct permission from the bare mint so customers can enable mint without enabling outbound Envoi traffic. Surfaces typed precondition errors instead of the silent fail-closed of a fire-and-forget mailer: * 412 ENVOI_NOT_ENABLED — `notifications_via_envoi` is false * 412 ENVOI_SENDER_NOT_CONFIGURED * 412 ENVOI_TEMPLATE_NOT_CONFIGURED * 412 ENVOI_APP_NOT_BOUND_TO_WORKSPACE * 503 ENVOI_DISPATCH_FAILED — RabbitMQ publish failure * 503 ENVOI_NOT_WIRED — heimdall has no mailbox client On success returns `{ expires_at }`. The plaintext code is NOT returned. Returns `{}` when the contact is missing or already verified (silent — same enumeration defence as `request-verification`).

Path parameters

app_slug*string

Headers

authorization*string

Request body

email*string

Email contact value to send the verification code to.

Example: "jane@example.com"

Response · 201 `{ expires_at }` on success; `{}` on no-match.

expires_at*string

Code expiry, ISO 8601. The plaintext code is NOT returned — Envoi has dispatched it.

Example: "2026-05-09T17:30:00.000Z"

Example

Request

POST /{app_slug}/v1/auth/send-verification-email
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "email": "jane@example.com"
}

Response

{
  "expires_at": "2026-05-09T17:30:00.000Z"
}
post/{app_slug}/v1/auth/verify

Submit a verification code

Consumes a 6-digit verification code (single-use) and flips the bound contact's `verified_at`. Public — anyone holding the code can submit it; that is the whole point of the verification flow. The server figures out which contact + account from the code value itself. Renamed from /verify-email — the same endpoint now serves both email and phone verifications.

Path parameters

app_slug*string

Request body

code*string

6-digit numeric verification code. The server resolves which contact the code is for from the code value itself.

Example: "123456"

Response · 200 Contact verified.

account_id*string

EndUser account id whose contact was verified.

contact_id*string

Account_contact id that just flipped to verified.

type*string

Contact type: `email` or `phone`.

Example: "email"

value*string

Normalised contact value.

Example: "jane@example.com"

verified_at*string

Verification timestamp, ISO 8601.

Example

Request

POST /{app_slug}/v1/auth/verify
Content-Type: application/json

{
  "code": "123456"
}

Response

{
  "account_id": "string",
  "contact_id": "string",
  "type": "email",
  "value": "jane@example.com",
  "verified_at": "string"
}
post/{app_slug}/v1/auth/request-password-resetAuth

Mint a password-reset code (PAK-required)

Mints a fresh 6-digit password-reset code for the EndUser owning the named contact. Body must contain exactly one of `email` / `phone` — the contact must be verified (otherwise an attacker who registered an unverified contact could push a reset on another user's account, were the per-app email uniqueness ever breached). The reset itself is account-scoped (revokes all sessions, replaces password) — the contact only acts as the lookup key. PAK-required: `heimdall.user.password-reset.create`. Returns `{ code, expires_at }` on success, `{}` on no-match (uniform shape prevents enumeration). Renamed from /auth/request-reset.

Path parameters

app_slug*string

Headers

authorization*string

Request body

emailstring

Email contact value to target. Set this OR `phone`, not both.

Example: "jane@example.com"

phonestring

Phone contact value (any format the customer normalised; recommended E.164). Set this OR `email`, not both.

Example: "+15551234567"

Response · 201 `{ code, expires_at }` when the contact matches a verified account; `{}` otherwise.

code*string

Plaintext 6-digit code. Returned when the contact matches an EndUser in this app and the precondition is satisfied (contact unverified, for verification; contact verified, for reset). Otherwise the response is `{}` (no code) — same shape on both branches to prevent enumeration.

Example: "123456"

expires_at*string

Code expiry, ISO 8601. Codes live for 10 minutes.

Example: "2026-05-09T17:30:00.000Z"

Example

Request

POST /{app_slug}/v1/auth/request-password-reset
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "email": "jane@example.com",
  "phone": "+15551234567"
}

Response

{
  "code": "123456",
  "expires_at": "2026-05-09T17:30:00.000Z"
}
post/{app_slug}/v1/auth/send-password-reset-emailAuth

Mint + dispatch a password-reset email via Envoi (PAK-required)

Same shape as `send-verification-email` but for the reset slot. PAK perm: `heimdall.user.password-reset.send-email`. Same loud precondition errors. Email-only.

Path parameters

app_slug*string

Headers

authorization*string

Request body

email*string

Email contact value to send the password-reset code to.

Example: "jane@example.com"

Response · 201 `{ expires_at }` on success; `{}` on no-match.

expires_at*string

Code expiry, ISO 8601. The plaintext code is NOT returned — Envoi has dispatched it.

Example: "2026-05-09T17:30:00.000Z"

Example

Request

POST /{app_slug}/v1/auth/send-password-reset-email
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "email": "jane@example.com"
}

Response

{
  "expires_at": "2026-05-09T17:30:00.000Z"
}
post/{app_slug}/v1/auth/reset-password

Submit a reset code + new password

Consumes the 6-digit reset code (single-use). On success the password is updated and every active session for the user is revoked.

Path parameters

app_slug*string

Request body

code*string

6-digit numeric code from the password-reset message.

Example: "123456"

new_password*string

New password. Must satisfy the app-configured policy.

Response · 204

Password updated.

Example

Request

POST /{app_slug}/v1/auth/reset-password
Content-Type: application/json

{
  "code": "123456",
  "new_password": "string"
}
post/{app_slug}/v1/auth/switch-tenantAuth

Switch the active tenant for this session

Re-issues the EndUser's access + refresh tokens with the new `org_id` + `org_role` claims, after verifying the caller holds a tenant_membership for the target tenant. The session row is updated to the new tenant so subsequent refreshes preserve the context. Refresh-token rotation runs as usual; the previous refresh token enters the standard 60-second grace window.

Path parameters

app_slug*string

Request body

tenant_id*string

Tenant UUID to switch the EndUser's session into. Caller must already hold a tenant_membership row for this tenant in the app, else 403.

Response · 200

access_token*string

Short-lived JWT signed with the app-scoped JWKS key. Carries sub, role, permissions[], type=end_user.

refresh_token*string

Long-lived opaque-ish JWT used to mint a new access token via /auth/refresh. Rotated on use.

token_type*string

Example: "Bearer"

expires_in*number

Access-token lifetime in seconds.

Example: 3600

Example

Request

POST /{app_slug}/v1/auth/switch-tenant
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "tenant_id": "string"
}

Response

{
  "access_token": "string",
  "refresh_token": "string",
  "token_type": "Bearer",
  "expires_in": 3600
}
get/{app_slug}/v1/auth/me/tenantsAuth

List the EndUser's tenant memberships in this app

Returns each tenant the caller is a member of, including the tenant slug + display name and the member's role. Powers a tenant-switcher UI on the customer's product.

Path parameters

app_slug*string

Response · 200

data*array

Example

Request

GET /{app_slug}/v1/auth/me/tenants
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "tenant_id": "string",
      "display_id": "tnt_3a9f2b1c0d4e",
      "tenant_slug": "string",
      "display_name": "string",
      "role_name": "string",
      "joined_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}

Consumer · Me

get/{app_slug}/v1/meAuth

Get the signed-in EndUser profile

Returns the EndUser identity, app membership, and role name. The flat `permissions[]` array is intentionally NOT included — permissions resolve from the role on demand. Call `GET /me/permissions` for the flat list when the client needs it for UI gating.

Response · 200

id*string

EndUser account id (UUID).

username*string

Username inside the app.

Example: "ada"

display_name*string

Example: "Ada Lovelace"

role*string

App-level role name. Resolves per-request from the JWT.

Example: "member"

joined_at*string · date-time

When the EndUser joined the app.

created_at*string · date-time

When the account row was created.

email*string

Primary email contact value. `null` if the user has no email contact.

Example: "ada@example.com"

email_verified_at*string · date-time

When the primary email was verified. `null` until verification completes.

Example

Request

GET /{app_slug}/v1/me
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "string",
  "username": "ada",
  "display_name": "Ada Lovelace",
  "role": "member",
  "joined_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "email": "ada@example.com",
  "email_verified_at": "2026-01-01T00:00:00.000Z"
}
patch/{app_slug}/v1/meAuth

Update the signed-in EndUser profile

Currently only the display name can be changed.

Request body

display_namestring

New display name. Pass an empty string to clear it; omit to leave unchanged.

Example: "Ada Lovelace"

Response · 200

Updated account row.

Example

Request

PATCH /{app_slug}/v1/me
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "display_name": "Ada Lovelace"
}
delete/{app_slug}/v1/meAuth

Permanently delete the signed-in EndUser account

Removes the EndUser, all sessions, and all related records inside this app. Cannot be undone.

Response · 204

Account deleted.

get/{app_slug}/v1/me/permissionsAuth

Resolve the signed-in EndUser's effective permissions

Returns the union of the app-level role permissions and the tenant-level role permissions (when the token carries an `org_id`). Resolved per-request through a 60-second LRU — role-permission edits propagate within ~1 minute. Use this instead of decoding a `permissions[]` claim from the JWT, which is no longer included. M2M tokens cannot call this — use `POST /:appSlug/v1/oauth/introspect` to inspect M2M scopes.

Response · 200

role*string

App-level role name from the EndUser's token. `null` when unset.

Example: "admin"

org_role*string

Tenant-scoped role name from the token when signed into a tenant. `null` otherwise.

Example: "editor"

permissions*array

Sorted union of the app-level role permissions and the tenant-scoped role permissions when applicable.

Example: ["user.read","user.list"]

Example

Request

GET /{app_slug}/v1/me/permissions
Authorization: Bearer YOUR_TOKEN

Response

{
  "role": "admin",
  "org_role": "editor",
  "permissions": [
    "user.read",
    "user.list"
  ]
}
post/{app_slug}/v1/me/change-passwordAuth

Change the password of the signed-in EndUser

Verifies `current_password`, sets the new hash, and revokes every OTHER active session for the account (the calling session is preserved). 401 if the current password is wrong or the account has no password credential — admin-provisioned accounts without an initial password should use the reset flow instead.

Request body

current_password*string

The current password — required even when changing.

new_password*string

The new password. Must be at least 8 chars; the app may enforce a stricter policy.

Response · 204

Password updated.

Example

Request

POST /{app_slug}/v1/me/change-password
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "current_password": "string",
  "new_password": "string"
}
get/{app_slug}/v1/me/sessionsAuth

List all active sessions for the EndUser

Each session includes a `current` flag identifying the one belonging to the bearer token used for this request.

Response · 200

data*array
pagination*object

{ next_cursor, has_more } — pagination fixed to a single page.

Example

Request

GET /{app_slug}/v1/me/sessions
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "string",
      "ip": "string",
      "user_agent": "string",
      "created_at": "2026-01-01T00:00:00.000Z",
      "last_used_at": "2026-01-01T00:00:00.000Z",
      "expires_at": "2026-01-01T00:00:00.000Z",
      "is_current": false
    }
  ],
  "pagination": {}
}
delete/{app_slug}/v1/me/sessions/{id}Auth

Revoke a session

Sign the EndUser out of one device. The session's refresh token is destroyed; its access token expires on its TTL. Requires the `session.revoke` permission when the app's `enforce_app_permissions` flag is on; otherwise (the default) any valid token can call this. Self-revoke (revoking your own session as a member with no perms) is something to enforce separately if needed; this guard does not exempt the principal from the role check.

Path parameters

id*string

Response · 204

Session revoked.

get/{app_slug}/v1/me/activityAuth

Recent account activity

Login events, password changes, and other security-relevant records, scoped to this EndUser.

Response · 200

data*array
pagination*object

Example

Request

GET /{app_slug}/v1/me/activity
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "string",
      "app_id": "string",
      "action": "auth.session.created",
      "resource_type": "string",
      "resource_id": "string",
      "actor_id": "string",
      "details": {},
      "created_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {}
}
get/{app_slug}/v1/me/contactsAuth

List the EndUser's contacts

Response · 200

data*array

Example

Request

GET /{app_slug}/v1/me/contacts
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "string",
      "type": "email",
      "value": "string",
      "is_primary": false,
      "verified_at": "2026-01-01T00:00:00.000Z",
      "created_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}
post/{app_slug}/v1/me/contactsAuth

Add a new contact (unverified, non-primary)

Adds an email or phone contact to the EndUser. The contact starts unverified and non-primary. Verification is a separate flow — call /auth/request-verification with the contact value and the customer's PAK to mint a code, then submit via /auth/verify.

Request body

type*enum (2)

Example: "email"

value*string

Email or phone value (E.164 recommended for phone)

is_primaryboolean

Whether to flag the new contact as primary for its type. Demotes the previous primary of the same type. Default false.

Response · 201

id*string
type*enum (2)
value*string
is_primary*boolean
verified_at*string · date-time
created_at*string · date-time

Example

Request

POST /{app_slug}/v1/me/contacts
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "type": "email",
  "value": "string",
  "is_primary": false
}

Response

{
  "id": "string",
  "type": "email",
  "value": "string",
  "is_primary": false,
  "verified_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z"
}
delete/{app_slug}/v1/me/contacts/{id}Auth

Delete a contact

Path parameters

id*string

Response · 204

Contact deleted.

post/{app_slug}/v1/me/contacts/{id}/promoteAuth

Promote a verified contact to primary

Demotes the previous primary of the same type. Refused (409) when the target contact is unverified — promoting an unverified contact would let an attacker who registered the contact bypass verification by routing reset codes through it.

Path parameters

id*string

Response · 204

Contact promoted.


Consumer · Verify

post/{app_slug}/v1/verify

Verify a token signature + return its principal

Validates the JWT against the app's JWKS keys and returns `{ valid, principal }`. Use this when you need the full claims; use `/authorize` for a yes/no permission check.

Path parameters

app_slug*string

Request body

token*string

EndUser or M2M JWT to verify against the app JWKS.

Response · 200

valid*boolean

True iff the signature, issuer, audience, and session are all valid.

errorstring

Failure code on `valid: false` — e.g. `TOKEN_INVALID`, `TOKEN_EXPIRED`, `TOKEN_REVOKED`, `ACCOUNT_SUSPENDED`.

principalobject
sub*string

Subject — EndUser account id or M2M client id.

aid*string

App UUID this token is bound to.

rolestring

App-level role name when EndUser.

permissionsarray

Permission claim — only set on M2M tokens.

type*string

Principal type, e.g. `end_user` or `m2m`.

Example

Request

POST /{app_slug}/v1/verify
Content-Type: application/json

{
  "token": "string"
}

Response

{
  "valid": false,
  "error": "string",
  "principal": {
    "sub": "string",
    "aid": "string",
    "role": "string",
    "permissions": [
      "string"
    ],
    "type": "string"
  }
}
post/{app_slug}/v1/authorize

Verify a token + check one or more permissions

Returns `{ authorized: boolean }`. Pass `permission` (string) for a single check, or `permissions` (array) when ALL must be held.

Path parameters

app_slug*string

Request body

token*string

JWT to verify and authorize.

permissionstring

Single permission to check, e.g. 'user.read'. Use this OR `permissions`.

permissionsarray

Multiple permissions; the token must hold ALL of them.

Response · 200

authorized*boolean
errorstring

Failure code on `authorized: false`.

missing_permissionsarray

Permissions the token does not hold.

Example

Request

POST /{app_slug}/v1/authorize
Content-Type: application/json

{
  "token": "string",
  "permission": "string",
  "permissions": [
    "string"
  ]
}

Response

{
  "authorized": false,
  "error": "string",
  "missing_permissions": [
    "string"
  ]
}
post/{app_slug}/v1/authorize/batch

Multi-permission authorization in a single round-trip

Runs N permission checks against the same token in one call. Each result reports the missing permissions (if any). Useful when a UI needs to flag many actions at once.

Path parameters

app_slug*string

Request body

token*string

JWT to verify and authorize against each check.

checks*array

List of checks. Each returns { authorized, missing_permissions }.

Response · 200

results*array

Example

Request

POST /{app_slug}/v1/authorize/batch
Content-Type: application/json

{
  "token": "string",
  "checks": [
    {
      "permissions": [
        "string"
      ]
    }
  ]
}

Response

{
  "results": [
    {
      "authorized": false,
      "missing_permissions": [
        "string"
      ]
    }
  ]
}

Consumer · OAuth (M2M)

post/{app_slug}/v1/oauth/token

Exchange client_credentials for an access token

Standard OAuth 2.0 client_credentials grant. Returns an app-scoped access token whose `scopes[]` claim is the union of the client's configured scopes. Use for service-to-service calls.

Path parameters

app_slug*string

Request body

grant_type*string

OAuth grant type. Only 'client_credentials' is supported.

Example: "client_credentials"

client_id*string

M2M client identifier (m2m_*).

Example: "m2m_a1b2c3d4e5f6..."

client_secret*string

M2M client secret (issued at client creation, never returned again).

Response · 200

access_token*string

Access token (RS256 JWT signed with the app JWKS key).

token_type*string

Example: "Bearer"

expires_in*number

Access-token lifetime in seconds.

Example: 3600

scope*string

Space-separated scopes, RFC 6749.

Example: "user.read user.list"

Example

Request

POST /{app_slug}/v1/oauth/token
Content-Type: application/json

{
  "grant_type": "client_credentials",
  "client_id": "m2m_a1b2c3d4e5f6...",
  "client_secret": "string"
}

Response

{
  "access_token": "string",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "user.read user.list"
}
post/{app_slug}/v1/oauth/introspectAuth

Introspect the bearer access token (RFC 7662)

OAuth 2.0 token introspection. Send the access token via `Authorization: Bearer <token>` and receive either `{ active: false }` (invalid / expired / wrong app / revoked session) or the active claim set including `scopes` (M2M) or `role` (EndUser), `exp`, `iat`, `iss`, `aid`. The body `token` field is optional and, if present, must match the bearer — RFC 7662 allows a separate `token` body field but we deliberately refuse cross-token introspection so a stolen token can't be probed against another active credential. The endpoint accepts both M2M and EndUser tokens — use it as the canonical way for a service to know what its own token can do, instead of decoding the JWT by hand. Returns 401 when the bearer is missing.

Path parameters

app_slug*string

Headers

authorization*string

Request body

tokenstring

The access token to introspect. Optional — when omitted the endpoint introspects the `Authorization: Bearer` token. When present, it must match the bearer (we refuse cross-token introspection so a stolen token cannot be probed against a different active credential).

token_type_hintstring

Optional `token_type_hint` per RFC 7662. We only issue access tokens at this endpoint so the only meaningful value is `access_token`; the field is accepted for client-library compatibility.

Example: "access_token"

Response · 200

active*boolean

Per RFC 7662: `true` if the token is currently active for this app, `false` otherwise. An inactive response is the default for any token we can't validate — invalid signature, expired, revoked session, wrong app — and intentionally carries no other claims so an attacker can't probe the token's shape.

Example: true

substring

Subject claim: M2M client id or EndUser account id.

Example: "m2m_a1b2c3d4..."

client_idstring

OAuth `client_id` (M2M only).

Example: "cli_a1b2c3..."

typeenum (2)
scopesarray

Flat scope list (M2M only).

Example: ["user.read","user.list"]

scopestring

Space-separated `scope` string per OAuth 2.0 RFC 6749 — alias of `scopes` joined with " ".

Example: "user.read user.list"

expnumber

Token expiry as Unix timestamp.

Example: 1746906606

iatnumber

Token issued-at Unix timestamp.

Example: 1746903006

issstring

Issuer.

Example: "heimdall"

aidstring

App UUID this token is bound to.

Example: "app-uuid"

Example

Request

POST /{app_slug}/v1/oauth/introspect
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "token": "string",
  "token_type_hint": "access_token"
}

Response

{
  "active": true,
  "sub": "m2m_a1b2c3d4...",
  "client_id": "cli_a1b2c3...",
  "type": "m2m",
  "scopes": [
    "user.read",
    "user.list"
  ],
  "scope": "user.read user.list",
  "exp": 1746906606,
  "iat": 1746903006,
  "iss": "heimdall",
  "aid": "app-uuid"
}

Consumer · Admin

get/{app_slug}/v1/admin/usersAuth

List end-users in this app

Cursor-paginated. Supports the same `status` and `search` filters as the heimdall-admin equivalent. Use the cursor in subsequent calls — Heimdall does not expose offset pagination.

Path parameters

app_slug*string

Query parameters

limitstring
cursorstring
statusstring
searchstring

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /{app_slug}/v1/admin/users
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "string",
      "username": "string",
      "display_name": "string",
      "status": "string",
      "role": "string",
      "joined_at": "2026-01-01T00:00:00.000Z",
      "created_at": "2026-01-01T00:00:00.000Z",
      "email": "string",
      "email_verified_at": "2026-01-01T00:00:00.000Z",
      "active_session_count": 0,
      "last_used_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/{app_slug}/v1/admin/usersAuth

Provision a new end-user

Create an account in this app. Distinct from the public `/auth/signup`: no session is issued, no password is required (when omitted, the customer triggers a password-reset to finish onboarding), and the primary email contact starts unverified — the customer is responsible for kicking off the verification flow via `/auth/request-verification` or `/auth/send-verification-email`. The `roleName` defaults to the app's `signup_default_role`.

Path parameters

app_slug*string

Request body

email*string

Primary email — becomes the account's primary email contact (unverified).

usernamestring

Username inside the app. Derived from the email local-part if omitted.

display_namestring

Human-readable display name.

passwordstring

Optional initial password. When omitted, the account has no credential — the customer triggers the password-reset flow to onboard the user.

role_namestring

Role to assign. Defaults to the app's `signup_default_role`.

Response · 201 Created end-user record.

id*string
username*string
display_name*string
email*string
email_verified*boolean
role*string
status*string
created_at*string · date-time

Example

Request

POST /{app_slug}/v1/admin/users
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "email": "string",
  "username": "string",
  "display_name": "string",
  "password": "string",
  "role_name": "string"
}

Response

{
  "id": "string",
  "username": "string",
  "display_name": "string",
  "email": "string",
  "email_verified": false,
  "role": "string",
  "status": "string",
  "created_at": "2026-01-01T00:00:00.000Z"
}
get/{app_slug}/v1/admin/users/{user_id}Auth

Read one end-user

Path parameters

app_slug*string
user_id*string

Response · 200

id*string
username*string
display_name*string
status*string
role*string
joined_at*string · date-time
created_at*string · date-time
email*string
email_verified_at*string · date-time
active_session_count*number
last_used_at*string · date-time

Example

Request

GET /{app_slug}/v1/admin/users/{user_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "string",
  "username": "string",
  "display_name": "string",
  "status": "string",
  "role": "string",
  "joined_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "email": "string",
  "email_verified_at": "2026-01-01T00:00:00.000Z",
  "active_session_count": 0,
  "last_used_at": "2026-01-01T00:00:00.000Z"
}
patch/{app_slug}/v1/admin/users/{user_id}Auth

Update an end-user's display name or primary email

Email update promotes an existing contact to primary (not for creating new contacts — that goes through the per-contact flow).

Path parameters

app_slug*string
user_id*string

Request body

display_namestring

New display name. Empty string clears.

emailstring

New primary email address. Replaces the current primary email contact (still unverified after the change).

Response · 200

id*string
username*string
display_name*string
status*string
role*string
joined_at*string · date-time
created_at*string · date-time
email*string
email_verified_at*string · date-time
active_session_count*number
last_used_at*string · date-time

Example

Request

PATCH /{app_slug}/v1/admin/users/{user_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "display_name": "string",
  "email": "string"
}

Response

{
  "id": "string",
  "username": "string",
  "display_name": "string",
  "status": "string",
  "role": "string",
  "joined_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "email": "string",
  "email_verified_at": "2026-01-01T00:00:00.000Z",
  "active_session_count": 0,
  "last_used_at": "2026-01-01T00:00:00.000Z"
}
delete/{app_slug}/v1/admin/users/{user_id}Auth

Permanently delete an end-user

Removes the account, its sessions, contacts, and tenant memberships. Audit rows are preserved.

Path parameters

app_slug*string
user_id*string

Response · 204

End-user deleted.

patch/{app_slug}/v1/admin/users/{user_id}/statusAuth

Change an end-user status (active / suspended / deactivated)

Setting status to `suspended` or `deactivated` also revokes all active sessions for the user (same behaviour as the heimdall-admin equivalent).

Path parameters

app_slug*string
user_id*string

Request body

status*enum (3)

Response · 200

id*string
username*string
display_name*string
status*string
role*string
joined_at*string · date-time
created_at*string · date-time
email*string
email_verified_at*string · date-time
active_session_count*number
last_used_at*string · date-time

Example

Request

PATCH /{app_slug}/v1/admin/users/{user_id}/status
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "status": "active"
}

Response

{
  "id": "string",
  "username": "string",
  "display_name": "string",
  "status": "string",
  "role": "string",
  "joined_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "email": "string",
  "email_verified_at": "2026-01-01T00:00:00.000Z",
  "active_session_count": 0,
  "last_used_at": "2026-01-01T00:00:00.000Z"
}
patch/{app_slug}/v1/admin/users/{user_id}/roleAuth

Assign an end-user a role

Sets `app_membership.role_id` for the target user. For M2M callers the assignable role is bounded by the token's scopes (the guard already enforced `role.assign`); the role's own permission set is NOT additionally narrowed against the M2M scopes because M2M scopes are typically broader than per-user role perms by design (a backend cron with `user.list` + `role.assign` SHOULD be able to promote a user into a role carrying any per-app perm). EndUser admins are bounded by their own role's perms transitively — they couldn't authenticate as an admin without holding the perm.

Path parameters

app_slug*string
user_id*string

Request body

role_name*string

Target role name (must already exist in this app).

Response · 200

Membership updated.

Example

Request

PATCH /{app_slug}/v1/admin/users/{user_id}/role
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "role_name": "string"
}

Consumer · Admin · Roles

get/{app_slug}/v1/admin/rolesAuth

List roles in this app

Cursor-paginated by `created_at`. Use the returned `next_cursor` in subsequent calls — offset pagination is not exposed.

Path parameters

app_slug*string

Query parameters

limitstring
cursorstring

Response · 200

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /{app_slug}/v1/admin/roles
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "string",
      "app_id": "string",
      "name": "string",
      "description": "string",
      "is_system": false,
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/{app_slug}/v1/admin/rolesAuth

Create a role

Creates a role with no permissions bound yet. Use `PUT /:roleName/permissions` to attach a permission set after creation — that route also runs caller-narrowing so an EndUser admin can't grant verbs they don't themselves hold.

Path parameters

app_slug*string

Request body

name*string

Example: "editor"

descriptionstring

Example: "Can edit content"

Response · 201 Role created.

id*string
app_id*string
name*string
description*string
is_system*boolean
created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /{app_slug}/v1/admin/roles
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "editor",
  "description": "Can edit content"
}

Response

{
  "id": "string",
  "app_id": "string",
  "name": "string",
  "description": "string",
  "is_system": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
get/{app_slug}/v1/admin/roles/{role_name}Auth

Read one role with its permission set

Returns the role plus the flat `permissions[]` array currently bound. Use this to drive a "configure role" UI in the customer product without having to call `/permissions` separately.

Path parameters

app_slug*string
role_name*string

Response · 200

id*string
app_id*string
name*string
description*string
is_system*boolean
created_at*string · date-time
updated_at*string · date-time
permissions*array

Example

Request

GET /{app_slug}/v1/admin/roles/{role_name}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "string",
  "app_id": "string",
  "name": "string",
  "description": "string",
  "is_system": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "permissions": [
    {
      "id": "string",
      "resource": "user",
      "action": "read",
      "description": "string"
    }
  ]
}
patch/{app_slug}/v1/admin/roles/{role_name}Auth

Update a role's description

Renames are intentionally NOT supported — the role name is part of every issued EndUser JWT, so changing it would silently break live sessions. Create a new role and migrate users instead.

Path parameters

app_slug*string
role_name*string

Request body

descriptionstring

Example: "Updated description"

Response · 200

id*string
app_id*string
name*string
description*string
is_system*boolean
created_at*string · date-time
updated_at*string · date-time

Example

Request

PATCH /{app_slug}/v1/admin/roles/{role_name}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "description": "Updated description"
}

Response

{
  "id": "string",
  "app_id": "string",
  "name": "string",
  "description": "string",
  "is_system": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/{app_slug}/v1/admin/roles/{role_name}Auth

Delete a custom role

System roles (`owner`, `admin`, `member`) cannot be deleted — attempts return 403. Roles currently assigned to any member return 409 `ROLE_IN_USE`; reassign the affected members first.

Path parameters

app_slug*string
role_name*string

Response · 204

Role deleted.

put/{app_slug}/v1/admin/roles/{role_name}/permissionsAuth

Replace a role's permission set

Destructive — the role's permission set is replaced wholesale with the supplied array. Caller-narrowed: the caller must hold every permission in the request, otherwise 403 `Cannot grant actions you don't have: ...`. For EndUser callers the caller-held set is their app-membership role's effective permissions. For M2M callers the held set is the token's `scopes[]` claim — the M2M cannot grant a role any verb that wasn't configured on the M2M credential. Unknown permission strings return 400.

Path parameters

app_slug*string
role_name*string

Request body

permissions*array

Array of permission strings in resource.action format

Example: ["user.read","role.create"]

Response · 200

Permissions updated (empty body).

Example

Request

PUT /{app_slug}/v1/admin/roles/{role_name}/permissions
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "permissions": [
    "user.read",
    "role.create"
  ]
}

Consumer · Admin · Permissions

get/{app_slug}/v1/admin/permissionsAuth

List all permissions visible to this app

Returns the union of system permissions (shared across every app — `is_system: true` in the response) and any custom entries this app has defined (`is_system: false`). Useful for driving a role-editor UI that picks from the full catalogue.

Path parameters

app_slug*string

Response · 200

Array of object

id*string
app_id*string

App scope. `null` for system permissions.

resource*string

Example: "user"

action*string

Example: "read"

description*string
created_at*string · date-time
is_system*boolean

True iff the row is a system (shared) permission.

Example

Request

GET /{app_slug}/v1/admin/permissions
Authorization: Bearer YOUR_TOKEN

Response

[
  {
    "id": "string",
    "app_id": "string",
    "resource": "user",
    "action": "read",
    "description": "string",
    "created_at": "2026-01-01T00:00:00.000Z",
    "is_system": false
  }
]
post/{app_slug}/v1/admin/permissionsAuth

Create a custom permission

Inserts a `<resource>.<action>` row in the per-app permission catalogue. The resource and action segments must each match `^[a-z][a-z0-9_-]{1,47}$`. Conflicts with a system permission name return 409. Customers usually then bind the new permission to a role via `PUT /admin/roles/:roleName/permissions`.

Path parameters

app_slug*string

Request body

resource*string

Resource name

Example: "project"

action*string

Action name

Example: "read"

descriptionstring

Example: "View projects"

Response · 201 Permission created.

id*string
app_id*string

App scope. `null` for system permissions.

resource*string

Example: "user"

action*string

Example: "read"

description*string
created_at*string · date-time

Example

Request

POST /{app_slug}/v1/admin/permissions
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "resource": "project",
  "action": "read",
  "description": "View projects"
}

Response

{
  "id": "string",
  "app_id": "string",
  "resource": "user",
  "action": "read",
  "description": "string",
  "created_at": "2026-01-01T00:00:00.000Z"
}
delete/{app_slug}/v1/admin/permissions/{permission_key}Auth

Delete a custom permission

The `permissionKey` path parameter is the literal `<resource>.<action>` string. System permissions cannot be deleted — attempts return 403. Removing a permission also removes every `role_permission` row that bound it (cascade); role lookups dropped of that perm on the next request.

Path parameters

app_slug*string
permission_key*string

Response · 204

Permission deleted.


Consumer · Admin · MFA

get/{app_slug}/v1/users/{user_id}/factorsAuth

List a user's MFA factors

Path parameters

app_slug*string
user_id*string

Response · 200

data*array

Example

Request

GET /{app_slug}/v1/users/{user_id}/factors
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "string",
      "type": "totp",
      "label": "string",
      "enabled": false,
      "created_at": "2026-01-01T00:00:00.000Z",
      "enabled_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}
post/{app_slug}/v1/users/{user_id}/factorsAuth

Provision an MFA factor on behalf of a user

Creates the factor in the enabled state and returns the cleartext secret + otpauth URI + recovery codes in the response. Intended for synthetic-account-style flows where the caller bundles credentials and ships them out once.

Path parameters

app_slug*string
user_id*string

Request body

type*enum (1)

Example: "totp"

labelstring

Optional human-readable label shown in the authenticator app and on the factor list (e.g. "iPhone 15").

Response · 201

factor*object
id*string
type*string

Factor type. Currently `totp` is the only supported value; future factor types (webauthn, etc.) will extend this list without a breaking change to the response shape.

Example: "totp"

label*string
enabled*boolean
created_at*string · date-time
enabled_at*string · date-time
enrollment*object
recovery_codes*array

Cleartext single-use recovery codes minted alongside the factor.

Example

Request

POST /{app_slug}/v1/users/{user_id}/factors
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "type": "totp",
  "label": "string"
}

Response

{
  "factor": {
    "id": "string",
    "type": "totp",
    "label": "string",
    "enabled": false,
    "created_at": "2026-01-01T00:00:00.000Z",
    "enabled_at": "2026-01-01T00:00:00.000Z"
  },
  "enrollment": {},
  "recovery_codes": [
    "string"
  ]
}
delete/{app_slug}/v1/users/{user_id}/factors/{factor_id}Auth

Disable a user's MFA factor

Path parameters

app_slug*string
user_id*string
factor_id*string

Response · 204

Factor disabled.


Consumer · MFA

get/{app_slug}/v1/me/mfa/factorsAuth

List the authenticated EndUser's MFA factors

Includes factors in any state (pending enrollment + enabled). Disabled factors are not listed.

Response · 200

data*array

Example

Request

GET /{app_slug}/v1/me/mfa/factors
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "string",
      "type": "totp",
      "label": "string",
      "enabled": false,
      "created_at": "2026-01-01T00:00:00.000Z",
      "enabled_at": "2026-01-01T00:00:00.000Z"
    }
  ]
}
post/{app_slug}/v1/me/mfa/factorsAuth

Begin enrollment of a new MFA factor

Creates a pending factor and returns the type-specific enrollment payload (for TOTP: the otpauth URI, QR data URL, and shared secret). The factor stays disabled until `/factors/:id/verify` is called with two consecutive valid codes.

Path parameters

app_slug*string

Request body

type*enum (1)

Example: "totp"

labelstring

Optional human-readable label shown in the authenticator app and on the factor list (e.g. "iPhone 15").

Response · 201

factor*object
id*string
type*string

Factor type. Currently `totp` is the only supported value; future factor types (webauthn, etc.) will extend this list without a breaking change to the response shape.

Example: "totp"

label*string
enabled*boolean
created_at*string · date-time
enabled_at*string · date-time
enrollment*object

Type-specific enrollment payload. Shape depends on `factor.type` — for TOTP this is the TotpEnrollmentData shape shown.

Example

Request

POST /{app_slug}/v1/me/mfa/factors
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "type": "totp",
  "label": "string"
}

Response

{
  "factor": {
    "id": "string",
    "type": "totp",
    "label": "string",
    "enabled": false,
    "created_at": "2026-01-01T00:00:00.000Z",
    "enabled_at": "2026-01-01T00:00:00.000Z"
  },
  "enrollment": {}
}
post/{app_slug}/v1/me/mfa/factors/{id}/verifyAuth

Confirm enrollment of a pending factor

For TOTP: submit two codes from consecutive 30-second windows. On success the factor flips to enabled, recovery codes are minted (returned once), and prior recovery codes for the account are invalidated. Subsequent calls with the same `:id` fail with 400.

Path parameters

id*string

Request body

codes*array

Two codes from consecutive 30-second windows of the authenticator app. The second must come from a different period than the first.

Example: ["123456","654321"]

Response · 200

factor*object
id*string
type*string

Factor type. Currently `totp` is the only supported value; future factor types (webauthn, etc.) will extend this list without a breaking change to the response shape.

Example: "totp"

label*string
enabled*boolean
created_at*string · date-time
enabled_at*string · date-time
recovery_codes*array

Single-use recovery codes for this account. Shown once — store them now.

Example: ["a1b2-c3d4-e5f6-7890","1234-5678-9abc-def0"]

Example

Request

POST /{app_slug}/v1/me/mfa/factors/{id}/verify
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "codes": [
    "123456",
    "654321"
  ]
}

Response

{
  "factor": {
    "id": "string",
    "type": "totp",
    "label": "string",
    "enabled": false,
    "created_at": "2026-01-01T00:00:00.000Z",
    "enabled_at": "2026-01-01T00:00:00.000Z"
  },
  "recovery_codes": [
    "a1b2-c3d4-e5f6-7890",
    "1234-5678-9abc-def0"
  ]
}
delete/{app_slug}/v1/me/mfa/factors/{id}Auth

Disable an MFA factor

Soft-disable. The row is preserved (with `disabled_at` set) for audit. Has no effect on existing sessions — their `amr` claim still reflects whatever satisfied sign-in at the time. Disabling the only enabled factor removes the MFA requirement on future sign-ins.

Path parameters

id*string

Response · 204

Factor disabled.

post/{app_slug}/v1/me/mfa/recovery-codes/regenerateAuth

Regenerate the recovery-code set

Mints a fresh set of single-use recovery codes and invalidates every prior code for the account. The new codes are returned once — store them now.

Response · 200

recovery_codes*array

Example

Request

POST /{app_slug}/v1/me/mfa/recovery-codes/regenerate
Authorization: Bearer YOUR_TOKEN

Response

{
  "recovery_codes": [
    "string"
  ]
}
post/{app_slug}/v1/me/mfa/step-upAuth

Bump this session's MFA recency

Verify a fresh code (TOTP or recovery) against any enabled factor on the account, and update the session's `auth_methods` + `mfa_at`. Call `/auth/refresh` after this to mint a token whose claims reflect the new state. Customers reading `mfa_at` from access tokens to gate sensitive actions should require a `/step-up` + refresh cycle when the timestamp is older than their policy window. 5 consecutive wrong codes lock step-up on this session for 15 minutes.

Request body

code*string

TOTP code (6 digits) OR a recovery code (16 hex chars, hyphenated or not).

Example: "123456"

Response · 200

amr*array

Example: ["pwd","totp"]

mfa_at*string · date-time

Example

Request

POST /{app_slug}/v1/me/mfa/step-up
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "code": "123456"
}

Response

{
  "amr": [
    "pwd",
    "totp"
  ],
  "mfa_at": "2026-01-01T00:00:00.000Z"
}

Consumer · OAuth Sign-In

post/{app_slug}/v1/auth/oauth/exchange

Exchange a `heimdall_code` (received on the web-flow callback redirect) for Heimdall access + refresh tokens. Single-use.

Path parameters

app_slug*string

Request body

code*string

The opaque `heimdall_code` the customer received on the OAuth callback `return_to`. Single-use; expires after 60 seconds.

Response · 200

access_token*string
refresh_token*string
token_type*enum (1)
expires_in*number

Lifetime of the access token in seconds.

Example

Request

POST /{app_slug}/v1/auth/oauth/exchange
Content-Type: application/json

{
  "code": "string"
}

Response

{
  "access_token": "string",
  "refresh_token": "string",
  "token_type": "Bearer",
  "expires_in": 0
}
post/{app_slug}/v1/auth/oauth/{provider}

Sign in / sign up with a provider ID token (native flow)

Submit the provider-issued ID token from a native client (iOS ASAuthorizationController, Google Sign-In for iOS / Android). Backend verifies the token signature, issuer, audience (against this app's configured native client ids), and nonce binding; consumes the nonce server-side to defeat in-window replay; resolves or creates the Heimdall account; mints access + refresh tokens with `amr=["oauth", "<provider>"]`. Same response shape as `/auth/signin`.

Path parameters

app_slug*string
provider*enum (1)

OAuth provider id. Currently: apple.

Request body

id_token*string

Provider-issued ID token (JWT). For Apple, the value returned by `ASAuthorizationAppleIDCredential.identityToken` after UTF-8 decoding the data.

nonce*string

Raw nonce the client generated and SHA-256-hashed into the authorize request. Backend recomputes the hash and compares to the token's `nonce` claim. Must be at least 32 bytes of entropy (base64url-encoded ~43 chars).

userobject

Apple-specific first-signin payload: { name, email }. Persist for display only — the verified JWT is the source of truth.

Response · 200

access_token*string
refresh_token*string
token_type*enum (1)
expires_in*number

Lifetime of the access token in seconds.

Example

Request

POST /{app_slug}/v1/auth/oauth/{provider}
Content-Type: application/json

{
  "id_token": "string",
  "nonce": "string",
  "user": {}
}

Response

{
  "access_token": "string",
  "refresh_token": "string",
  "token_type": "Bearer",
  "expires_in": 0
}
get/{app_slug}/v1/auth/oauth/{provider}/authorize

Start the web redirect flow. 302s to the upstream provider with a server-generated state + nonce.

Path parameters

app_slug*string
provider*enum (1)

Query parameters

return_to*string

Absolute URL the customer wants the user redirected to after sign-in. Its origin MUST be in the app's `allowed_redirect_origins`. The full URL (including query + hash) is preserved; we append `?heimdall_code=<code>`.

Response · 302

Redirect to provider authorize URL.

post/{app_slug}/v1/auth/oauth/{provider}/callback

Provider callback (Apple uses `response_mode=form_post`). Verifies the ID token, mints Heimdall tokens, stashes them behind a single-use code, 303s to `<return_to>?heimdall_code=…`.

Path parameters

app_slug*string
provider*enum (1)

Request body

Response · 303

Redirect to return_to.

Example

Request

POST /{app_slug}/v1/auth/oauth/{provider}/callback
Content-Type: application/json

{}

Consumer · Tenants

get/{app_slug}/v1/tenantsAuth

List tenants in this app, paginated by created_at desc. PAK must hold heimdall.tenant.read.

Path parameters

app_slug*string

Query parameters

limitnumber
cursorstring
statusenum (3)

Response · 200 List page.

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /{app_slug}/v1/tenants
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "string",
      "display_id": "tnt_3a9f2b1c0d4e",
      "app_id": "string",
      "slug": "acme-org",
      "display_name": "Acme Org",
      "status": "active",
      "metadata": {},
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/{app_slug}/v1/tenantsAuth

Create a tenant inside this app. Returns the new tenant with a `display_id` (`tnt_<12hex>`). PAK must hold heimdall.tenant.create.

Path parameters

app_slug*string

Request body

display_name*string

Human-readable display name shown in the customer UI.

Example: "Acme Co"

slugstring

Optional URL-safe slug, unique per app (case-insensitive). When omitted, derived from display_name; collisions retried with a random suffix.

Example: "acme-co"

metadataobject

Free-form metadata; opaque to Heimdall. Customer-controlled.

Example: {"plan":"pro","region":"eu-west"}

Response · 201 Tenant created.

id*string
display_id*string

Example: "tnt_3a9f2b1c0d4e"

app_id*string
slug*string

Example: "acme-org"

display_name*string

Example: "Acme Org"

status*enum (3)
metadata*object
created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /{app_slug}/v1/tenants
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "display_name": "Acme Co",
  "slug": "acme-co",
  "metadata": {
    "plan": "pro",
    "region": "eu-west"
  }
}

Response

{
  "id": "string",
  "display_id": "tnt_3a9f2b1c0d4e",
  "app_id": "string",
  "slug": "acme-org",
  "display_name": "Acme Org",
  "status": "active",
  "metadata": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
get/{app_slug}/v1/tenants/{tenant_id}Auth

Fetch a single tenant by its UUID or `tnt_<12hex>` display id. PAK must hold heimdall.tenant.read.

Path parameters

app_slug*string
tenant_id*string

Response · 200 Tenant.

id*string
display_id*string

Example: "tnt_3a9f2b1c0d4e"

app_id*string
slug*string

Example: "acme-org"

display_name*string

Example: "Acme Org"

status*enum (3)
metadata*object
created_at*string · date-time
updated_at*string · date-time

Example

Request

GET /{app_slug}/v1/tenants/{tenant_id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "string",
  "display_id": "tnt_3a9f2b1c0d4e",
  "app_id": "string",
  "slug": "acme-org",
  "display_name": "Acme Org",
  "status": "active",
  "metadata": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
patch/{app_slug}/v1/tenants/{tenant_id}Auth

Patch a tenant. Any subset of {display_name, slug, status, metadata}. PAK must hold heimdall.tenant.update.

Path parameters

app_slug*string
tenant_id*string

Request body

display_namestring
slugstring
statusenum (3)
metadataobject

Response · 200 Updated tenant.

id*string
display_id*string

Example: "tnt_3a9f2b1c0d4e"

app_id*string
slug*string

Example: "acme-org"

display_name*string

Example: "Acme Org"

status*enum (3)
metadata*object
created_at*string · date-time
updated_at*string · date-time

Example

Request

PATCH /{app_slug}/v1/tenants/{tenant_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "display_name": "string",
  "slug": "string",
  "status": "active",
  "metadata": {}
}

Response

{
  "id": "string",
  "display_id": "tnt_3a9f2b1c0d4e",
  "app_id": "string",
  "slug": "acme-org",
  "display_name": "Acme Org",
  "status": "active",
  "metadata": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/{app_slug}/v1/tenants/{tenant_id}Auth

Delete a tenant. Cascades to tenant_membership rows; account rows are unaffected. PAK must hold heimdall.tenant.delete.

Path parameters

app_slug*string
tenant_id*string

Response · 204

Deleted.

get/{app_slug}/v1/tenants/{tenant_id}/membersAuth

List members of a tenant. Cursor-paginated by joined_at desc. PAK must hold heimdall.tenant.read.

Path parameters

app_slug*string
tenant_id*string

Query parameters

limitnumber
cursorstring

Response · 200 List page of members.

data*array
pagination*object
next_cursor*string

Opaque cursor for the next page. `null` when no more pages are available.

Example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"

has_more*boolean

True iff another page exists; if true, pass `next_cursor` on the next request.

Example: false

Example

Request

GET /{app_slug}/v1/tenants/{tenant_id}/members
Authorization: Bearer YOUR_TOKEN

Response

{
  "data": [
    {
      "id": "string",
      "tenant_id": "string",
      "account_id": "string",
      "role_id": "string",
      "role_name": "string",
      "joined_at": "2026-01-01T00:00:00.000Z",
      "invited_by": "string"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ",
    "has_more": false
  }
}
post/{app_slug}/v1/tenants/{tenant_id}/membersAuth

Add a member to a tenant. Pass `accountId` for an existing user, or `email` (+ optional `display_name`/`username`) to auto-create the account inline. PAK must hold heimdall.tenant.members.add.

Path parameters

app_slug*string
tenant_id*string

Request body

account_idstring

UUID of an existing EndUser account in this app. Use this OR `email`.

emailstring

Email address; auto-creates an account when not yet known to this app. Use this OR `accountId`.

display_namestring

Display name for the auto-created account.

usernamestring

Username for the auto-created account; derived from email when omitted. Lowercased; non-alphanumerics stripped.

role_name*string

Role name from this app's `role` table; case-insensitive lookup.

Example: "member"

Response · 201 Membership row.

membership*object
id*string
tenant_id*string
account_id*string
role_id*string
role_name*string
joined_at*string · date-time
invited_by*string
account_id*string
created_account*boolean

Example

Request

POST /{app_slug}/v1/tenants/{tenant_id}/members
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "account_id": "string",
  "email": "string",
  "display_name": "string",
  "username": "string",
  "role_name": "member"
}

Response

{
  "membership": {
    "id": "string",
    "tenant_id": "string",
    "account_id": "string",
    "role_id": "string",
    "role_name": "string",
    "joined_at": "2026-01-01T00:00:00.000Z",
    "invited_by": "string"
  },
  "account_id": "string",
  "created_account": false
}
patch/{app_slug}/v1/tenants/{tenant_id}/members/{account_id}Auth

Change a member's role. PAK must hold heimdall.tenant.members.update-role.

Path parameters

app_slug*string
tenant_id*string
account_id*string

Request body

role_name*string

New role name from the app's `role` table.

Example: "admin"

Response · 200 Updated membership.

id*string
tenant_id*string
account_id*string
role_id*string
role_name*string
joined_at*string · date-time
invited_by*string

Example

Request

PATCH /{app_slug}/v1/tenants/{tenant_id}/members/{account_id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "role_name": "admin"
}

Response

{
  "id": "string",
  "tenant_id": "string",
  "account_id": "string",
  "role_id": "string",
  "role_name": "string",
  "joined_at": "2026-01-01T00:00:00.000Z",
  "invited_by": "string"
}
delete/{app_slug}/v1/tenants/{tenant_id}/members/{account_id}Auth

Remove a member from a tenant. The underlying account row stays in the app — only the tenant_membership row is deleted. PAK must hold heimdall.tenant.members.remove.

Path parameters

app_slug*string
tenant_id*string
account_id*string

Response · 204

Removed.