Private betaZynth Auth is currently in private beta testing.New organizations are created by invitation only, and no plan can be purchased yet.Request early access

API reference

Base path: /api/v1, served from the application originhttps://auth.zynthmedia.com on the managed service, or your own domain when you self-host. (The public website at zynthmedia.com serves no API; see Where things live.) All request and response bodies are JSON. Authenticated endpoints require Authorization: Bearer <access_token>.

This page is hand-maintained and is not yet exhaustive. Auto-generating it from the backend OpenAPI spec — so it cannot drift — is a planned refinement. Every endpoint family is now at least summarized here, but the guides remain the operational walkthroughs, and each section below links to its own. If an endpoint is missing here, it may still exist — check the guide for that capability before concluding otherwise.

Conventions, errors & limits

  • Errors are generic by design (no account/organization/tenant enumeration): most failures return {"detail": "…"} with a stable HTTP status. Authentication failures are a uniform 401 Invalid credentials.
  • Rate limiting. Repeated failed sign-ins are throttled per source IP and per account: once the threshold is hit, POST /auth/login returns 429 Too Many Requests. A successful login clears the account counter. (Magic-link issuance is separately rate-capped.) Registration is rate-capped too: POST /auth/signup and POST /auth/signup/customer share one per-IP budget and return 429 with the standard rate headers past it — retry after the window shown in Retry-After. Also throttled, all fail-closed (a budget that cannot be computed refuses rather than waves through): invite redemption/invites/preview and /invites/accept deliberately share one per-IP budget, so preview is not a free guessing oracle; domain join/auth/signup/domain-check and /auth/signup/domain, likewise shared; and the agent-bootstrap family/onboard/bootstrap (per-IP, per-network and a global ceiling), /onboard/poll and /onboard/pow (one shared budget), and the human /onboard/verify/* legs; and the early-access waitlist doorPOST /early-access (per-IP and a global ceiling). The agent surfaces are throttled the same way: the token mint and the MCP surface each carry per-agent, per-organization and per-source budgets — see those sections for the numbers a client should expect to live inside. Separately, API-key requests are metered against the plan's monthly quota: past api_calls_monthly the key's requests get 429 carrying RateLimit-Policy: monthly-quota — "you are over plan", not "slow down".
  • Rate-limit headers (v0.21.0). Every throttled 429 — sign-in, the OAuth /authorize and /token endpoints, release downloads, SDK telemetry — carries a computed Retry-After (seconds until one request will be accepted; previously a fixed 60) plus the draft IETF trio RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset, describing the most constrained limit that applies to your request. Well-behaved clients should honour Retry-After and may watch RateLimit-Remaining to back off before hitting the wall. Two deliberate exceptions: enumeration-safe endpoints (e.g. POST /auth/forgot-password) return 202 with no rate headers — exposing the cap would reveal whether an address is being throttled — and in the rare degraded state where limits cannot be computed, a 429 carries only a fixed short Retry-After.
  • Request-body limit. Request bodies are capped at the edge (1 MB); a larger body is refused with 413 before it reaches the API. A malformed or excessively-nested JSON body returns a clean 400.
  • SCIM endpoints use application/scim+json and return RFC 7644 error documents (not the {"detail": …} shape) — see Directory provisioning.

Authorization

Beyond authentication, privileged endpoints are gated by permissions (RBAC + ABAC). A permission is domain:action (e.g. compliance:admin, command:read, isms:write, hipaa:phi:log), with a read/write split so access can follow least privilege. Permissions come from roles:

  • Built-in roles: owner (all permissions), admin, security_operator, compliance_officer, auditor (read-only: this role grants no permission that reaches a write), member (the baseline every workforce member has — self-service + PHI logging), and customer (the CIAM end-user baseline — self-service identity only; it is never grantable by invitation). GET /api/v1/authz/catalog returns the live set.
  • Read permissions cannot reach a write. Every permission is flagged read-only or not, and no endpoint that changes state is reachable by read-only permissions alone — enforced against the running application, not by convention. This is why acting on your own record — granting or withdrawing consent, opening or cancelling a data-subject request, acknowledging a policy, reporting an SDK signal, revoking a delegation you granted — needs identity:self:write and not identity:read. Both are in the member and customer baselines, so every person holds them; an API key or agent delegation holds only what it was given by name, so a key that reports SDK telemetry must carry identity:self:write.
  • The tenant owner always has every permission. A regular member starts with only the member baseline until granted a role.

A request that lacks the required permission returns 403 (vs 401 for missing/invalid authentication). Self-service endpoints (your own data, consent, DSRs about yourself) need no special permission — just a valid token. Endpoints below note the permission they require.

Role management (assigning roles, defining custom roles, ABAC policies) is available through the authorization admin API and the Access Control console (/roles in the app — People / Roles / Policies), which gates its controls off GET /me/permissions.

GET /api/v1/me/permissions

The caller's own effective permissions — use it to hide or disable UI controls the user lacks. Returns: { is_owner, permissions: ["compliance:read", …] }. Needs only a valid token.

API keys

Programmatic credentials for integrations (v0.21.0). A key looks like zyn_live_<secret>_<check> (zyn_test_… outside production — a test key can never authenticate against production, and vice versa). Use it exactly like an access token: Authorization: Bearer zyn_live_… on any endpoint. What a key may do is defined by its scopes, drawn from the same permission vocabulary as roles (e.g. compliance:read) — a key with no scopes authenticates but can call nothing.

Managed under /api/v1/tenants/api-keys (requires tenant:manage):

  • POST — mint a key: label, scopes, optional expires_in_days (default 365; null = explicitly non-expiring). The plaintext appears once, in this response. Store it; it is never retrievable again (we keep only a hash). Two bounds, both 403: the requested scopes must be a subset of the caller's own permissions (owners hold everything), and an AI agent principal may not mint a key at all — a key carries none of an agent's governance, so minting one would step outside it. Both apply to rotate.
  • GET — list keys: display handle (zyn_live_abcd…wxyz), scopes, expiry, last_used_at (coarse), revocation state. Never secrets.
  • POST /{id}/rotate — zero-downtime rotation: issues a successor with the same scopes; the old key keeps working until you revoke it. Migrate, then revoke.
  • POST /{id}/revoke — with a reason. Takes effect on the key's next request.
  • GET /usage — API-key request volume for a window, and the ceiling it is measured against. Optional period query param (defaults to the current one). Returns {period, total, quota, rows}quota is null on an unlimited plan, and rows breaks the total down per key. This is the counter behind the monthly-quota 429.

A rejected key — malformed, wrong environment, unknown, expired, or revoked — always gets the same generic 401. Treat a 401 on a previously working key as "rotate now".

POST /api/v1/auth/signup

Create a user, an organization (tenant), and an owner membership. Returns a token pair.

Body

FieldTypeNotes
emailstringValid email; must be unique.
passwordstringMust satisfy the password policy.
organization_namestring1–255 chars; the slug is derived from it.

Responses201 token pair · 400 weak password · 409 email already registered or organization name already taken.

POST /api/v1/auth/signup/customer

Self-register a customer (CIAM end-user) into an existing organization. Available only when the organization has enabled customer signup (a tenant setting — see tenant settings); otherwise — and for unknown organizations alike — it returns a generic 403 (no organization enumeration). The same password policy applies as for workforce users.

Body: email, password, tenant_slug, optional use_cookie.

Responses201 token pair (a session starts immediately; a verification email is dispatched) · 400 weak password · 403 signup unavailable · 409 email already registered.

Customer accounts hold minimal permissions (identity:read plus identity:self:write, which is what carries their own consent and data-subject rights), get a 30-day refresh-token lifetime (workforce: 7 days), and cannot be granted admin roles.

POST /api/v1/auth/verify-email

Public. Confirms an email address from the single-use token in the verification email. Body: token. 204 verified · 400 invalid/expired/already-used token. Verification is currently informational (email_verified on GET /me) — login is not blocked on it.

POST /api/v1/auth/resend-verification

Public. Always returns 202 regardless of whether the email is registered (no account enumeration). Re-sends the verification email for registered, unverified addresses. Body: email.

POST /api/v1/auth/login

Authenticate a user within a specific organization.

Body: email, password, tenant_slug (the organization slug), optional use_cookie.

Responses401 invalid credentials (generic — the response does not reveal whether the email, password, or organization was wrong) · 403 when the organization requires enterprise SSO — checked after the password verifies, so only a real member ever learns the policy; sign in through the identity provider instead. (An owner membership passes as audited break-glass rather than being locked out.) On success, one of two 200 shapes:

  • No MFA: a token pair {access_token, refresh_token, token_type}.
  • MFA enabled: {mfa_required: true, mfa_token}no session yet. Complete the login at POST /api/v1/mfa/challenge with a code. See MFA.

Passkeys (WebAuthn)

Phishing-resistant sign-in. A user-verified passkey carries two factors in one ceremony (possession + biometric/PIN), so passkey sign-ins do not additionally prompt for a TOTP code. Users may register multiple passkeys; ceremonies use single-use, short-lived challenges, and all failures are generic (no credential or account enumeration).

POST /api/v1/webauthn/credentials/options · POST /api/v1/webauthn/credentials

Authenticated enrollment: fetch creation options (pass them to navigator.credentials.create()), then submit the browser's response with an optional name. 201 returns the credential summary · 400 invalid/expired/replayed ceremony.

GET /api/v1/webauthn/credentials · POST /api/v1/webauthn/credentials/{id}/remove

List the caller's passkeys; removing one re-proves the account password in the body when one exists (a stolen session alone cannot strip factors). A passwordless account (e.g. a passkey-first owner) sends no password: removal is allowed only while another passkey remains — the last way to sign in can never be removed. 204 removed · 400 wrong password / last credential · 404 unknown credential.

POST /api/v1/early-access — the private-beta waitlist

Public, no principal: the form on the marketing site's early-access page. Body: email, full_name, role, organization; app_name, app_url, app_description, stage (idea|building|production), platforms (any of web|mobile|api|internal); integrations (at least one of agents_mcp|customer_oidc|workforce_sso|api_keys), test_goals; expected_users (lt_100|100_1k|1k_10k|gt_10k), expected_agents (0|1_5|6_50|gt_50), timeline (this_month|next_quarter|later); consent and acknowledgement (both must be true422 otherwise, and nothing is stored) with their consent_text_version / acknowledgement_text_version; optional intended_use, source. Answers 202 with {"status": "accepted"} whether the address is new or already on the list — a repeat submission refreshes the consent record on the same row, and the answer is the same bytes either way, so the list is not enumerable. Refusals: 429 past the fail-closed per-IP budget or the global ceiling (standard rate headers); 400 for a disposable email domain or a domain with no mail exchanger (the same screening as agent onboarding, under its own switch). What is stored, why, and how it is erased: Website analytics & privacy and the privacy policy; the operator's read and erasure path is in the operator plane.

POST /api/v1/onboard/{bootstrap,poll,pow} — agent-driven onboarding

Deployment-gated by ONBOARD_BOOTSTRAP_ENABLED (404 on every method when off).

POST /onboard/bootstrap201. Public and aggressively rate-limited (fail-closed per-IP/per-network/global budgets, disposable-email screening, optional proof-of-work). While public signup is closed (PUBLIC_SIGNUP_ENABLED=false, the private-beta default) the body must also carry invite_code — a beta invite the owner issued to the same human_email; without a valid one the door answers 403 with a single message whatever the cause (missing, unknown, expired, revoked, already used, issued to another address, or issued for the provisioning door). The invite is consumed when your organization is created at /onboard/verify/confirm, not at this call — an invite revoked in between refuses the confirmation with 409 and creates nothing. Body: organization_name, human_email, optional client_info (name, platform, model), optional manifest (≤ 64 KiB — parked for the same human review), and optional pow (challenge, nonce) when proof-of-work is required. Returns the device-flow handshake: device_code (shown once, ever), short user_code, verification_uri, expires_in, interval, and pow_required — check it before your first call, since it tells you whether to solve a challenge first.

POST /onboard/poll — body device_code. Answers authorization_pending / denied / 410 expired / 429 slow_down (poll at the stated interval), exactly once approved with the agent's own credential, and consumed on every later poll once that one release has happened. A polling loop must terminate on consumed, not retry it.

POST /onboard/pow mints a single-use proof-of-work challenge.

The human legs (/api/v1/onboard/verify/*) run on the verification page: code entry, single-use email confirm, passkey-first credential, approve/deny. Note the two different clocks — the emailed link is valid far longer than the bootstrap attempt itself, which expires in about an hour.

Until approval the tenant is quarantined: the whole /api/v1/members family (including invitations, roles and member domains), OAuth clients, webhooks and API keys answer 403 with an actionable reason. See the agent onboarding guide.

POST /api/v1/manifest/{plan,request-approval,apply} — IAM-as-code

manifest:plan / manifest:apply permissions. All three take the same envelope body: {"manifest": { …the manifest_version:1 document… }} — posting the document at the top level is a 422, as is any document that fails schema validation.

POST /manifest/plan{manifest_hash, appliable, steps[], summary}, a read-only diff and the drift answer on re-submission. Each step is {index, kind, target, would, detail} — the verdict field is would, one of create / noop / conflict / invalid.

POST /manifest/request-approval{manifest_hash, approval_id, approval_status, created, appliable}. Parks the apply ask bound to the manifest's resolved consequence — its content hash, the resolved plan's hash, and the permission catalogue's version — and the approval row carries that resolved plan so the human decides on what it would do. Editing the manifest invalidates the ask, and so does live state moving such that the same document resolves to a different plan.

POST /manifest/apply{manifest_hash, approval_id, approved_by, resumed, applied, skipped}. Re-plans first and refuses 409 with its blockers before spending the approval on any conflict; 403 — never spending the approval — when no decision covers this consequence, which the message distinguishes: no approval at all, one whose resolved plan has since changed (plan-changed), or one whose approving human does not hold a permission the manifest would mint into a role (approver-authority); 502 if a step fails mid-run (re-applying the same manifest resumes past everything already done). Every step is audited under the manifest hash as correlation_id. An unchanged re-apply is a no-op that confirms the original spend (resumed: true, applied: 0).

Agents drive the same choreography over MCP (plan_manifest / request_manifest_approval / apply_manifest, with dry_run: true supported on every write tool).

POST /api/v1/webauthn/login/options · POST /api/v1/webauthn/login/verify

Public, email-less sign-in (discoverable credentials). Fetch request options (pass to navigator.credentials.get()), then submit the assertion with tenant_slug and optional use_cookie. 200 token pair · 401 generic sign-in failure (unknown credential, bad assertion, unknown organization, replayed challenge, or a suspected cloned authenticator — the response never says which).

Passwordless email sign-in. Links are single-use, expire after ~15 minutes, and requesting a new link replaces the outstanding one. Magic-link sign-in does not bypass an enrolled TOTP factor.

POST /api/v1/auth/magic-link

Public. Body: email, tenant_slug. Always returns 202 with the same body whether or not the address is registered (no enumeration). Issuance is rate-capped server-side; over the cap the response is unchanged and no email is sent.

POST /api/v1/auth/magic-link/verify

Public. Body: token (from the emailed link), optional use_cookie. Returns exactly what password login returns: 200 token pair, or the MFA challenge (mfa_required: true, mfa_token) · 401 generic — invalid, expired, already used, or superseded by a newer link.

Social login (Google · GitHub)

"Continue with Google/GitHub" (authorization-code + PKCE, server-orchestrated). New users are JIT-provisioned as customers into the target organization iff it has customer self-signup enabled; an existing account with a matching provider-verified email is linked automatically (an unverified provider email is rejected — no account takeover). Social sign-in does not bypass an enrolled TOTP factor. All failures collapse to one generic redirect (no enumeration).

GET /api/v1/auth/social/providers

Public, no body. Returns {providers: ["google", …]} — the providers this deployment actually has configured, so a sign-in page renders only buttons that work.

POST /api/v1/auth/social/{provider}/start

Public. providergoogle | github. Body: tenant_slug. Returns 200 {authorization_url} — navigate the browser there · 404 provider not configured.

GET /api/v1/auth/social/{provider}/callback

The provider's redirect target (browser navigation; not called directly). On success it 302-redirects to the app with a one-time completion code (60-s TTL); on any failure it redirects with a generic error flag.

POST /api/v1/auth/social/complete

Redeem the completion code. Body: code, optional use_cookie. Returns exactly what password login returns: 200 token pair, or the MFA challenge (mfa_required: true, mfa_token) when the account has TOTP enrolled · 401 invalid/expired/replayed code.

GET /api/v1/auth/social/identities · POST /api/v1/auth/social/{provider}/link/start · POST /api/v1/auth/social/{provider}/unlink

Authenticated identity management: list linked provider accounts; link a new provider (same browser flow, bound to the session user); unlink one. Unlink requires the account password when one exists, and refuses to remove a passwordless account's last sign-in method (409 — set a password first). 204 unlinked · 404 not linked.

Members, invitations & domain join

How people get into an organization and what they may do once in. Three surfaces: the authenticated admin lifecycle, the anonymous redemption trio that turns an invitation into an account, and auto-join domains for organizations that would rather not invite one address at a time. Walkthrough: Team members.

Admin — /api/v1/members (members:read to list, members:manage to change). The whole family is refused with 403 while the tenant sits in bootstrap quarantine.

  • GET /api/v1/members — the roster. GET /api/v1/members/invites — invitations not yet redeemed.
  • POST /api/v1/members/invites — invite by email. Body: email, role (default member; a system-role code). 201 {invite_id, email, role, expires_at, reissued} — re-inviting a still-pending address re-issues the same invitation rather than stacking a second (reissued: true). 422 unknown role · 409 already a member · 403 when the plan's members seat cap is reached — the message names the limit, and pending invitations count against the total (caps).
  • DELETE /api/v1/members/invites/{invite_id} — revoke a pending invitation. 204 · 404.
  • PATCH /api/v1/members/{membership_id} — change a member's role. Body: role. 422 unknown role · 404 · and a 409 family that is the point of the endpoint: owner memberships change only via ownership transfer; a membership managed by your identity provider (SCIM) is edited in the directory, not here; and a change that would breach a separation-of-duties rule is blocked naming the rule.
  • DELETE /api/v1/members/{membership_id} — remove a member. 204, and immediate: the removal and the user's session revocation (CAE) commit in one transaction, so membership dying and access dying are the same event. 409 for the sole owner (transfer ownership first) and for a SCIM-managed membership · 404.

Redemption — /api/v1/invites (public; the token travels in the body, never a URL, so it never reaches an access log). Preview and accept share one fail-closed per-IP budget → 429.

  • POST /api/v1/invites/previewBody: token. What the acceptance page shows.
  • POST /api/v1/invites/accept — redeem as a new user. Body: token, password, optional use_cookie. Returns a token pair — the invitee lands signed in. 400 weak password · 409 an account with that email already exists (sign in, then use accept-existing).
  • POST /api/v1/invites/accept-existingauthenticated; joins the invited organization with the account you are already signed in as. Any organization's session works, but the session's email must be the invited email — otherwise 403. 200 {tenant_slug, role} · 409 already a member.
  • Across all three: 410 Gone for an expired invitation (ask for a new one — deliberately distinct from 404, which means invalid, revoked, or already redeemed).

Auto-join domains. Admin lifecycle under /api/v1/members/domains (members:read / members:manage, quarantine-gated like the rest):

  • GET — claimed domains. POST — claim one. Body: domain. 201 returns the TXT record to publish. 422 for a malformed domain or a public email provider (gmail.com and friends can never be claimed) · 409 another organization already holds it verified.
  • POST /{domain_id}/verify — run the DNS check; returns {domain, verified}. DELETE /{domain_id} — release the claim (204).

And the anonymous join pair, which shares its own fail-closed per-IP budget (429):

  • POST /api/v1/auth/signup/domain-checkBody: email. What the signup page may offer.
  • POST /api/v1/auth/signup/domainBody: email, password, optional use_cookie. Creates the account plus a baseline membership and returns a token pair. 400 weak password · 409 the email already has an account, or no verified domain matches it.

Organization settings

Organization settings live in a typed registry on the platform: each setting declares its type, default, bounds, risk class, and who may edit it. The write path refuses anything the registry doesn't sanction and names the violated rule in the error, every change is recorded in an append-only history in the same transaction as the change, and security-classed changes raise an immediate Command Center detection. A setting added to the platform appears in the API and the console with no release on either side.

All endpoints require tenant:manage (owner/admin), and serve only the tenant-editable settings. Settings classed security are structurally never tenant-editable — those belong to the operator surface.

  • GET /api/v1/tenants/config/schema — every tenant-editable setting with its key, summary, type (boolean | integer | string), default, current value, overridden, risk, and any bounds / allowed values. This is what the console renders.
  • PATCH /api/v1/tenants/config/{key} — set one value. Body: value. 404 for a key the registry doesn't know (unknown means refuse, never create), 400 when the value violates its declared type/bounds/allowed set — with the rule stated.
  • GET /api/v1/tenants/config/{key}/history — who changed what, old → new, when; newest first.

Currently tenant-editable:

KeyMeaning
auth.magic_link_enabledAllow members to sign in with emailed magic links. Turning it off also stops already-issued links from redeeming.
auth.social_login_enabledAllow "Continue with Google / GitHub" for this organization.
auth.ciam_signup_enabledAllow customers to self-register (customer self-signup). Default false.
members.domain_join_enabledAllow anyone with an email address on a verified domain to join this organization without an invitation (team members). Default false.
autonomy.level_contain_source · autonomy.level_contain_non_human · autonomy.level_raise_assurance · autonomy.level_revoke_access · autonomy.level_disable_principalThe oversight level for each class of autonomous response, default manual (nothing acts). Each key's allowed set is its class ceiling: contain_source and raise_assurance accept manual|recommend|graduated|full, contain_non_human (stopping an AI agent) and revoke_access stop at graduated, disable_principal at recommend. GET …/config/schema returns the exact set per key.
autonomy.disabled_untilThe autonomous-response emergency stop, as an expiry instant (empty = enabled). Prefer POST /api/v1/autonomy/kill-switch, which offers the 1/8/24h options and enforces the reason requirement.

Legacy route. GET / PATCH /api/v1/tenants/settings still serves ciam_signup_enabled (same permission, same behaviour, same audit trail — it reads and writes through the registry underneath). New integrations should use the config routes above; the legacy pair is kept for compatibility and is not extended.

POST /api/v1/auth/refresh

Exchange a valid refresh token for a new pair. Re-validates tenant membership first, and rotates the refresh token (single-use). Reusing an already-rotated token revokes the whole session — see rotation & reuse detection.

Body: refresh_token — or omit it to use the cookie transport (the httpOnly cookie set at login is used instead, and the response rotates the cookie).

Responses200 token pair (a new refresh token; discard the old) · 401 invalid/expired/already-used refresh token, revoked session, or membership no longer active (in cookie mode the dead cookie is also cleared).

POST /api/v1/auth/sign-out-all

Authenticated. Signs the user out of every device at once (CAE, ADR-0034) — bumps a server-side revocation epoch so every access + refresh token issued before now is rejected on its next use, and clears the current session. 204 on success. Use it after a lost device or a suspected compromise.

Sender-constrained tokens (DPoP)

API/SDK clients can opt into RFC 9449 DPoP so a stolen token is useless without the client's key. Send a DPoP proof (a JWT signed by your key, typ: dpop+jwt, embedding your public JWK) in the DPoP header on any sign-in request; the issued tokens bind to your key (token_type: DPoP). Thereafter send Authorization: DPoP <token> plus a fresh DPoP proof (htm, htu, iat, single-use jti, and ath = the access token's SHA-256) on every request. On refresh, send a proof under the same key (no ath at the token endpoint). A bound token used without a proof — or as a plain Bearer token — is rejected (401). Clients that send no proof get ordinary bearer tokens (unchanged).

AI agents get the same protection. Both agent token mints — POST /api/v1/agents/token and the federated exchange — accept the same optional DPoP header, and the minted actor=agent token then binds to your ephemeral key: the same per-request enforcement, the same downgrade guard, one confirmation mechanism for every principal. Combined with federation this is the full ADR-0079 posture — no secret at rest to steal, and the short-lived token that does exist is useless without the runtime's key.

POST /api/v1/auth/logout

Requires a valid access token. Revokes the session server-side and returns 204. Every token in the session — including the still-unexpired access token — stops working immediately.

POST /api/v1/auth/change-password

Requires a valid access token. Verifies the current password, enforces the password policy on the new one, and rejects reuse of the current password. On success all tokens issued before the change are invalidated (every other device is signed out); the response returns a fresh token pair so the calling device stays signed in.

Body: current_password, new_password, optional use_cookie.

Responses200 new token pair · 400 new password is weak, identical to the current one, or the current password is incorrect · 401 not authenticated.

POST /api/v1/auth/forgot-password

Public. Begins a password reset. Always returns 202 regardless of whether the email is registered — it never reveals account existence. If the email maps to an active user, a single-use, short-lived reset link is emailed.

Body: email.

Deployment-gated by PASSWORD_RESET_ENABLED, which ships true. While it is false (Zynth's own deployment sets it so during the private beta) this endpoint answers 403 with a reason naming who to contact instead — identically for a registered and an unknown address, so closing it introduces no enumeration oracle. POST /auth/reset-password is deliberately not gated: a link already in someone's mailbox was issued legitimately and still works. Sign-in itself is unaffected, including the magic link at POST /api/v1/auth/magic-link, which remains the way back in for a forgotten password.

POST /api/v1/auth/reset-password

Public. Sets a new password from a single-use reset token (from the emailed link). Enforces the password policy and invalidates all of the user's prior tokens.

Body: token, new_password.

Responses204 password changed (log in again) · 400 invalid/expired/already-used token, or the new password is weak.

Multi-factor authentication (TOTP)

Time-based one-time-password (TOTP, RFC 6238) MFA — any authenticator app (Google Authenticator, 1Password, Authy…). Enrollment endpoints require a valid access token; POST /mfa/challenge does not (it uses the mfa_token from the login step).

MFA requires the server operator to provision an MFA_ENCRYPTION_KEY; without it, setup returns 503.

POST /api/v1/mfa/setup

Begin enrollment. Returns {secret, provisioning_uri, recovery_codes} — show the QR from provisioning_uri, and have the user save the 8 recovery codes now (shown only once). MFA is not yet active. 503 if MFA is unavailable · 409 if MFA is already enabled.

POST /api/v1/mfa/setup/verify

Activate MFA by confirming the first code. Body: code. 200 returns the MFA status · 400 wrong code (not activated).

POST /api/v1/mfa/challenge

Complete a login that returned mfa_required. Body: mfa_token (from login), code (a TOTP or a single-use recovery code), optional use_cookie. 200 returns the token pair · 400 bad code · 401 invalid/expired challenge.

The challenge token is single-use: it completes exactly one login, allows at most 5 code attempts, and expires after ~5 minutes — after any of those, log in again to get a fresh challenge. (A replayed challenge returns 401 even though its signature is valid.)

POST /api/v1/mfa/disable

Turn MFA off. Body: code (a current TOTP or recovery code — a stolen access token alone can't disable MFA). 204 on success · 400 bad code · 409 MFA not enabled.

POST /api/v1/mfa/recovery-codes/regenerate

Replace all recovery codes with a fresh set. Body: code (current TOTP or recovery code). 200 returns {recovery_codes} (the old set stops working) · 400 bad code.

GET /api/v1/mfa/status

Returns {mfa_enabled, mfa_verified, mfa_verified_at, recovery_codes_remaining}.

Data-protection & privacy rights (GDPR / APAC)

Data-subject rights under GDPR (Art 15/16/17/20) and APAC (SG PDPA / PH DPA). Subject endpoints require only the caller's own access token; admin endpoints require the compliance:admin permission (or compliance:read for read-only). Permissions come from RBAC roles — the tenant owner holds all of them, and the compliance_officer role grants the compliance set. See Authorization.

These operations are also available in the Zynth Auth app under Privacy & Data (/privacy): a self-service tab (consent, export-my-data, file/cancel requests) for every user, and admin tabs (the DSR queue and retention policies) for holders of the compliance permissions.

GET /api/v1/gdpr/my-data

Immediate right of access (Art 15) / portability (Art 20): returns everything your organization holds on the authenticated caller — identity, the membership in the requesting organization, request history, and a pseudonymous activity log. The export is bounded to the organization it is requested in: if the same person also uses other organizations on the platform, that is never disclosed here.

POST /api/v1/gdpr/requests

File a formal request about your own data. Body: request_type (access | export | erasure | rectification), optional description. 201 with a pending request; the statutory clock is 30 days.

GET /api/v1/gdpr/requests · POST /api/v1/gdpr/requests/{id}/cancel

List your own requests; cancel one that is still open (409 if already resolved).

Admin (compliance:read / compliance:admin)

  • GET /api/v1/gdpr/admin/requests — all requests in the tenant (filter by status/type).
  • POST /api/v1/gdpr/admin/requests/{id}/verify — confirm the requester's identity.
  • POST /api/v1/gdpr/admin/requests/{id}/rejectBody: reason.
  • POST /api/v1/gdpr/admin/requests/{id}/process — fulfill a verified request: access/export returns the subject's data; erasure scrubs the subject's personal data (refused with 409 if the subject is the tenant's sole owner — reassign ownership first). The tamper-evident audit log is retained (GDPR Art 17(3)) and never altered.

Per-purpose consent, granted and withdrawn by the subject. A consent_type is a lowercase purpose slug (^[a-z0-9_-]{1,64}$, e.g. marketing_email). Every change is recorded in an append-only history and sealed into the audit log.

  • GET /api/v1/consent — the caller's current consent state (one entry per purpose).
  • POST /api/v1/consent/{consent_type}/grant — grant consent (idempotent).
  • POST /api/v1/consent/{consent_type}/withdraw — withdraw consent (idempotent).
  • GET /api/v1/consent/history — the caller's consent change history (newest first).
  • GET /api/v1/consent/admin · GET /api/v1/consent/admin/history — tenant-wide review (tenant owner), filterable by user_id / consent_type.

Retention policies

Requires compliance:admin (read with compliance:read). Declares how long each governed data class is kept (GDPR Art 5(1)(e) storage limitation). data_class is one of events · dsr · consent_events (the tamper-evident audit log is exempt and never purged).

  • GET /api/v1/retention — the tenant's retention policies.
  • PUT /api/v1/retention/{data_class} — declare/update one. Body: retention_days (≥1), enabled, archive_before_delete, legal_hold_exempt, notes.
  • GET /api/v1/retention/preview — how many records are currently past each policy's retention.

ISO 27001 ISMS

Governance records that evidence a running Information Security Management System. Managing them is gated by isms:write (read with isms:read); policies can be read and acknowledged by any member. Every change is audited and mapped to ISO 27001 controls, feeding the ISO 27001 readiness report.

Risk register (ISO 27001 6.1.2 / 6.1.3) — inherent_score is computed as likelihood × impact (each 1–5):

  • GET /api/v1/isms/risks (filter by status) · POST /api/v1/isms/risksBody: title, likelihood (1–5), impact (1–5), optional description/category/owner_id.
  • GET /api/v1/isms/risks/{id} · PATCH /api/v1/isms/risks/{id} (partial update; statusidentified·assessed·treating·accepted·closed).
  • POST /api/v1/isms/risks/{id}/treatmentsBody: option (mitigate·accept·avoid·transfer), description. Recording a treatment moves an untreated risk to treating.

Security policies (ISO 27001 A.5.1):

  • GET /api/v1/isms/policies (any member) · POST /api/v1/isms/policies (owner) — Body: title, optional version (default 1.0), category, body.
  • PATCH /api/v1/isms/policies/{id} (owner).
  • POST /api/v1/isms/policies/{id}/acknowledge (any member) — idempotent per policy version; a version bump requires a fresh acknowledgment.
  • GET /api/v1/isms/policies/{id}/acknowledgments (owner) — who has acknowledged.

Information-asset register (ISO 27001 A.5.9) — isms:read / isms:write:

  • GET /api/v1/isms/assets (filter by classification/status) · POST /api/v1/isms/assetsBody: name, optional asset_type, classification (public·internal·confidential·restricted), owner_id, description.
  • GET /api/v1/isms/assets/{id} · PATCH /api/v1/isms/assets/{id}.

HIPAA

HIPAA compliance surfaces for tenants handling protected health information. Every mutating action is audited and mapped to HIPAA controls, feeding the HIPAA readiness report. No PHI is stored — only opaque tenant-side identifiers.

PHI access logging (45 CFR §164.312(b) audit controls):

  • POST /api/v1/hipaa/phi-access (any member — the app records each access) — Body: phi_type, access_type (view·create·update·export·disclose), purpose (treatment·payment·operations·other), optional record_id/patient_ref/reason, is_emergency (break-glass access is flagged as a warn event).
  • GET /api/v1/hipaa/phi-access (owner / privacy officer; filter by record_id/is_emergency).

Breach notifications (Subpart D) — hipaa:read / hipaa:admin:

  • GET /api/v1/hipaa/breaches (filter by status) · POST /api/v1/hipaa/breachesBody: title, individuals_affected, optional description/breach_type/discovered_at. The 60-day notification_deadline and the is_large_breach flag (≥500 individuals → HHS + media duties) are computed server-side.
  • GET /api/v1/hipaa/breaches/{id} · PATCH /api/v1/hipaa/breaches/{id} (status, risk_level, individuals_affected, resolution_notes).
  • POST /api/v1/hipaa/breaches/{id}/notifications — record a statutory notification sent. Body: notification_type (individual·hhs·media).

Encryption verification (§164.312(a)(2)(iv) / (e)(2)(ii)) — hipaa:read / hipaa:admin:

  • GET /api/v1/hipaa/encryption (filter by resource_type/compliant) · POST /api/v1/hipaa/encryptionBody: resource_type, resource_name, encryption_at_rest, encryption_in_transit, optional algorithm/notes/ next_verification_date. is_compliant is derived — true only when encrypted both at rest and in transit.
  • PATCH /api/v1/hipaa/encryption/{id}.

Authorization admin (roles, assignments, policies)

Manage who-can-do-what. All endpoints are tenant-scoped and gated by the authz:* permissions (held by owner and admin). Every mutation is audited and compliance-mapped (SOC 2 CC6.1–6.3, ISO 27001 A.5.15/A.5.16).

Roles & catalogauthz:roles:read / authz:roles:manage:

  • GET /api/v1/authz/catalog — the permission catalog + built-in system roles; the source for a custom-role builder.
  • GET /api/v1/authz/roles · POST /api/v1/authz/roles — list / create custom roles. Body: code, name, permissions (catalog codes or domain:* / *:read / * patterns), description. Codes cannot shadow a system role.
  • GET /api/v1/authz/roles/{id} · PATCH /api/v1/authz/roles/{id} · DELETE /api/v1/authz/roles/{id} (soft-delete — existing assignments stop granting it).

Assignmentsauthz:assignments:read / authz:assignments:manage:

  • GET /api/v1/authz/assignments (filter by user_id) — active assignments, each with its role label and expires_at. Pass include_revoked=true to include revoked grants, each carrying revoked_at, revoked_by and revoke_reason — this is how you answer "who removed this access, and why" without a database query.
  • POST /api/v1/authz/assignments — grant a role. Body: user_id, exactly one of role_code (system) or role_id (custom), optional expires_at (ISO-8601, must be in the future → JIT).
  • DELETE /api/v1/authz/assignments/{id} — revoke. Optional reason query parameter (≤280 chars) is stored on the revocation record and returned by the listing above; it defaults to "revoked by administrator". Revoking your own assignment is refused (400) to prevent self-lockout — another admin must do it.
  • GET /api/v1/authz/effective-access/{user_id}what a user can actually do, and via which role: their effective permissions plus a sources breakdown (owner bridge / member baseline / each assignment, with expiry surfaced). Platform-scoped permissions (operator:*, assurance:* — Zynth's own operator and assurance planes) are excluded from every source and from the effective set outside the platform-operator tenant, so this view always matches what the engine would decide.

ABAC policiesauthz:policies:read / authz:policies:manage:

  • GET /api/v1/authz/policies · POST /api/v1/authz/policiesBody: name, resource, action, effect (allow/deny), conditions (list of {field, operator, value} clauses, ANDed), priority (lower evaluated first). Policies overlay RBAC with deny-overrides.
  • GET /api/v1/authz/policies/{id} · PATCH /api/v1/authz/policies/{id} · DELETE /api/v1/authz/policies/{id} (soft-delete; system policies are immutable).

Delegations (authz:delegations:read / authz:delegations:manage) — the live grant source for agents. You delegate a bounded subset of your own authority; a delegation cannot grant a permission you don't hold (the C4 superset check).

  • GET /api/v1/authz/delegations (filter by delegate_id) · POST /api/v1/authz/delegationsBody: delegate_id, delegate_type (user/agent), permissions, scope, oversight_mode, max_risk_score (1–20), expires_in_days (1–30). DELETE /api/v1/authz/delegations/{id} revokes — and cascades: revoking a delegation also revokes every sub-delegation made under it (the response reports cascaded, the number torn down). The delete of the human who started a chain no longer leaves the delegatees holding the grant to its expiry.

Bounds on a chain (422). A delegation may be sub-delegated, but only so far: the chain depth is capped at 3 (a delegation you grant is link 1; the fourth link down the chain is refused). Separation-of-duties rules are enforced on the delegation path as well as at role assignment — a delegation that would hand a delegate a permission distinguishing one role in an SoD pair, while they already hold one distinguishing the other, is refused with the rule's name. The C4 superset check is always applied, whoever the delegator is.

The band and the scope are bounds, not labels. oversight_mode and max_risk_score govern what the delegate may do with this grant: at decision time the effective band is the stricter of the delegation's and the delegate's own registration, so a delegation can narrow authority and never widen it. Both fields default to their safe values (HUMAN_APPROVE, max_risk_score: 5) and those defaults are enforced — omit them and the delegate is supervised and clamped to risk 5. Set them explicitly when you mean an agent to act unattended. An agent delegating to another agent cannot hand over a band wider than its own (422). And an AUTONOMOUS delegation may not carry a max_risk_score above 10 (422), so an action scoring 11–20 always runs under a human-in-the-loop delegation and its approval — no agent acts unattended above risk 10.

scope narrows where the authority may be used, as glob lists per dimension: {"mcp_servers": ["zynth-content"], "mcp_tools": ["content_*"]}. Empty means unrestricted. Both dimensions are enforced on every MCP surface — the gateway (/api/v1/mcp/call/{server}) and the first-party MCP server (/api/v1/mcp, whose server name is zynth-auth), where an excluded tool is also hidden from tools/list. A dimension outside the known set is rejected with 422, because a narrowing nothing can evaluate would confine nothing. Those dimensions describe MCP calls, so they do not narrow a plain REST call — to bound REST authority, delegate fewer permissions.

Authorization resources & the check API

Two related surfaces: a registry that lets your own APIs define permissions in Zynth's vocabulary, and the decision endpoint your services call to ask whether a principal may act.

Resources/api/v1/authz/resources (authz:resources:read / authz:resources:manage). A resource binds a permission namespace to an OAuth resource you have already registered, so every permission you mint is anchored to a known token audience.

  • GET /api/v1/authz/resources — registered namespaces, each with its permissions.
  • POST /api/v1/authz/resourcesBody: oauth_resource_id, namespace (2–40 chars), optional description. 201 · 422 if the OAuth resource isn't registered yet — register the audience first · 409 namespace collision, or that resource is already bound.
  • POST /api/v1/authz/resources/{resource_id}/permissions — mint one. Body: action, optional description, read_only (default false), risk (1–15). 201 {permission_id, code, risk} · 409 it already exists.
  • DELETE /api/v1/authz/resources/{resource_id} · DELETE /api/v1/authz/resources/permissions/{permission_id}204, but 409 while any role still grants it: revoke the grants first, so a deletion can never silently widen or narrow someone's access.

The check API. Both lanes take {"checks": [{permission, principal_id?}, …]}at most 50 per call (422 past that) — and answer {results: [{principal_id, permission, allowed, via, reason, obligations}], count}. via is rbac · policy · owner · default, so a denial explains itself. Only denials are recorded.

  • POST /api/v1/authz/check — for a principal asking about itself. Any valid session, API key or agent token; no permission needed. principal_id is optional and always resolved to the caller — naming anyone else is 403, which points you at the service lane.
  • POST /api/v1/authz/service-check — for a resource server asking about its users. Authenticates with an OP client_credentials token (Authorization: Bearer …) that carries the authz:check scope; the tenant comes from the token, not a header. 401 without a bearer token or with anything that isn't a client-credentials token · 403 without the authz:check scope · 422 if any check omits principal_id, which is required on this lane. Verifying that token at your own service is the MCP authorization guide's procedure; see also Verifying tokens.

Access governance — requests, SoD, certification

Joiner-mover-leaver control over the roles above: users ask for access instead of an admin guessing, separation-of-duties rules refuse toxic role combinations at grant time, and certification campaigns make someone re-attest live grants on a deadline. Walkthrough: Access requests.

Availability: two gates, both required. This family needs the enterprise entitlement (authz.governance) and the deployment flag AUTHZ_GOVERNANCE_ENABLED. With either missing every route answers 404, not 403 — on every HTTP method, before authentication is considered, byte-identical to a path that was never registered (ADR-0084). A 404 here means "not available on this deployment or plan", not "wrong id".

Access requests/api/v1/authz/requests. Filing and withdrawing are self-service for human principals (an agent gets 403); deciding needs authz:assignments:manage.

  • POST /api/v1/authz/requests — ask for a role. Body: exactly one of role_code / role_id, justification (10–2000 chars — the record an auditor reads), requested_days (1–90, default 7). 201. A live duplicate ask is returned rather than doubled. 422 on a malformed ask.
  • GET /api/v1/authz/requests?status= — the approver inbox (authz:assignments:manage). GET /api/v1/authz/requests/mine — your own asks.
  • POST /api/v1/authz/requests/{id}/decide — approve or deny in one call. Body: approve (bool), optional reason, sod_override (default false). Approval mints a time-bounded role assignment. 403 you cannot decide your own request · 409 when it already has a verdict, or when a SoD rule blocks the grant · 404.
  • POST /api/v1/authz/requests/{id}/cancel — withdraw your own. Someone else's request is a 404, not a 403 (no enumeration). Unanswered requests expire on their own.

Separation of duties/api/v1/authz/sod-rules (authz:policies:read / authz:policies:manage). Rules are create and delete only; there is no edit, because silently rewriting a control is how a control stops being one.

  • GET — the tenant's rules plus the shipped templates (operate-vs-attest, audit-vs-admin). POSTBody: name, optional description, roles (2–8 distinct role codes that may never be held together). DELETE /{rule_id} .
  • There is no separate violation-check endpoint by design — the rule is enforced inline at every grant, so assignments and request decisions both return 409 naming the rule. The only way past it is sod_override: true on a request decided by a different approver — an override that leaves a record, rather than a quiet exception.

Certification campaigns/api/v1/authz/campaigns.

  • POST (authz:assignments:manage) — create and launch in one call: the in-scope live grants are snapshotted as review items immediately. Body: name, scope (all · system_roles · custom_roles), deadline (ISO-8601, must be future), auto_revoke (default true), optional reviewer_permission (defaults to authz:assignments:manage). Returns the campaign with item_count — how many grants were snapshotted. 422 on a bad scope, an unparseable or past deadline, or a duplicate name.
  • GET (authz:assignments:read) — campaigns. GET /{id} — one campaign with its items (the review-item list — the create response reports only the count, as item_count).
  • POST /{id}/items/{item_id}/decide — attest or revoke. Body: attest (bool), optional reason. Authorized by the campaign's own reviewer_permission (or ownership), not a fixed one. 403 you cannot review your own access · 409 if the campaign is closed or the item already decided.
  • POST /{id}/close — close it, returning the decision counts. With auto_revoke, grants nobody attested are revoked at close: the default outcome of an unanswered review is losing the access, not keeping it. 409 if already closed.

AI agents

Register and govern AI agents as first-class principals. An agent is created by a human, gets its own credential (never a human's), and carries a risk profile plus an emergency kill switch. Gated by agents:read / agents:manage, with the kill switch on its own agents:kill permission (a security operator can stop a rogue agent without full manage rights).

Risk level → governance band. risk_level is minimal, limited, or high. It derives a band — a max risk-score ceiling, a minimum oversight mode (AUTONOMOUS / HUMAN_IN_LOOP / HUMAN_APPROVE), and a confidence floor — that you can tighten but never weaken (high locks HUMAN_APPROVE). Omitted config fields default from the band. The band is one half of what governs an agent: the other is the band on the delegation carrying the permission, and the stricter of the two applies. confidence_floor is validated and stored but enforces nothing — no request carries a confidence signal, and a self-reported one from the agent would not be a control; treat it as documentation of intent, not a bound.

  • GET /api/v1/agents · POST /api/v1/agents — list / register. Body: name, risk_level, optional description, max_risk_score, oversight_mode, confidence_floor. The response includes the credential secret once — store it now; it is never retrievable again. 409 when the name is already taken in your organization (a re-run setup script hits this constantly; the honest answer is "you already have one", not an error) · 422 for a config weaker than the risk band.
  • GET /api/v1/agents/{id} · PATCH /api/v1/agents/{id} — fetch / update (never returns the credential). A config weaker than the risk band is rejected (422). Tightening applies immediately; loosening takes a second person — see the dual-control note below.
  • POST /api/v1/agents/{id}/rotate-credential — issue a fresh credential (old one stops working); returned once. Requires dual control — see below.
  • POST /api/v1/agents/{id}/kill-switchBody: engaged (bool), optional reason (≤ 200 chars, recorded on the agent). Engage the emergency stop or release it (agents:kill). Engaging is open to any holder of that permission; releasing is human-only, because it restores a stopped principal's authority.

The containment record is returned on every agent read. kill_switch_engaged_at, kill_switch_engaged_by, kill_switch_reason and tokens_revoked_at accompany kill_switch_engaged on GET /api/v1/agents and GET /api/v1/agents/{id}. All four survive a release, deliberately — "who stopped this, and why" has to outlive switching the agent back on — so a non-null kill_switch_reason means "has been stopped at some point", not "is stopped now". Only kill_switch_engaged says that. tokens_revoked_at is the containment epoch: every token minted at or before that instant is refused, and it is monotonic, so releasing the switch does not resurrect them.

Is it live right now? Every agent read also returns liveness ("live", "idle" or "never") and live_until. Agents are sessionless — nothing is written when a token is used, and last_authenticated_at is overwritten on every mint — so that timestamp alone answers the wrong question in both directions. liveness answers the right one: would a credential this agent currently holds still be accepted? It is "live" only while a token minted at the last authentication is inside its lifetime and nothing would refuse it — so containment, suspension, retirement and the tokens_revoked_at epoch each flip it to "idle" immediately, in the same breath as the refusal itself. "never" means the credential has never been used; live_until is when the window closes (or closed), and is null only in that case. It is a statement about the token, not a heartbeat: for "when did this agent last do something", read last_seen from GET /api/v1/command/events/by-actor.

Managing an agent identity is human-only, and loosening one takes two people. An AI-agent principal (or an API key) holding agents:manage is refused 403 on register, update, rotate-credential, retire and kill-switch release — the alternative is a principal that can rewrite the record it is governed by, mint a successor nothing governs, or take over a peer by re-crededentialing it. Reads (agents:read) are unaffected.

Two acts hand out authority and therefore need a second approver: widening an agent's governance band, and rotating its credential. Both answer 202 Accepted with approval_id and no credential; a different person holding agents:manage approves it at POST /api/v1/approvals/{approval_id}/decide, and you then repeat the original call, which returns 200 and (for a rotation) the new secret. The approval binds to the exact request — one agent, one proposed band — is single-use, and cannot be decided by whoever asked for it. If this deployment has the approvals ledger switched off there is nobody to ask, so both acts answer 503 rather than proceeding unattended.

"Tighten but never weaken" is now measured against the agent's current configuration, not against the risk level supplied in the same request. Lowering risk_level counts as a weakening on its own, because it is what would make a later widening legal.

  • DELETE /api/v1/agents/{id}retire an agent (agents:manage). 204, and immediate on both halves of the agent's authority: the credential is destroyed, so no new token can be minted, and any access token the agent already holds is refused (401) on its next request — the same fail-closed instant as the kill switch, but permanent. The agent leaves the list and every subsequent call for it returns 404. Repeating the DELETE on an already-retired agent is 404.

Retirement is a soft delete, and that is deliberate. The agent's record is kept, because its audit entries, approval decisions and detections all reference it — destroying that trail would erase the evidence of what a non-human principal actually did, which is the opposite of governing it (EU AI Act Art 12 record-keeping, ISO 42001 8.4). What is destroyed is the agent's authority, not its history: you will still find it in the audit log.

There is no un-retire. To pause an agent you may want back, PATCH it to status: "suspended" instead — that is reversible; this is not.

These agents are managed from the AI Agents console (/agents in the app): register, tighten the risk profile, rotate the credential, engage the kill switch, retire the agent, and delegate authority.

Attestation issuers (federated workload identity)

Availability: ships dormant. While AGENT_FEDERATION_ENABLED is off (the default), the whole attestation surface — the registry and the exchange — returns 404.

The registry of external issuers this organization trusts to attest its agent workloads, and the bindings that map exact attestation claims to pre-registered agents. All endpoints require tenant:manage; every write is recorded and raises an immediate detection — this registry decides who can mint agent tokens, the same weight as an SSO connection. Also in the console: AI Agents → Attestation issuers.

  • GET /api/v1/agents/attestation/profiles — the supported issuer types and, per type, the claims a binding may pin, plus whether an explicit jwks_uri is required:

    ProfileSignature algsMax token validityExplicit jwks_uri
    github-actionsRS25615 minno — public discovery
    kubernetesRS256, ES2561 hyes — cluster discovery is not reachable from outside
    awsRS2561 hno
    gcpRS2561 hno
    spiffeRS256, ES25615 minyes — bundle endpoint, not OIDC discovery

    These are enforced, not advisory: a token signed with an algorithm its profile doesn't declare, or claiming a longer life than its issuer ever mints, is refused; registering a kubernetes/spiffe issuer without a jwks_uri is a named 400 at setup time rather than an opaque timeout at the first exchange.

  • GET / POST /api/v1/agents/attestation/issuers — list / register. Body: issuer_url (https, no query/fragment — the attestation token's iss must equal it exactly), profile, optional jwks_uri. Max 10 issuers per organization.

  • PATCH /…/issuers/{id}enabled: false stops all exchange for the issuer without deleting its configuration. DELETE /…/issuers/{id} — trust ends whole; bindings go with it.

  • POST /…/issuers/{id}/bindingsBody: agent_id (a live agent in this organization), claims (exact string matches drawn from the profile's claim schema). The subject claim is mandatory, wildcards are refused, and a duplicate claim set is 409 — a binding that matched more than one agent, or whole classes of foreign workloads, is a misconfiguration this API will not accept. Max 25 bindings per issuer. DELETE /…/issuers/{id}/bindings/{binding_id} removes one.

The federated exchange. POST /api/v1/agents/token/federated (unauthenticated — the attestation is the auth) — Body: tenant (your organization slug — an attestation token carries no Zynth tenant, so the request names it), subject_token (the attestation JWT your runtime already has: GitHub Actions OIDC, a Kubernetes projected service-account token, cloud instance identity, SPIFFE). Returns the same short-lived actor=agent token the classic path mints — kill switch, risk band and delegations apply unchanged — with no standing Zynth secret at rest anywhere.

What must hold, or the exchange refuses: the token's iss equals a registered, enabled issuer exactly; the signature verifies against the issuer's published keys (RS256/ES256 only); aud equals this deployment's issuer URL — mint your attestation for Zynth, e.g. GitHub's audience: input; exp − iat ≤ 1 hour; jti present and never seen before (each attestation exchanges exactly once); and the claims match exactly one binding.

Every refusal is the same generic 401 — no oracle for which check failed; the reason is visible to your admins in the Command Center. Rate-limited fail-closed in three layers (per-IP, per-named-tenant, and a failed-exchange brake per issuer): over the cap you get 429 with Retry-After.

Agent authentication. POST /api/v1/agents/token (unauthenticated — the credential is the auth) — Body: credential, and optionally task (below). Returns a short-lived actor=agent access token ({ access_token, token_type, expires_in, agent_id }). The agent then calls the API with Authorization: Bearer <token>. Its authority is the union of its active delegations, and every action passes the governance stage: the kill switch, a per-action risk ceiling, an oversight mode (a non-AUTONOMOUS agent's actions are blocked pending human approval), and a confidence floor. Engaging the kill switch denies an already-issued token on its next call.

Task-scoped capability tokens. By default the token you get back carries everything the agent holds, for the whole expires_in, for any purpose. Send a task block and you get a token bound to one declared job instead:

POST /api/v1/agents/token
{
  "credential": "…",
  "task": {
    "task": "Summarise last night's detections",   // what it is for, in your own words
    "permissions": ["command:read"],               // must be a SUBSET of what the agent holds
    "budget": 25                                   // authorized requests before the token is spent
  }
}

The response adds a task object echoing what was actually granted:

{
  "access_token": "…", "token_type": "bearer", "expires_in": 900, "agent_id": "…",
  "task": { "id": "…", "task": "Summarise last night's detections",
            "permissions": ["command:read"], "budget": 25 }
}

Read task.permissions rather than assuming you got what you asked for: patterns are expanded to concrete codes (command:* comes back as the individual codes), and what is echoed is what is enforced.

  • permissions must be a subset of the agent's own. Ask for anything the agent does not currently hold and the whole mint is refused with 403task scope exceeds the agent's own authority: <codes> — rather than quietly handing you the intersection. A scope that expands to nothing the catalogue knows (a typo) is refused the same way. Fix the request or the delegation; do not retry.
  • It is a ceiling, not a grant. The declared set is intersected with the agent's authority at the time of each request, so revoking a delegation narrows the task token in the same instant it narrows the agent. A task token can never do more than the agent could.
  • Outside the scope is 403. Even for a permission the agent genuinely holds — that is the point. The refusal is recorded and alerts your security operators at the first occurrence, since an agent that chose its own scope minutes ago has no legitimate reason to reach past it.
  • budget counts authorized requests, not permission checks: one API call spends at most one unit, and a request that is refused for any reason spends nothing. When it runs out, every further action is 403 — mint a new token (with a new task) to continue.
  • The declared task is recorded and shown to humans. It appears on the agent's audit trail and, when an action needs human approval, in the approver's queue as the request's justification, so the person deciding sees what it is for beside what is being asked. It is your text, displayed as your text: it informs the decision and grants nothing.
  • Nothing else changes. The kill switch, the token epoch, the risk ceiling, the oversight mode and the mint's rate limits all apply exactly as they do to an ordinary agent token — a task token is weaker in every dimension and stronger in none.

The practical shape: mint one task token per job your agent runs, with the narrowest permission list and the smallest budget that job needs. A task token for reading detections cannot write a policy acknowledgment even if the agent holds that permission — which is what makes "the agent can only do what we gave it for this run" a mechanical fact rather than a hope.

Rate limits on the mint, all fail-closed (429 + Retry-After, with the usual RateLimit-* trio): a per-source-IP floor, a failed-attempt brake per credential — only failed attempts move it, so an agent authenticating normally never approaches it — and a per-organization issuance allowance charged only once a credential verifies. The defaults are far above normal use: an agent holding a 15-minute token needs about four mints an hour, and hundreds of agents can share one egress address. If you see a 429 here, something is re-authenticating on every request instead of holding its token for expires_in — cache the token.

The MCP surface (POST /api/v1/mcp) carries its own fail-closed budget on every frame, initialize and tools/list included: per agent, per organization, and per source host. The per-agent ceiling is roughly an order of magnitude above a busy model-driven tool loop, and an agent that exhausts its own ceiling does not consume its siblings' organization allowance — one misbehaving agent cannot throttle your fleet. Over the limit you get 429 with a computed Retry-After; honour it rather than retrying immediately.

The blast-radius budget and its circuit breaker. Separately from the request budgets above, each agent has a write budget — how many changes it may make in a rolling hour — and a refusal allowance. The write budget comes from your plan (200 changes per agent per hour on the free tier, 2 000 on pro, 20 000 on enterprise) and can be raised for your organization; reads never count against it, and neither does a change that is waiting on a human approval. The refusal allowance is fixed: an agent that is refused around 50 times in an hour is probing for capability or has lost a delegation, and either way a person should look.

Exhausting either does not slow the agent down — it stops it. The agent's kill switch is engaged automatically, so its very next request is refused at authentication on every surface, including any token it is already holding. The agent's record shows the reason (blast-radius breaker: mutation budget exhausted or … denial rate exceeded) with no person named as the one who engaged it, and your Command Center raises a critical alert on the first occurrence. Releasing it is the same one click as any other kill switch, by anyone holding agents:kill — but read the reason first: an agent released without fixing what tripped it will trip again inside the hour.

This bound applies to agents only. People and API keys are never metered by it, and it is deliberately generous: it exists so that a compromised or runaway agent cannot rewrite your organization faster than you can notice, not to shape ordinary traffic.

Agent approvals (human-in-the-loop)

Where a non-AUTONOMOUS agent's blocked action goes to wait for a person. An agent attempting something its oversight mode won't let it do alone gets a 403 carrying an approval_request_id; a human decides here, and the agent re-tries. Walkthrough: Agent action approvals.

Availability: ships dormant. While AGENT_APPROVALS_ENABLED is off (the default), every route below returns 404 before the body is even validated.

  • GET /api/v1/approvals (agents:approvals:read) — the queue. Filters: status (pending · approved · denied · expired · consumed), agent_id, resource_type. Every row carries resource — the concrete thing being decided, not only its type. For a manifest ask that is the resolved plan: manifest_hash, plan_hash, catalogue_version, appliable, and steps[] (kind, target, would, detail, plus grants[] — the exact permission codes a role would carry or an invitation would confer). It is null on asks recorded before this field existed, and on paths that do not yet supply one; treat absence as "no recorded detail", never as "nothing consequential".
  • GET /api/v1/approvals/{id} — one request, plus approvals_recorded (multi-approver asks declare required_approvals). Needs agents:approvals:readexcept that an agent may always poll its own request, which is how it learns the verdict without holding a governance permission.
  • POST /api/v1/approvals/{id}/decide (agents:approvals:decide) — Body: approve (bool), optional reason (≤2000), break_glass (default false). Refusals are the design: 403 agents cannot decide approvals (the principal under judgement never judges), 403 you cannot decide your own request, 403 break-glass is owner-only, and 403 when you lack the required approver permission — which the message names. 409 if the request already has a verdict or has expired. An approval is spent once and binds to the exact action asked about.

Agent-authored content/api/v1/approvals/content (same flag, same 404). The sibling-service lane for the publish loop, keyed on an exact content_hash rather than an approval id: POST /request (content:draft:write — an agent may only request its own review), POST /feedback (the decisions and, on a denial, the reason the agent learns from), POST /consume (content:read — spend the approval for those exact bytes; 409 with a structured {state, message} so a caller distinguishes "not decided yet" from "denied" without a second round trip), POST /reversal (content:review — record that a published piece had to be pulled), and GET /track-record/{agent_id} (content:read — the earned-autonomy evidence: lower_bound against the bar, reversals_30d, and every unmet criterion named). See Agent-authored content.

MCP

Two distinct MCP surfaces — do not confuse them.

The first-party serverPOST /api/v1/mcp, always mounted. A single JSON-RPC 2.0 endpoint (protocol 2025-06-18) exposing Zynth's own administrative tools to agents: initialize, ping, tools/list, tools/call. Agent principals only — a human or service token gets 403, and so does an agent whose kill switch is engaged, checked before anything else runs. tools/list returns only the tools the agent's delegations actually reach, so the catalogue an agent sees is its authority. Every write tool accepts dry_run: true, which reports what would happen and changes nothing — including the IAM-as-code choreography (plan_manifest / request_manifest_approval / apply_manifest, see manifests). Guide: Operating Zynth over MCP; the token model is MCP authorization.

The tool gateway/api/v1/mcp/servers and /api/v1/mcp/call, which broker an agent's calls to your MCP servers under the same governance. Flag-gated: 404 on every route while the gateway is off. Guide: MCP tool gateway.

  • GET / POST /api/v1/mcp/servers (mcp:servers:read / mcp:servers:manage) — list / register. Body: name (the lowercase slug agents call by, immutable), upstream_url (https), optional description, max_calls_per_minute (default 30), max_calls_per_day (default 500).
  • GET · PATCH · DELETE /api/v1/mcp/servers/{server_id} — read, reconfigure (enabled: false stops calls without deleting), unregister.
  • PUT /api/v1/mcp/servers/{server_id}/tools · DELETE /api/v1/mcp/servers/{server_id}/tools/{tool_name} — the per-tool allow-list and its risk scoring; a tools/list reply is filtered down to it.
  • POST /api/v1/mcp/call/{server_name} — the proxied JSON-RPC call. Agent principals only (403). Each call passes the full governance stage — kill switch, per-tool risk ceiling, oversight mode, rate caps — and a refusal is a 403 naming the reason. 404 unknown server · 502 when the upstream itself fails, so an upstream outage never reads as a denial.

Payment security

Step-up elevation, PSD2-style SCA with dynamic linking, and transaction-risk inputs. Zynth authenticates the human; your processor handles the money, and no card data reaches the platform. Full walkthrough: Payment security.

Availability: ships dormant. While PAYMENTS_STEPUP_ENABLED is off (the default), every route below returns 404 — the whole surface, on every HTTP method, before authentication is considered. The refusal is byte-identical to a path that was never registered, so a dormant deployment is indistinguishable from one built without the feature (ADR-0084).

payments:verify gates the two service-side calls (consume, risk context). Opening and satisfying your own elevation needs no permission beyond an authenticated session.

  • POST /api/v1/payments/step-up — open an elevation bound to one exact operation. Body: action ([a-z0-9_.:-]+), optional context object. Returns the elevation plus methods — the factors this user has actually enrolled. 400 no_factor when they have none (a password-only user cannot step up). Floats in context are refused: amounts belong in minor units, and the error names the rule.

  • POST /api/v1/payments/sca/challenge — the payment variant. Body: amount_minor (positive integer, minor units), currency (supported ISO-4217 code), payee, payee_name. Returns the elevation, methods, and display — the payer-facing sentence rendered by the server from the bytes it bound ("Confirm PHP 149.99 to Acme Store"). Show it verbatim. An unsupported currency is a 400 listing the supported set rather than a guessed decimal position.

  • POST /api/v1/payments/step-up/{id}/webauthn-options — mint a passkey challenge for a pending elevation (held server-side, bound to that one ask).

  • POST /api/v1/payments/step-up/{id}/verify — satisfy it. Body: {"method":"totp","code":…} or {"method":"webauthn","credential":…,"credential_raw_id":…}. 401 on a bad code or assertion, which burns an attempt; past the cap the ask is dead, not paused. For SCA, 403 insufficient_factors when the session plus this factor do not reach two factor categories — the message names the shortfall and points at the passkey route.

  • POST /api/v1/payments/elevations/consume — spend it, once (payments:verify). Body: elevation_id, user_id, sid, action, context exactly as bound. Returns the attestation; for SCA that includes the transaction as bound, the sentence shown, and which factor categories were proven. Refusals — all meaning do not proceed: 409 fingerprint_mismatch (any bound detail changed, including the payee's display name), 409 not_spendable (already used, expired, or never approved), 409 session_gone (the session that earned it has ended, or sessions were revoked since).

  • GET /api/v1/payments/risk/context?user_id=…&sid=… — risk inputs (payments:verify). Returns a coarse score and band with every factor that produced them — account age, session age, factor strength, open detections against the user and the source IP — plus unavailable (factors not yet computed, disclosed rather than omitted) and degraded (an input could not be read and was scored worst-case, so the score is a floor). Never an exemption verdict: Zynth does not rule a payment exempt from strong authentication.

Command Center

The security-operations read plane: the raw event stream, the tamper-evident audit chain, detections, findings, incidents, and compliance evidence. All under /api/v1/command, all gated by command:read, with the three mutations needing command:operate. Scoping is automatic — a platform-operator tenant reads across organizations, everyone else reads only their own. Also the Command Center console in the app. No feature flag or entitlement: this surface is always on.

Events

  • GET /api/v1/command/events — the envelope stream. Filters: since, category, action, severity, correlation_id, actor_id, actor_type, tenant_id, limit (default 100, max 1000). Newest first, and every filter composes (actor_id + since + severity narrow together). A non-platform caller naming another organization's tenant_id gets 403.
    • actor_id is the per-principal drill-down — "what has this agent been doing". It matches actor.id exactly, whatever kind of principal that is: a user or agent UUID, but also a named service (alertmanager) or a credential prefix, because those are real actors in this stream. 1–64 characters (the column's width); outside that is a 422.
    • actor_type is one of human · agent · service · system. Anything else is a 422 naming the field, rather than a silently empty page.
    • Both narrow within your own scope: they never widen it, so an actor_id that also acts in another organization still returns only your events for it.
  • GET /api/v1/command/events/by-actorevent volume per principal, over the whole window. Parameters: actor_type (the same four-value vocabulary; default agent) and days (1–90, default 7). Returns one entry per principal — total, by_outcome (every outcome the window contains), elevated (events at or above warn), first_seen/last_seen — plus total/shown/hidden, because the roster is bounded and tells you what it cut. Ordered by volume, ties broken on actor_id.
    • Not derivable from GET /command/events. That endpoint is capped at limit, so counting its rows answers "…among the most recent N" while looking exactly like a total. This one is a GROUP BY over the window.
    • Attribution is by the envelope's actor, never by the action name. content.reversal_recorded is titled "Agent content was unpublished" and its actor is the human reviewer who unpublished it, with the agent in the metadata — it counts against the reviewer, which is correct.
    • Scoped like every other read here: your organization's principals, and a platform operator's every tenant.
  • GET /api/v1/command/events/stream — Server-Sent Events (text/event-stream). Opens at the live tip and tails from there; it is not a backfill, so page history from the list endpoint above. No query parameters. Quiet periods send the SSE comment : keepalive so proxies don't reap the connection, and frames carry no id:, so there is no Last-Event-ID resume — reconnect and re-page if you need the gap. It authenticates from the Authorization header like every other endpoint, which means a browser's bare EventSource cannot connect: use a fetch-based SSE client.

Audit

  • GET /api/v1/command/audit — the append-only audit log. Filters: action, actor_id, limit (default 100, max 1000). Each entry carries its compliance_tags and its entry_hash.
  • GET /api/v1/command/audit/verify — the tamper-evidence check. Walks the entire hash chain from genesis, re-computing every link, and returns {verified, entries, broken_seq}broken_seq names the first entry that doesn't match. This is the endpoint that answers "has anything been altered?", so it is deliberately narrow: command:read plus platform-operator membership (403 otherwise), and it verifies globally rather than per tenant.

Detections & findings

  • GET /api/v1/command/detections — filters severity, rule_id, actor_id, limit (default 100, max 1000); each row is a deduplicated group with count, first_seen/last_seen and status. actor_id is "what was raised about this principal": pass the bare id and the server matches every group-key spelling that names an actor (actor:<id> and agent:<id>), so a rule that changed grouping does not appear to have lost its history.
  • GET /api/v1/command/detections/summary?days= (1–90, default 7) — roll-up by severity and by rule, each with an open count.
  • POST /api/v1/command/detections/{id}/acknowledge (command:operate) — Body: status (acknowledged | resolved, default acknowledged), optional note. 404 unknown.
  • GET /api/v1/command/findings — analyst-style findings over grouped detections. Filters severity, source (default analyst), limit (default 50, max 500). Read-only.

Incidents

  • POST /api/v1/command/incidents (command:operate) — Body: title, severity, optional description, detection_ids (≤100), assignee. 201; 422 naming any detection id that is malformed or not visible to you.
  • GET /api/v1/command/incidents — filters status, severity, limit (default 100, max 1000). PATCH /api/v1/command/incidents/{id} (command:operate) — Body: any of status (open · investigating · contained · resolved · closed), assignee, containment, remediation, root_cause. 404 unknown.
  • GET /api/v1/command/incidents/summary?days= (1–365, default 90) — counts by status and severity, plus an unresolved block that deliberately ignores the window (it reports all-time backlog and the age of the oldest open incident — the number to alert on).

Compliance

  • GET /api/v1/command/compliance/controls — the control catalog: each framework, and per control the actions that evidence it.
  • GET /api/v1/command/compliance/evidence?framework=…framework is required (422 listing the known set). Optional days (1–3650, default 90) and exportexport=true raises the per-control sample cap from 3 to the full set, for handing an auditor the actual entries. Returns readiness, the gaps list, and per-control audit samples with their hashes.
  • GET /api/v1/command/compliance/report?framework=… — the same window rendered as a readiness report: readiness_pct, satisfied vs gaps, open incidents, and an overall posture.

Autonomous response

The tier above detection: the platform's own response engine, which can act on a security signal — today, ending every session for a user whose refresh token was replayed. The model (oversight levels, the blast-radius ceilings that cap them, and the record a class must earn before acting unattended) is Autonomous response; operating it is the guide. Also in the console under Command Center → Autonomy.

Availability: ships dormant on self-hosted installs. While AUTONOMY_EXECUTION_ENABLED is off (the default) the engine records what it would have done and executes nothing — the endpoints below all serve, and the ledger only ever contains shadow rows.

Four permissions, split so an incident responder can stop the engine without gaining the power to reconfigure it: autonomy:read, autonomy:manage, autonomy:approve, autonomy:disable.

The superset rule. Approving — or undoing — an action requires autonomy:approve plus the permission to perform that action yourself (tenant:manage for session revocation). An approver can never unlock authority they do not hold. An action with no declared approver permission can be neither approved nor reverted by anyone: fail-closed by default.

  • GET /api/v1/autonomy/decisions — the decision ledger (autonomy:read). Query: mode (filter), limit (default 50, max 200). Returns {decisions, viewer_scope}. mode is one of dry_run, shadow (the engine decided not to act, and the row says why), proposed, denied, expired, executing, executed, reversed. Each decision carries the plan's one-sentence effect, its rule_id / action_id / blast_class / target_id, the verdict (adjudication, decided_by, decision_reason), and three server-computed flags: actionable (awaiting a verdict and still inside its window), revertible, and unattended (executed with no human in the loop). viewer_scope is tenant or platform — a platform operator reads every organization's decisions here, while caps and graduation below are always their own.

  • POST /api/v1/autonomy/decisions/{id}/adjudicate — approve or deny, then execute if approved. Body: approve (bool), optional reason (≤1000 chars — optional on approval, and worth writing: it is the oversight record an auditor reads). Returns {decision, executed, outcome, reason}. 409 if the decision already has a verdict or is not awaiting one; 403 if you lack the underlying permission. The verdict is recorded before execution is attempted, so a decision is never lost to a downstream failure — and every guard is re-checked at execution time, including a fingerprint of the exact effect that was approved. If the world moved between the yes and the act, the decision expires unexecuted rather than acting on facts that have aged out (executed: false, with the reason).

  • POST /api/v1/autonomy/decisions/{id}/revert — undo an executed action. Body: reason (required, 1–1000 chars — a responder who cannot say why in one line probably wants the kill switch instead). Returns {decision, reverted, detail}. 409, with nothing changed, when the effect was superseded — e.g. a person revoked the same sessions again after the engine did; undoing then would silently discard their decision. Reverting session revocation restores the previous state, so a wrongly-signed-out user's existing devices resume working; sessions ended by anything else stay ended. A reversal feeds the circuit breaker: one in the trailing 30 days holds that class below unattended operation until the window is clean.

  • GET /api/v1/autonomy/metrics — engine health (autonomy:read): by_mode counts, execution latency (mean + p95 over 30 days, with unattended_30d), per-class cap utilisation (rate_used/rate_cap, spread_used/spread_cap), and pending — count, how many are still actionable, and oldest_age_seconds. That last number is the one to alert on: a rising oldest-age means proposals are not being adjudicated, so no record grows, nothing graduates, and Recommend has quietly become shadow mode with extra steps.

  • GET /api/v1/autonomy/graduation — per-class gate status (autonomy:read): eligible, the confidence bar and your lower_bound against it, approvals/total, span_days vs min_days, reversals_recent, the catalogue version the evidence is scoped to, every unmet criterion named, and approvals_needed — ≈how many more clean approvals would clear the bar (null = not reachable soon). Always your own organization: a track record is a property of your own adjudications and has no cross-organization view. disable_principal is absent by design — its ceiling is Recommend, so it has no graduation path.

  • GET /api/v1/autonomy/kill-switch (autonomy:read) · POST /api/v1/autonomy/kill-switch (autonomy:disable) — the emergency stop. Body: enable (bool), hours (1, 8 or 24), optional reason. The stop is stored as an expiry instant and auto-re-arms; the read returns {engaged, scope, until, indefinite, reason, offered_hours} where scope is global (the deployment gate) or tenant. Omitting hours means indefinite, which is 400 without a reason — the unbounded option is deliberately the awkward one, because the real-world failure is the switch nobody turns back on. Every change raises an always-on critical detection: disabling is both a legitimate emergency action and an intruder's first move, and the platform makes it visible rather than guessing which.

Autonomy levels are organization settingsautonomy.level_contain_source, autonomy.level_contain_non_human, autonomy.level_raise_assurance, autonomy.level_revoke_access, autonomy.level_disable_principal — all defaulting to manual. Each key accepts only the levels its class ceiling permits: contain_non_human and revoke_access stop at graduated, disable_principal at recommend, and anything past it is a 400 naming the allowed set.

contain_non_human — stopping an AI agent. The one response class in which a false positive harms no person: the agent's kill switch is engaged and every token already issued to it stops working, while nobody loses access to their own account. Reverting it releases the switch — the agent works again immediately — but never resurrects the tokens minted before it, and the revert is refused to any non-human caller (403), because handing a non-human principal its authority back is the same act as releasing a kill switch by hand, which is people-only. Its graduation bar is 0.90, not revoke-access's 0.95. The one cap not expressible as a setting is inducibility — a detection an attacker can deliberately trip holds its response at recommend whatever its class is configured to, because that is a per-detection judgement rather than a per-class one.

GET /api/v1/autonomy/drill also exists and is platform-operator only — it reports a soak drill running in a disposable tenant, and is not part of the tenant-facing surface.

GET /api/v1/me

Return the authenticated principal's identity and tenant context. Role and ownership come from the database (re-validated per request), not the token.

Response 200:

{
  "user_id": "…",
  "tenant_id": "…",
  "role": "owner",
  "is_owner": true,
  "tenant_isolation": "pooled",
  "population": "workforce",
  "email_verified": true
}

401 if the token is missing/invalid or the membership is no longer active.

Self-service — /api/v1/me/*

Your own account, with no permission beyond a valid token: what a user may inspect and undo about themselves. These back the Account area of the app. See also GET /me/permissions and connected apps.

Sessions/api/v1/me/sessions

  • GET — every live session, each flagged current so a user can tell which one they are reading this on.
  • DELETE /{sid} — sign one device out. 204 · 404 for a session that isn't yours or is already dead — the two are indistinguishable on purpose. Revoking your current session is allowed and behaves like logout. To end them all at once, use /auth/sign-out-all.

ActivityGET /api/v1/me/activity — your own recent security events (limit, default 50, max 100). The self-scoped read of what the Command Center shows an administrator.

Agents acting for you/api/v1/me/agents

  • GET — the live delegations you granted to agents, each with the agent's name, the permissions carried, and the expiry.
  • DELETE /{delegation_id} — revoke one, through the same path the admin surface uses. 204 · 404 for a delegation that isn't yours, already revoked, or not to an agent. Revoking authority you delegated is self-service by definition; granting it is not — that stays on the governed admin surface.

Profile/api/v1/me/profile

  • GET{email, name, given_name, family_name, picture, editable}. editable is false when your organization provisions you through SCIM — render the form read-only rather than letting an edit fail.
  • PATCHBody: name, given_name, family_name. email and picture are not editable here (they are sourced from your sign-in identity). 409 when the profile is identity-provider-managed — edit it in the directory · 422 if a name is too long or carries control characters.

GET /.well-known/jwks.json

Public. Returns the JWKS document of RS256 public signing keys — up to three, in order: the retiring previous key (verify-only), the active signing key, and a staged next key. Only active is present in steady state. Select by kid rather than position or count. See Tokens & sessions.

GET /.well-known/security.txt

Public, text/plain. The RFC 9116 security-contact document — where to report a vulnerability. Fields published: Contact, Expires, Preferred-Languages, Canonical, and Policy when a public site is configured.

Config-derived, so a self-hosted install advertises its own security team rather than Zynth's (SECURITY_CONTACT). Policy comes from PUBLIC_SITE_URL and is omitted when that is unset — the disclosure policy is a marketing page, and a self-hosted install has no such page, so the field is left out rather than pointing at a URL that 404s.

On the managed service this document is reachable at both auth.zynthmedia.com/.well-known/security.txt and zynthmedia.com/.well-known/security.txt — the same document, so a researcher scanning either origin finds it. Canonical names the application origin as the authoritative location.

GET /api/v1/pricing

Public, no authentication — this is what the public pricing page renders. Returns the published plans of the managed service:

{
  "currency": "USD",
  "note": "…",
  "plans": [
    {
      "plan_code": "pro", "name": "Pro", "price": "$49", "period": "per month",
      "blurb": "…", "cta_label": "Start free", "cta_href": "…",
      "highlights": ["…"], "popular": true, "published": true,
      "features": ["sso.oidc", "scim", "…"],
      "caps": { "mau": 10000, "api_calls_monthly": null }
    }
  ]
}

The presentation fields (price, blurb, highlights, CTA) are editable content, but features and caps are derived from the entitlements catalog — the same values GET /api/v1/entitlements resolves — so published pricing cannot claim a capability a plan doesn't actually grant. A null cap means unlimited. On a self-hosted install this endpoint reflects that install's own (usually empty) pricing content, not Zynth's.

Token pair shape

{ "access_token": "…", "refresh_token": "…", "token_type": "bearer" }

In cookie mode, refresh_token is null — the refresh token travels only as the httpOnly cookie.

Browser clients can keep the long-lived refresh token out of JavaScript-readable storage: send use_cookie: true on signup / login / mfa/challenge / change-password, and the refresh token is set as an httpOnly, Secure, SameSite=Strict cookie scoped to /api/v1/auth (the body's refresh_token is null). Then call refresh with an empty body — the cookie authenticates and is rotated on every refresh; logout (and any failed cookie refresh) clears it. API/SDK clients that omit use_cookie keep the body transport unchanged.

OpenID Provider (OAuth 2.1 + OIDC Core)

Zynth Auth is a standards OpenID Provider — point any OIDC library at the issuer and it works. This section is the wire contract; the step-by-step integration guide is Log in with Zynth Auth (OIDC). The bounded profile (ADR-0052): code response type only, PKCE S256 mandatory, three grants, no implicit or ROPC — ever.

GET /.well-known/openid-configuration

Public discovery document. Every URL derives from the install's configured issuer (your own domain on self-host, https://auth.zynthmedia.com on managed). Advertises exactly what is enforced: response_types_supported: ["code"], code_challenge_methods_supported: ["S256"], grant_types_supported: ["authorization_code", "refresh_token", "client_credentials"], id_token_signing_alg_values_supported: ["RS256"], scopes_supported: ["openid", "profile", "email"], and the authorize/token/userinfo/jwks/ end-session endpoints below.

GET / POST /api/v1/oauth/authorize

The authorization endpoint (front-channel; both GET query and form POST per OIDC Core §3.1.2.1). Params: response_type=code, client_id, redirect_uri (exact match against the client's registered URIs — no wildcards/prefix/substring), scope (space- separated), state, code_challenge + code_challenge_method=S256 (required), nonce, optional prompt / login_hint / max_age, and optional resource (RFC 8707 — the identifier of the API the token is for; it must be registered and enabled for the client's organization, else invalid_target).

prompt (none · login · consent · select_account, space-separated; none may not be combined) and max_age are enforced: login/max_age force re-authentication, and none returns ?error=login_required or ?error=consent_required rather than showing any screen. login_hint is accepted and carried, but nothing consumes it yet. Full table in the OIDC guide.

Errors on an unproven client_id/redirect_uri return 400 and are never redirected (RFC 6749 §4.1.2.1). Once the redirect target is proven, protocol errors come back as ?error=…&error_description=…&state=… on the redirect URI. On success the browser is sent through Zynth's hosted login + consent screens and returned to redirect_uri with ?code=…&state=…. Rate limited fail-closed (429 + Retry-After): a per-IP floor, plus a per-client allowance resolved from your plan (free 60 / pro 600 / enterprise 6,000 requests per 5-minute window; negotiable per tenant) applied once the request has passed validation.

Authorization codes are single-use. Replaying a code revokes the session it minted and raises an oauth-code-replay detection — it is treated as theft, not a soft error.

/authorize parks the request and sends the browser to Zynth's hosted screens; these are what those screens call. They are not part of an RP's integration — an OIDC library never touches them — but they are documented because a self-hosted install may replace the hosted UI. All three authorize legs require the end user's own session (no special permission).

  • GET /api/v1/oauth/authorize/request/{request_id} — what the consent screen renders: the client, the scopes asked for, and whether this user has already consented.
  • POST /api/v1/oauth/authorize/completeBody: request_id. The user said yes: records the consent and returns the redirect carrying code + state.
  • POST /api/v1/oauth/authorize/rejectBody: request_id. The user said no: returns the redirect carrying error=access_denied, so the RP is told rather than left hanging.
  • POST /api/v1/oauth/logout/completeBody: request_id. Confirms a parked RP-initiated logout: ends the session, fans out the back-channel logout_tokens, and returns the post_logout_redirect_uri.

POST /api/v1/oauth/token

Form-encoded (application/x-www-form-urlencoded). Confidential clients authenticate via HTTP Basic (client_secret_basic) or client_secret_post; public clients send client_id and rely on PKCE. Serves three grant_types:

  • authorization_codecode, redirect_uri (exact, re-checked), code_verifier (PKCE re-verified), client_id. Returns access_token, id_token (when openid is granted), and refresh_token (when the client has that grant).
  • refresh_tokenrefresh_token. Rotates (new refresh token each time, old one invalidated); reusing a rotated token revokes the whole grant session. scope= may narrow (never widen) the grant.
  • client_credentials — machine-to-machine, confidential clients only. Returns an access token only (no id_token/refresh/user); sub is the client_id.

All three accept the optional resource parameter (RFC 8707). When present, the issued access token's aud becomes that identifier instead of the platform audience, so the token verifies only at that API. On authorization_code and refresh_token the value is fixed by the authorization and merely re-asserted: a request naming a different resource than the grant carries is refused with invalid_target — a resource cannot be introduced or swapped mid-family.

Response 200: {access_token, token_type, expires_in, scope, id_token?, refresh_token?} with Cache-Control: no-store. token_type is DPoP when the request carried a DPoP proof, else Bearer. Errors (RFC 6749 §5.2 JSON): invalid_grant, invalid_client (401), unauthorized_client, unsupported_grant_type, invalid_target (an unregistered/disabled resource, or one that doesn't match the grant). Rate limited fail-closed (429), in three layers: a per-IP floor before authentication; a per-client allowance resolved from your plan after authentication (free 120 / pro 1,200 / enterprise 12,000 requests per 5-minute window; negotiable per tenant — a plan change applies with nothing redeployed); and a failed-authentication brake — repeated wrong-credential attempts for a client_id are refused with 429 before verification (default 10 per 15 minutes). Only your own authenticated traffic counts against your client allowance; third parties naming your client_id cannot spend it.

GET / POST /api/v1/oauth/userinfo

OIDC Core §5.3. Authorization: Bearer <access_token> (must be an OP-issued user token — a platform/SDK token or a client_credentials token is refused). Honors revocation — 401 if the session was signed out, the password changed, or CAE fired, even before token expiry. 403 insufficient_scope without openid.

Claims released:

ClaimReleased
subalways
tid, tenant_slugalways (see tenancy claimstid is the key, the slug is display-only)
email, email_verifiedwith the email scope
name, given_name, family_name, picture, updated_atwith the profile scope (omitted when not stored)

On the profile scope. OIDC Core §5.4 defines profile as a set of possible claims, and §5.3.2 requires that claims which are not available be omitted rather than guessed or emitted empty. Profile attributes are sourced authoritatively — SCIM provisioning and enterprise SSO overwrite what the directory asserts on every sync/login, social login fills blanks, and non-IdP-managed users may edit their names in the console; claims_supported in discovery always reflects exactly what this deployment emits, so build against that rather than against §5.4's full list.

GET /api/v1/oauth/logout

RP-Initiated Logout 1.0 — the discovery end_session_endpoint. Params: id_token_hint (required — a Zynth-signed id_token; attributes the request to a client so no open redirect can form), post_logout_redirect_uri (must be pre-registered on the client, exact match), state. Ends the session and, on confirmation, fans out back-channel logout_tokens to every other RP holding a grant for that user. 400 on a missing/invalid hint or an unregistered redirect. Clients register a backchannel_logout_uri to receive the signed logout_token (best-effort by contract).

The id_token

A distinct RS256 JWT (never the access token), verifiable offline against JWKS. Claims: iss (your issuer), sub, aud (= client_id), azp, exp, iat, nonce (echoing the authorize request), auth_time, at_hash, sid (session id, used by back-channel logout), and amr — how the user actually authenticated (pwd, mfa, webauthn, social, sso, mlink) for your own step-up decisions. email / email_verified are included when the email scope is granted.

Tenancy claims — always present, not scope-gated, because a multi-tenant application cannot function without knowing which organization authenticated:

ClaimMeaningStability
tidThe organization's immutable UUIDPermanent. The only safe key.
tenant_slugHuman-readable slug, e.g. acme-incMutable — display only.

⚠️ Key off tid, never tenant_slug. A slug can be changed by an administrator; anything that stores, authorizes, or routes on it will silently break when that happens. Use tenant_slug for display, and only for display.

Both claims are also returned from /userinfo, so either artifact works as your profile source.

The access token is opaque — do not parse it

Per OAuth 2.0 (RFC 6749 §1.4) the access token is opaque to clients. It is a credential you pass through to the API, not a document you read. Its internal structure is a private contract between Zynth Auth and the resource server, and its claims are not part of this API contract — they may change at any time, without notice, in any release, including patch releases.

If you need the user's identity or their organization, take it from the id_token or /userinfo — both are contract, and both carry tid. Decoding the access token to reach the same data will eventually break, and it will break silently.

OAuth clients (tenant admin)

Register and manage the OAuth/OIDC clients (relying parties) allowed to sign in your organization's users. All endpoints are tenant-scoped and require the tenant:manage permission (owner/admin); also available under Organization → OAuth Clients in the app. A client belongs to exactly one organization and can only authenticate that organization's principals.

  • GET /api/v1/tenants/oauth-clients · GET …/{id} — list / read registered clients (secrets are never returned; has_secret and secret_created_at are).
  • POST /api/v1/tenants/oauth-clients — register a client. Body: name, client_type (confidential | public, immutable after creation), redirect_uris, post_logout_redirect_uris, backchannel_logout_uri, grant_types (default ["authorization_code"]), scopes, enabled. Confidential clients get a show-once client_secret in the 201 response — store it now, it is never retrievable again. Redirect URIs are validated (absolute, no wildcards, no fragments, https except loopback http). Grant rules: refresh_token requires authorization_code; authorization_code requires ≥1 redirect URI; public clients can't use client_credentials.
  • PUT /api/v1/tenants/oauth-clients/{id} — reconfigure (full replace of writable fields; client_type is immutable, grants re-validate against it).
  • POST /api/v1/tenants/oauth-clients/{id}/secret — rotate a confidential client's secret; the new plaintext is returned once (409 for public clients).
  • DELETE /api/v1/tenants/oauth-clients/{id} — unregister; /authorize and /token refuse the client immediately.

OAuth resources — audience-bound tokens (tenant admin)

The RFC 8707 resource registry: the set of APIs this organization may have tokens minted for. Registering an identifier is what authorizes the OP to mint a token whose aud is that identifier rather than the platform audience — so the token verifies only at that API and is rejected by every other resource and by the Zynth API itself, including /userinfo (a per-request UserInfo revocation check does not work with resource-bound tokens; see the trade-off). That is the confused-deputy defence, and it is what makes Zynth Auth usable as the authorization server in front of your own microservices, not only MCP servers.

The registry is admin-curated: a client can name a resource, never introduce one. An unregistered or disabled identifier is refused with invalid_target and never defaulted.

All endpoints require tenant:manage. Writes are audited and raise an immediate oauth-resource-changed detection — changing this registry changes the tenant's token-audience surface, so it carries the same weight as an SSO credential change.

  • GET /api/v1/tenants/oauth-resources — list the organization's registered resources (id, identifier, name, enabled).
  • POST /api/v1/tenants/oauth-resources — register one. Body: identifier, name. The identifier must be an absolute URI with no fragment (RFC 8707 §2) and https — plain http only for loopback hosts (localhost, 127.0.0.1, [::1]), because tokens minted for a plain-http network host would travel in clear. Max 512 chars, 50 resources per organization.
  • PATCH /api/v1/tenants/oauth-resources/{id} — rename, or enabled: false to stop minting for it without deleting the record.
  • DELETE /api/v1/tenants/oauth-resources/{id} — unregister.

Also manageable in the console: Settings → Organization → OAuth Resources — register, disable (minting refused, record kept) and remove, with the same audit + detection posture.

Once registered, clients name it with the resource parameter at /authorize and /token. Verifying such a token at your own service is the MCP authorization guide's procedure — it is not MCP-specific; set audience= to your identifier instead of the platform audience. See also Verifying tokens.

Connected apps (self-service consent)

A user's own OAuth grants — no special permission, your consents are yours. Also under Account → Connected apps in the app.

  • GET /api/v1/oauth/consents — the caller's live connected apps (client name, granted scopes, granted-at).
  • DELETE /api/v1/oauth/consents/{id} — disconnect an app. Revocation is immediate: live grant sessions for that client are killed, a back-channel logout_token is sent, and /token refuses further refreshes for that user from that moment. 204 · 404 if not the caller's consent.

Webhooks

Outbound HTTP notifications for events in your organization. All endpoints live under /api/v1/tenants/webhooks, require tenant:manage, and are refused with 403 while the tenant sits in bootstrap quarantine. Walkthrough, including verification code: Webhooks.

  • GET /api/v1/tenants/webhooks/events — the publishable event catalogue, each with a summary. Only identity-facing events are publishable; Command Center, operator and control-plane events are structurally never delivered off-platform.
  • GET /api/v1/tenants/webhooks — registered endpoints (metadata only — never the secret): URL, subscribed event_types, enabled, disabled_reason, consecutive_failures, secret_rotated_at.
  • POST /api/v1/tenants/webhooks — register one. Body: url, description, event_types. 201 returns the secret once — this is the only response that ever carries it. 400 for an unknown event type (no wildcards; the error points at /events) or a URL the egress policy refuses (https only, no credentials, no IP literals, no loopback/blocked hosts) · 409 past 25 endpoints per organization.
  • PATCH /api/v1/tenants/webhooks/{id} — change description, event_types, enabled. The URL is not editable — a new destination is a new endpoint. Re-enabling also clears the failure streak. 404 unknown.
  • POST /api/v1/tenants/webhooks/{id}/rotate-secret — returns the new secret once. The previous secret keeps verifying until the next rotation, and during the overlap each delivery is signed with both — so you can roll without dropping a delivery. 404.
  • DELETE /api/v1/tenants/webhooks/{id}204 · 404.
  • POST /api/v1/tenants/webhooks/{id}/test — a real, signed zynth.test delivery sent synchronously. Returns {delivered, status_code, error, blocked_by_policy}blocked_by_policy distinguishes "your server said no" from "we refused to call that URL". The probe is never written to the delivery log. 404.
  • GET /api/v1/tenants/webhooks/{id}/deliveries?limit= (default 50, capped at 200) — the delivery log, newest first, with attempt counts, last status code and next_attempt_at. 404 on an unknown endpoint id, like its siblings — an empty list always means "no deliveries yet", never "wrong id".
  • POST /api/v1/tenants/webhooks/{id}/deliveries/{delivery_id}/replay — re-queue one delivery, keeping the same Zynth-Event-Id so a correct consumer de-duplicates it. 404.

Delivery contract. At-least-once and unordered — de-duplicate on Zynth-Event-Id. Each request carries Zynth-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 over "<t>.<raw body>" with your secret; verify against the raw bytes, compare in constant time, and reject a timestamp outside ~5 minutes. During a rotation overlap the header carries two v1= values — accept if either matches. Failures retry with exponential backoff, and an endpoint that fails persistently is auto-disabled with a disabled_reason; a single success resets the streak.

Entitlements

Your organization's resolved plan — the features it grants and the caps it imposes. The app gates its UI on exactly these values, so what a user sees matches what the API allows. Conceptual detail: Plans, entitlements & usage.

GET /api/v1/entitlements

Any authenticated caller. Returns:

{
  "plan_code": "pro",
  "features": ["scim", "sso.oidc", "tokens.dpop", "..."],
  "caps": { "mau": 10000, "agents": 50, "api_calls_monthly": 5000000 },
  "source": "plan",
  "degraded": false,
  "licence": null
}
  • features — granted capability codes. A premium endpoint your plan lacks returns 403 naming the feature. Core IAM (auth, MFA, tokens, sessions, audit, the OpenID Provider) is never gated and never appears here as a requirement.
  • caps — ceilings; null means unlimited. Authentication is never denied by a cap (ADR-0053 §3): sign-in, MFA, sessions and token issuance for existing users always work. Most caps raise a warning and a commercial conversation. Two gate creation and do refuse at the ceiling: api_calls_monthly (API-key requests get 429 with RateLimit-Policy: monthly-quota) and members (new invitations refused; pending invitations count against the seat total). See Plans and entitlements.
  • sourceplan (managed SaaS) or licence (self-hosted install).
  • degradedtrue only on a self-hosted install whose licence is expired beyond grace or unreadable: premium features fall to the free floor while core identity keeps working.
  • licence — self-host only: {status, licensee, expires_at, maintenance_expires_at} where statusvalid · expiring · grace · degraded · invalid. null on managed SaaS.

Billing (tenant self-service)

Subscription and metered usage for your own organization. Requires tenant:manage. Card data never reaches Zynth Auth — purchase approval happens on PayPal-hosted pages.

Dormant when unconfigured. On a deployment without a billing provider these endpoints return 404 (the same posture as dormant SSO/SCIM) — it means "not enabled here", not "error".

GET /api/v1/billing

Current plan, metered usage, and caps. Returns {plan_code, usage, caps, over_caps, has_subscription} where usage carries mau, api_calls_monthly, members, oauth_clients, agents, and over_caps lists any metric past its ceiling (surfaced as a warning — service continues).

POST /api/v1/billing/subscribe

Body: plan_code (a purchasable paid plan). Returns 200 {approval_url} — send the browser there to complete payment with PayPal. 422 if the plan isn't purchasable · 502 if the provider is unreachable.

The plan changes only when the provider confirms activation via webhook — not when the user returns from the approval page.

POST /api/v1/billing/cancel

Requests cancellation at the provider. 202 accepted · 409 if there's no active subscription. The downgrade to Free lands when the provider confirms it.

POST /api/v1/billing/webhook

The provider's event sink (not called by your code). Authenticity is verified against PayPal before anything is read, and fails closed — an unverified payload is rejected (400) and recorded. It is the only writer of plan state on this surface.

Operator plane (platform staff)

Cross-tenant lifecycle for the platform operator — Zynth's own staff. Requires membership of the platform-operator tenant and operator:read / operator:manage; outside that tenant the surface does not exist (403). Not available to customer organizations.

Tenants/api/v1/operator/tenants:

  • GET — every tenant (name, slug, status, plan, isolation).
  • GET /{id} — one tenant with usage counters and resolved entitlements.
  • POST — sales-assisted provisioning. Body: organization_name, owner_email, plan_code, and an optional profile (33.7): agent (name, risk_level, description, max_risk_score, oversight_mode), delegation (permissions, expires_in_days, max_risk_score, oversight_mode), claim_expires_in_days (1–90, default 14), note. Creates the tenant and a passwordless owner, then emails a set-password invite — no operator ever handles the credential. With a profile it also registers the agent without a credential, creates the delegation with the new owner as delegator, and issues a provisioned-mode beta invite; the response's profile carries the claim code once. 409 on a duplicate slug/email or a taken agent name — a deleted organization keeps its slug for all time, and that refusal names it ("taken by a deleted organization — choose another name"); 403 when the plan has no agent seat (the tenant and owner stay); 422 on an unknown permission. The response's invite_email says whether the mail provider accepted the owner's set-password email (sent | failed) — a refused send is the operator's to act on, never a silent success.
  • POST /{id}/resend-invite — re-send the set-password link to a still-passwordless owner. 200 with invite_email (sent | failed); 409 once the owner has set a password (a reset is then their own forgot-password flow); 404 unknown.

Agent claimPOST /api/v1/agents/claim (anonymous, 33.7). Body: claim_code, owner_email. Exchanges a provisioned-mode invite for the pre-registered agent's first credential, exactly once: 200 with agent_id, tenant_id, credential (shown once; it authenticates at /api/v1/agents/token). 403 for every refusal (unknown, expired, revoked, already claimed, wrong door, wrong email) — one wording; 429 from a fail-closed per-IP throttle (AGENT_CLAIM_MAX_REQUESTS_PER_IP).

  • PUT /{id}/plan — assign a plan (Body: plan_code); this is the authority behind the tenant's entitlements. 422 on an unknown plan.
  • POST /{id}/suspend · POST /{id}/reactivate — suspension severs live sessions immediately (the next request from any existing session is refused) and blocks new sign-ins. The platform tenant itself cannot be suspended (409 — lockout guard).
  • DELETE /{id} — soft-delete (data scrubbing remains the separate erasure process). The organization's slug stays reserved for all time — it rides sign-in, OIDC paths and invite links, so a deleted organization's identifier is never re-issued to a new one; provisioning the same name again answers 409 naming the deleted organization; refused for the platform tenant.

Early access/api/v1/operator/early-access (the private-beta waitlist, 33.3):

  • GET — the waitlist, newest first; ?status=pending|invited|declined filters, ?limit= bounds (default 200, max 1000). Every listing is audited as early_access.listed.
  • DELETE /{id}hard-deletes one request and its consent record; answers 200 with an erasure receipt (id, erased_at — never the address); 404 if unknown. This is the erasure path for a waitlist row; the audit event carries the row id, never the address.
  • POST /{id}/invite — convert a pending request into a beta invite bound to its email (Body, all optional: mode bootstrap|provisioned, expires_in_days 1–90, note). 201 with the invite and the code, once; the row moves to invited. 409 if already invited.

Beta invites/api/v1/operator/beta-invites (the private beta's allowlist, 33.5):

  • POST — issue one. Body: email (the tester; the redeeming request must present the same address), mode (bootstrap: the tester's agent opens the organization through agent-driven onboarding; provisioned: the code releases a pre-registered agent's first credential), expires_in_days (1–90, default 14), note. 201 with the invite and the code, shown once — it is never retrievable again.
  • GET — every invite (handle, tester, door, status live|redeemed|revoked|expired, expiry, redemption, the tenant it created); never the code.
  • POST /{id}/revoke — revoke a live invite (200; 404 unknown; 409 already revoked or already redeemed — a redeemed invite has done its work; contain the tenant instead).

Per-tenant configuration/api/v1/operator/tenants/{id}/config. The tenant console serves only tenant-editable settings; this is the other half — the operator reads a tenant's full registry and writes the operator-only ones, including every security-classed setting (structurally never tenant-editable). Same registry validation, same single write path, same append-only history; the event is attributed to the target tenant, so security-config-changed raises against the organization whose posture changed.

  • GET /{id}/config (operator:read) — every setting for that tenant, each with its risk and an editable flag saying whether the tenant can change it themselves.

  • PATCH /{id}/config/{key} (operator:manage) — set one value. Body: value.

    KeyMeaning
    security.api_key_max_lifetime_daysCap on new API-key lifetime (0–3650; 0 = no cap, the shipped posture). A positive value refuses non-expiring and over-long keys, and rotated keys inherit it. Customer-facing: API keys.
    security.agent_max_credential_age_daysMaximum age of an AI agent's credential (0–3650; 0 = no maximum). A positive value raises an agent-credential-stale detection naming the agent and its exact age when it authenticates with an older credential. It never blocks the agent — an unattended agent is surfaced for rotation, not locked out mid-run.

Pricing content/api/v1/operator/pricing (what the public pricing page shows):

  • GET (operator:read) — the full editable pricing document, including unpublished plans.
  • PUT (operator:manage) — replace it. Every plan_code is validated against the entitlements tier catalog (422 on an unknown code), so pricing can never advertise a plan that doesn't exist. Only presentation is stored here; features and caps stay derived — see GET /api/v1/pricing. Changes are live immediately, with no deploy.

Licences/api/v1/operator/licences (self-host bundles):

  • GET — every minted licence's lifecycle record (never the signed bundle).
  • POST — mint. Body: licensee, install_id, plan_code, term_days, optional maintenance_days / features_extra / caps / tenant_id. The signed JWT is returned once in licence_jwt — hand it to the customer immediately; it is never retrievable again. 503 if the signing key isn't provisioned.
  • POST /{id}/revoke — registry-side revocation: refuses renewal and distribution access. An already-issued offline bundle can't be recalled remotely (no phone-home, by design). 409 if already revoked.

Distribution credentials/api/v1/operator/distribution-credentials (what a self-host customer presents to download a release):

  • GET — every issued credential's lifecycle record: label, public prefix, expiry, revocation, last use. Never the secret and never its hash.
  • POST — issue one against a licence. Body: licence_pk, label, optional expires_days (1–1830, default 365 — there is no "never expires"). The plaintext is returned once in credential; only its SHA-256 is stored, so it is never retrievable again. 404 if the licence doesn't exist, 409 if the licence is revoked — issuing under a revoked licence would grant exactly what the revocation withdrew.
  • POST /{id}/revoke — immediate and total: the credential can never authenticate a download again. 409 if already revoked.

Releases/api/v1/operator/releases (the published-release catalog a self-host customer downloads from):

  • GET — the full catalog, including yanked releases.
  • POST — register an already-uploaded bundle. Body: version (strict vX.Y.Z), object_key, checksum_sha256, size_bytes, published_at. Registering is what makes a release downloadable, so uploading first means a half-uploaded object is never handed out. published_at is the commercial date the customer's maintenance term is compared against, not the write time. 409 if that version already exists — published versions are immutable, because a customer who verified it once must get the same bytes again. 422 on a non-release version or a malformed checksum.
  • POST /{version}/yank — withdraw a release. The catalog row stays as history; downloads refuse from that point. 409 if already yanked.

Release downloads (self-host)

How a self-hosted install fetches a signed release bundle. This is the only endpoint that authenticates with a distribution credential rather than a user session — the box downloading has no user logged in. Your operator issues the credential (a zdc_… string, shown once) via the operator plane; you present it as a bearer token.

GET /api/v1/distribution/releases/{version}

Auth: Authorization: Bearer <distribution-credential>. version is a strict vX.Y.Z.

On success, responds 302 with a Location header pointing at a short-lived presigned URL for the bundle in object storage — follow the redirect to download the bytes (e.g. curl -fL). The presigned URL is itself a time-limited credential: it works for a few minutes whether or not you keep the distribution credential, and it is never logged. The response carries Cache-Control: no-store; do not cache or share the redirect.

Responses

  • 302 — redirect to the bundle (entitled).
  • 404any reason the download is refused: the credential is unknown, revoked, or expired; the owning licence is revoked; the release doesn't exist or was yanked; or your maintenance term does not cover that release's publication date. The response is deliberately identical in every case — it never reveals which condition failed, so a stranger cannot probe whether a credential is real. (An operator diagnosing a genuine entitlement question should check the licence's maintenance_expires_at against the release's published_at.)
  • 429 — rate-limited (per credential and per source IP); retry later.
  • 503 — the server has no object storage configured. This is an install-side misconfiguration, not an entitlement problem — tell your operator; it is not a 404 precisely so it can't be mistaken for "no such release".

What "maintenance" buys. You can download every release published while your maintenance term was active, forever — a lapsed term does not remove access to what you were already entitled to, it only stops access to releases published after it lapsed. See Plans, entitlements & usage.

Enterprise SSO & SCIM

Per-tenant enterprise federation and directory provisioning. These surfaces are enabled at the deployment level (they answer 404 while dormant) and configured per tenant in Settings → Organization → Enterprise SSO. The operational detail lives in the guides:

  • OIDC federationPOST /api/v1/auth/sso/start (protocol-agnostic) and …/auth/sso/oidc/*; tenant-admin config at GET/PUT/DELETE /api/v1/tenants/sso/oidc, with POST /api/v1/tenants/sso/oidc/test to prove a connection (discovery, credentials, reachability) before anyone signs in through it. OIDC back-channel logout at …/auth/sso/oidc/{tenant}/backchannel-logout revokes sessions. → Enterprise SSO (OIDC)
  • Completing an SSO sign-inPOST /api/v1/auth/sso/complete redeems the one-time code from either protocol's callback. Returns exactly what password login returns: a 200 token pair, or the MFA challenge (mfa_required: true, mfa_token) when the account has TOTP enrolled — enterprise SSO does not bypass an enrolled second factor.
  • SAML 2.0POST …/auth/sso/saml/start (not tenant-scoped: the tenant travels in the body) plus …/auth/sso/saml/{tenant}/{acs,metadata,slo}; tenant-admin config at GET/PUT/DELETE /api/v1/tenants/sso/saml, with GET /api/v1/tenants/sso/saml/sp (this tenant's SP entity id, ACS and SLO URLs — what you paste into the IdP) and POST /api/v1/tenants/sso/saml/import-metadata (paste the IdP's metadata XML instead of transcribing a certificate by hand). → Enterprise SSO (SAML)
  • SSO-required enforcementGET/PUT /api/v1/tenants/sso/enforcement (tenant:manage). Once required, POST /api/v1/auth/login answers 403 for non-owner members.
  • SCIM 2.0/scim/v2/{Users,Groups,ServiceProviderConfig,ResourceTypes,Schemas} (bearer token per tenant). Admin surface at /api/v1/tenants/scim (tenant:manage): GET/PATCH for the configuration, POST /api/v1/tenants/scim/token to mint or rotate the provisioning token — the plaintext is returned once, alongside the base_url to configure, and unlike webhook secrets rotating immediately invalidates the previous token, so update the IdP in the same sitting — and DELETE /api/v1/tenants/scim/token to revoke it outright (204; 404 if none exists), which stops provisioning without deleting the configuration. Note that a SCIM-managed membership can no longer be edited through the members API — it answers 409. → Directory provisioning (SCIM)

Password policy

NIST 800-63B, length-first: 12–128 characters, and not a well-known common password. No forced character-class rules. Passphrases are encouraged.