Endpoints: Core & Sessions
Health
Section titled “Health”GET /health
Section titled “GET /health”Liveness and readiness probe.
Response 200
{"status": "healthy", "timestamp": "2024-01-15T10:30:00.000000"}Authentication
Section titled “Authentication”POST /auth/keys
Section titled “POST /auth/keys”Create a new service account — a named, role-scoped API key, optionally restricted to a
set of source IPs — scoped to the caller’s org. Requires admin role (or higher — owner).
Request body — application/json
{"role": "member", "name": "ci-pipeline", "ip_allowlist": ["203.0.113.0/24"]}| Field | Type | Default | Description |
|---|---|---|---|
role | string | member | viewer, member, admin, or owner |
name | string | null | null | Human-readable label |
ip_allowlist | string[] | null | null | Source IPs this key may be used from — see below. null/omitted = usable from anywhere |
Each ip_allowlist entry is a bare IP ("203.0.113.5", treated as /32) or a CIDR block
("203.0.113.0/24") — a malformed entry rejects the whole request with 422.
Response 201 — key shown once, never retrievable again
{ "key_id": "3f4a5b6c-...", "key": "xK9mZpQ...", "role": "member", "name": "ci-pipeline", "ip_allowlist": ["203.0.113.0/24"], "created_at": "2026-01-01T00:00:00Z"}| Status | Description |
|---|---|
201 | Key created — save the key value now |
401 | Missing or invalid API key |
403 | Caller does not have admin (or owner) role |
422 | ip_allowlist contains an entry that isn’t a valid IP or CIDR block |
GET /auth/keys
Section titled “GET /auth/keys”List all service accounts in the caller’s org. Raw key values and hashes are never included. Requires admin role.
Response 200
{ "keys": [ { "key_id": "3f4a5b6c-...", "role": "member", "name": "ci-pipeline", "ip_allowlist": ["203.0.113.0/24"], "created_at": "2026-01-01T00:00:00Z", "is_active": true } ]}DELETE /auth/keys/{key_id}
Section titled “DELETE /auth/keys/{key_id}”Revoke a service account. The key is deactivated immediately. Requires admin role.
| Parameter | Description |
|---|---|
key_id | Key ID returned at creation |
| Status | Description |
|---|---|
200 | Key revoked |
401 | Missing or invalid API key |
403 | Caller does not have admin role |
404 | Key ID not found |
Session
Section titled “Session”Human login via email/password, backed by a short-lived JWT (config.session.access_ttl_minutes, admin-configurable — see Configuration reference) in an httpOnly cookie — the auth path the dashboard uses. API keys remain the machine-to-machine path (CI, MCP clients); both are consumed identically by require_principal.
POST /auth/register
Section titled “POST /auth/register”Public — no authentication. Creates a user account, then logs them in (sets the session cookie). Three mutually exclusive modes based on which optional field is set:
invite_tokenset → joins the org the invite grants access to, at the invite’s role. SeeGET /invite/{token}/POST /invite/{token}/accept.org_nameset (noinvite_token) → creates a brand-new organization owned by the new user (role: owner) — the original, still-default behavior.403s if a platform admin has turned offregistration.allow_public_org_creation(see Configuration Reference — registration) — org-less registration and invite registration are unaffected either way.- Neither set → creates the account with no organization at all (
org_id/rolenull in the response and every subsequent session). Every org-scoped endpoint403s until the account creates an org (POST /orgs) or accepts an invite.
Request body
{"email": "[email protected]", "password": "hunter2", "name": "Ada Lovelace", "org_name": "Acme Corp"}| Field | Type | Description |
|---|---|---|
email | string | — |
password | string | — |
name | string | null | Display name, optional at registration — can also be set/changed later via PATCH /auth/me |
org_name | string | null | Create a new org owned by this user |
invite_token | string | null | Join an existing org via invite instead |
Response 201
{"user_id": "u1...", "org_id": "8f2a...", "role": "owner"}| Status | Description |
|---|---|
201 | Registered — session cookie set |
403 | org_name given but public organization creation is disabled |
404 | invite_token given but unknown, expired, or revoked |
409 | Email already registered |
GET /auth/registration-options
Section titled “GET /auth/registration-options”Public — no authentication. What self-service registration currently allows, so a sign-up page can render the right form instead of offering a path the API would reject.
Response 200
{"allow_public_org_creation": true}| Field | Type | Description |
|---|---|---|
allow_public_org_creation | boolean | Mirrors registration.allow_public_org_creation (see Configuration Reference — registration). false → POST /auth/register with org_name, and POST /orgs for a non-superadmin, both 403 |
Reads the live value, so a PATCH /admin/settings flip shows up on the very next call — no restart.
POST /auth/session
Section titled “POST /auth/session”Public. Verifies email/password and sets the session cookie. A user who belongs to multiple organizations lands in the one they were last scoped to — set by their last login, switch, invitation acceptance or departure, and re-checked against a live, active membership, falling back to their first active membership when it names an org they have since left or been deactivated in. Call POST /auth/session/switch to move somewhere else; that choice is what the next login honours. A user with no organization at all logs in successfully with org_id/role null, not an error.
If the account has two-factor authentication enabled, no session cookie is set here — the response instead carries mfa_token/methods (no user/org_id/role); complete login with POST /auth/session/2fa.
Request body
Response 200
{ "user": {"user_id": "u1...", "email": "[email protected]", "name": null, "created_at": "2026-01-01T00:00:00Z", "is_active": true, "two_factor_enabled": false}, "org_id": "8f2a...", "role": "owner", "is_superadmin": false}Response 200 — 2FA enabled, challenge issued instead
{"mfa_token": "eyJhbGciOi...", "methods": ["totp", "recovery_code"]}| Status | Description |
|---|---|
200 | Logged in (session cookie set), or a 2FA challenge issued (mfa_token, no cookie) |
401 | Invalid email or password, or the account is inactive |
POST /auth/session/2fa
Section titled “POST /auth/session/2fa”Public. Redeems the mfa_token from a POST /auth/session response that required a second factor. code is checked as a current TOTP code first, then as an unused recovery code (tried in that order) — either completes login. A matched recovery code is consumed and can’t be reused. On success, same response shape and session cookie as POST /auth/session.
Request body
{"mfa_token": "eyJhbGciOi...", "code": "123456"}Response 200 — same shape as POST /auth/session’s successful-login response
| Status | Description |
|---|---|
200 | Logged in — session cookie set |
401 | mfa_token expired/invalid, or code doesn’t match a current TOTP code or an unused recovery code |
DELETE /auth/session
Section titled “DELETE /auth/session”Clears the session cookie in the calling browser. Other devices signed in to the same account are unaffected — this is a log-out, not a revocation. To end every session at once, change the password (POST /auth/me/password), which revokes all of them except the caller’s own; an administrator can do the same to any account by deactivating it or resetting its password or 2FA. See Session lifetime and revocation.
Response 200
{"detail": "Logged out"}GET /auth/me
Section titled “GET /auth/me”Resolves the caller’s API key or session cookie into a user/org/role triple. Used by the dashboard to bootstrap identity on page load. Requires any authenticated principal — API-key callers have no user (there’s no human account behind a machine key).
Response 200
{ "user": {"user_id": "u1...", "email": "[email protected]", "name": null, "created_at": "2026-01-01T00:00:00Z", "is_active": true, "two_factor_enabled": false}, "org_id": "8f2a...", "role": "owner", "is_superadmin": false, "organization_locked": false, "mfa_required": false}user is null for an API-key principal. organization_locked reflects platform-admin org locking. mfa_required is true only for a human session whose org requires 2FA and who hasn’t enrolled yet — always false for an API-key principal.
| Status | Description |
|---|---|
200 | Identity resolved |
401 | No valid API key or session |
PATCH /auth/me
Section titled “PATCH /auth/me”Update the caller’s own profile — currently just name. Requires a human session; 400 for
an API-key principal (no backing user document).
Request body
{"name": "Ada Lovelace"}Response 200 — the updated UserInfo, same shape as GET /auth/me’s user field.
| Status | Description |
|---|---|
200 | Profile updated |
400 | API-key principal |
401 | No session |
POST /auth/me/password
Section titled “POST /auth/me/password”Change the caller’s own password. Requires the current password — unlike an admin-initiated
reset below, the caller proves control of the account itself. Not applicable to SSO-backed
accounts (400).
Signs the account out everywhere else: every other browser and device holding a session for
it is rejected from its next request onward. The caller stays signed in — the 200 response
carries a replacement session cookie, so no re-login is needed on the browser that made the
change. See Session lifetime and revocation.
Request body
{"current_password": "old-password", "new_password": "new-password-at-least-8-chars"}| Status | Description |
|---|---|
200 | Password changed |
400 | API-key principal, or the account authenticates via SSO |
401 | No session, or current_password is wrong |
422 | new_password shorter than 8 characters |
POST /auth/me/2fa/enroll
Section titled “POST /auth/me/2fa/enroll”Begin TOTP enrollment. Generates a new secret and returns a QR-scannable provisioning URI plus
the same secret for manual entry. 2FA is not active yet — call POST /auth/me/2fa/confirm
with a code from the authenticator app to finish. Calling this again before confirming replaces
the pending secret. Requires a human session on a local account (400 for an API-key principal
or an SSO-backed account).
Response 200
{"secret_provisioning_uri": "otpauth://totp/OpenTremor:person%40acme.com?secret=JBSW...&issuer=OpenTremor", "manual_entry_code": "JBSWY3DPEHPK3PXP"}| Status | Description |
|---|---|
200 | Enrollment started |
400 | API-key principal, or the account authenticates via SSO |
401 | No session |
POST /auth/me/2fa/confirm
Section titled “POST /auth/me/2fa/confirm”Verify code against the pending secret from POST /auth/me/2fa/enroll. On success, enables
2FA and returns 10 one-time recovery codes — shown once, here, never retrievable again. Store
them somewhere safe; each is usable in place of a TOTP code at POST /auth/session/2fa if the
authenticator device is lost.
Request body
{"code": "123456"}Response 200
{"recovery_codes": ["a1b2c3d4e5", "f6a7b8c9d0", "..."]}| Status | Description |
|---|---|
200 | 2FA enabled — recovery codes shown once |
400 | No pending enrollment, API-key principal, or the account authenticates via SSO |
401 | code doesn’t match the pending secret |
POST /auth/me/2fa/disable
Section titled “POST /auth/me/2fa/disable”Turn off two-factor authentication. Requires the current password, same as POST /auth/me/password — turning off a security control needs the same re-proof of account control
as changing one.
Request body
{"current_password": "hunter2"}| Status | Description |
|---|---|
200 | 2FA disabled |
400 | API-key principal, or the account authenticates via SSO |
401 | current_password is incorrect |
GET /auth/me/organizations
Section titled “GET /auth/me/organizations”Every organization the caller has a membership in, for the dashboard’s org switcher. Requires a human session — API-key principals are scoped to a single org and have no memberships to list.
Response 200
[ {"org_id": "8f2a...", "name": "Acme Corp", "role": "owner"}, {"org_id": "3f4a...", "name": "Other Org", "role": "member"}]| Status | Description |
|---|---|
200 | Membership list |
401 | No session, or an API-key principal |
GET /auth/me/invitations
Section titled “GET /auth/me/invitations”Organization invitations addressed to this account’s email — see Open vs addressed. Already-redeemed, revoked and expired ones are filtered out, so every row is something the caller can still act on. Requires a human session.
This is what makes joining a second organization discoverable rather than dependent on an invite URL reaching someone: this deployment sends no email, so an open invite that never physically arrives may as well not exist.
Response 200
[ { "invite_id": "3f4a5b6c-...", "org_name": "Acme Corp", "role": "member", "created_at": "2026-01-01T00:00:00Z", "expires_at": "2026-01-08T00:00:00Z" }]Open invitations are never listed — they have no intended recipient to list them for. Note the
absence of token: the caller has already proved who they are by being signed in, so nothing
bearer-shaped needs to change hands.
| Status | Description |
|---|---|
200 | Pending invitation list (possibly empty) |
401 | No session, or an API-key principal |
POST /auth/me/invitations/{invite_id}/accept
Section titled “POST /auth/me/invitations/{invite_id}/accept”Redeem an invitation from that list, no token involved. Requires a human session.
Response 200
{"org_id": "8f2a...", "role": "member"}Also re-scopes the session cookie onto the org just joined, the way POST /auth/session/switch
does — otherwise an org-less account would join an organization and still be left belonging to
none.
| Status | Description |
|---|---|
200 | Joined; session re-scoped to the new org |
401 | No session, or an API-key principal |
404 | Not on this account’s invitation list — unknown, expired, revoked, or addressed to someone else, deliberately indistinguishable |
POST /auth/session/switch
Section titled “POST /auth/session/switch”Re-scopes the session cookie to a different org the caller is a member of. Requires a human session.
Request body
{"org_id": "3f4a..."}Response 200 — same shape as POST /auth/session
| Status | Description |
|---|---|
200 | Switched — session cookie re-issued for the new org |
401 | No session, or an API-key principal |
403 | Caller is not a member of org_id |