API reference
Base path: /api/v1, served from the application origin — https://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 uniform401 Invalid credentials. - Rate limiting. Repeated failed sign-ins are throttled per source IP and per account: once
the threshold is hit,
POST /auth/loginreturns429 Too Many Requests. A successful login clears the account counter. (Magic-link issuance is separately rate-capped.) Registration is rate-capped too:POST /auth/signupandPOST /auth/signup/customershare one per-IP budget and return429with the standard rate headers past it — retry after the window shown inRetry-After. Also throttled, all fail-closed (a budget that cannot be computed refuses rather than waves through): invite redemption —/invites/previewand/invites/acceptdeliberately share one per-IP budget, so preview is not a free guessing oracle; domain join —/auth/signup/domain-checkand/auth/signup/domain, likewise shared; and the agent-bootstrap family —/onboard/bootstrap(per-IP, per-network and a global ceiling),/onboard/polland/onboard/pow(one shared budget), and the human/onboard/verify/*legs; and the early-access waitlist door —POST /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: pastapi_calls_monthlythe key's requests get429carryingRateLimit-Policy: monthly-quota— "you are over plan", not "slow down". - Rate-limit headers (v0.21.0). Every throttled
429— sign-in, the OAuth/authorizeand/tokenendpoints, release downloads, SDK telemetry — carries a computedRetry-After(seconds until one request will be accepted; previously a fixed60) plus the draft IETF trioRateLimit-Limit,RateLimit-Remaining, andRateLimit-Reset, describing the most constrained limit that applies to your request. Well-behaved clients should honourRetry-Afterand may watchRateLimit-Remainingto back off before hitting the wall. Two deliberate exceptions: enumeration-safe endpoints (e.g.POST /auth/forgot-password) return202with 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, a429carries only a fixed shortRetry-After. - Request-body limit. Request bodies are capped at the edge (1 MB); a larger body is
refused with
413before it reaches the API. A malformed or excessively-nested JSON body returns a clean400. - SCIM endpoints use
application/scim+jsonand 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), andcustomer(the CIAM end-user baseline — self-service identity only; it is never grantable by invitation).GET /api/v1/authz/catalogreturns 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:writeand notidentity:read. Both are in thememberandcustomerbaselines, 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 carryidentity:self:write. - The tenant owner always has every permission. A regular member starts with only the
memberbaseline 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 (
/rolesin the app — People / Roles / Policies), which gates its controls offGET /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, optionalexpires_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, both403: 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 torotate.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 areason. Takes effect on the key's next request.GET /usage— API-key request volume for a window, and the ceiling it is measured against. Optionalperiodquery param (defaults to the current one). Returns{period, total, quota, rows}—quotaisnullon an unlimited plan, androwsbreaks the total down per key. This is the counter behind the monthly-quota429.
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
| Field | Type | Notes |
|---|---|---|
email | string | Valid email; must be unique. |
password | string | Must satisfy the password policy. |
organization_name | string | 1–255 chars; the slug is derived from it. |
Responses — 201 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.
Responses — 201 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.
Responses — 401 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 atPOST /api/v1/mfa/challengewith 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 true — 422 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/bootstrap → 201. 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).
Magic-link sign-in
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. provider ∈ google | 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(defaultmember; 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).422unknown role ·409already a member ·403when the plan'smembersseat 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.422unknown role ·404· and a409family 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.409for 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/preview— Body:token. What the acceptance page shows.POST /api/v1/invites/accept— redeem as a new user. Body:token,password, optionaluse_cookie. Returns a token pair — the invitee lands signed in.400weak password ·409an account with that email already exists (sign in, then useaccept-existing).POST /api/v1/invites/accept-existing— authenticated; 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 — otherwise403.200{tenant_slug, role}·409already a member.- Across all three:
410 Gonefor an expired invitation (ask for a new one — deliberately distinct from404, 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.201returns the TXT record to publish.422for a malformed domain or a public email provider (gmail.com and friends can never be claimed) ·409another 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-check— Body:email. What the signup page may offer.POST /api/v1/auth/signup/domain— Body:email,password, optionaluse_cookie. Creates the account plus a baseline membership and returns a token pair.400weak password ·409the 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 itskey,summary,type(boolean|integer|string),default, currentvalue,overridden,risk, and anybounds/allowedvalues. This is what the console renders.PATCH /api/v1/tenants/config/{key}— set one value. Body:value.404for a key the registry doesn't know (unknown means refuse, never create),400when 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:
| Key | Meaning |
|---|---|
auth.magic_link_enabled | Allow members to sign in with emailed magic links. Turning it off also stops already-issued links from redeeming. |
auth.social_login_enabled | Allow "Continue with Google / GitHub" for this organization. |
auth.ciam_signup_enabled | Allow customers to self-register (customer self-signup). Default false. |
members.domain_join_enabled | Allow 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_principal | The 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_until | The 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/settingsstill servesciam_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).
Responses — 200 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.
Responses — 200 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.
Responses — 204 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,setupreturns503.
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 bystatus/type).POST /api/v1/gdpr/admin/requests/{id}/verify— confirm the requester's identity.POST /api/v1/gdpr/admin/requests/{id}/reject— Body: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 with409if 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.
Consent (GDPR Art 7 / APAC)
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 byuser_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 bystatus) ·POST /api/v1/isms/risks— Body:title,likelihood(1–5),impact(1–5), optionaldescription/category/owner_id.GET /api/v1/isms/risks/{id}·PATCH /api/v1/isms/risks/{id}(partial update;status∈identified·assessed·treating·accepted·closed).POST /api/v1/isms/risks/{id}/treatments— Body:option(mitigate·accept·avoid·transfer),description. Recording a treatment moves an untreated risk totreating.
Security policies (ISO 27001 A.5.1):
GET /api/v1/isms/policies(any member) ·POST /api/v1/isms/policies(owner) — Body:title, optionalversion(default1.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 byclassification/status) ·POST /api/v1/isms/assets— Body:name, optionalasset_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), optionalrecord_id/patient_ref/reason,is_emergency(break-glass access is flagged as awarnevent).GET /api/v1/hipaa/phi-access(owner / privacy officer; filter byrecord_id/is_emergency).
Breach notifications (Subpart D) — hipaa:read / hipaa:admin:
GET /api/v1/hipaa/breaches(filter bystatus) ·POST /api/v1/hipaa/breaches— Body:title,individuals_affected, optionaldescription/breach_type/discovered_at. The 60-daynotification_deadlineand theis_large_breachflag (≥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 byresource_type/compliant) ·POST /api/v1/hipaa/encryption— Body:resource_type,resource_name,encryption_at_rest,encryption_in_transit, optionalalgorithm/notes/next_verification_date.is_compliantis 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 & catalog — authz: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 ordomain:*/*: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).
Assignments — authz:assignments:read / authz:assignments:manage:
GET /api/v1/authz/assignments(filter byuser_id) — active assignments, each with its role label andexpires_at. Passinclude_revoked=trueto include revoked grants, each carryingrevoked_at,revoked_byandrevoke_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 ofrole_code(system) orrole_id(custom), optionalexpires_at(ISO-8601, must be in the future → JIT).DELETE /api/v1/authz/assignments/{id}— revoke. Optionalreasonquery 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 asourcesbreakdown (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 policies — authz:policies:read / authz:policies:manage:
GET /api/v1/authz/policies·POST /api/v1/authz/policies— Body: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 bydelegate_id) ·POST /api/v1/authz/delegations— Body: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 reportscascaded, 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/resources— Body:oauth_resource_id,namespace(2–40 chars), optionaldescription.201·422if the OAuth resource isn't registered yet — register the audience first ·409namespace collision, or that resource is already bound.POST /api/v1/authz/resources/{resource_id}/permissions— mint one. Body:action, optionaldescription,read_only(defaultfalse),risk(1–15).201{permission_id, code, risk}·409it already exists.DELETE /api/v1/authz/resources/{resource_id}·DELETE /api/v1/authz/resources/permissions/{permission_id}—204, but409while 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_idis optional and always resolved to the caller — naming anyone else is403, which points you at the service lane.POST /api/v1/authz/service-check— for a resource server asking about its users. Authenticates with an OPclient_credentialstoken (Authorization: Bearer …) that carries theauthz:checkscope; the tenant comes from the token, not a header.401without a bearer token or with anything that isn't a client-credentials token ·403without theauthz:checkscope ·422if any check omitsprincipal_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 flagAUTHZ_GOVERNANCE_ENABLED. With either missing every route answers404, not403— on every HTTP method, before authentication is considered, byte-identical to a path that was never registered (ADR-0084). A404here 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 ofrole_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.422on 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), optionalreason,sod_override(defaultfalse). Approval mints a time-bounded role assignment.403you cannot decide your own request ·409when 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 a404, not a403(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 shippedtemplates(operate-vs-attest,audit-vs-admin).POST— Body:name, optionaldescription,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
409naming the rule. The only way past it issod_override: trueon 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(defaulttrue), optionalreviewer_permission(defaults toauthz:assignments:manage). Returns the campaign withitem_count— how many grants were snapshotted.422on 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, asitem_count).POST /{id}/items/{item_id}/decide— attest or revoke. Body:attest(bool), optionalreason. Authorized by the campaign's ownreviewer_permission(or ownership), not a fixed one.403you cannot review your own access ·409if the campaign is closed or the item already decided.POST /{id}/close— close it, returning the decision counts. Withauto_revoke, grants nobody attested are revoked at close: the default outcome of an unanswered review is losing the access, not keeping it.409if 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, optionaldescription,max_risk_score,oversight_mode,confidence_floor. The response includes thecredentialsecret once — store it now; it is never retrievable again.409when 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) ·422for 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-switch— Body:engaged(bool), optionalreason(≤ 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_reasonandtokens_revoked_ataccompanykill_switch_engagedonGET /api/v1/agentsandGET /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-nullkill_switch_reasonmeans "has been stopped at some point", not "is stopped now". Onlykill_switch_engagedsays that.tokens_revoked_atis 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") andlive_until. Agents are sessionless — nothing is written when a token is used, andlast_authenticated_atis overwritten on every mint — so that timestamp alone answers the wrong question in both directions.livenessanswers 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 thetokens_revoked_atepoch each flip it to"idle"immediately, in the same breath as the refusal itself."never"means the credential has never been used;live_untilis when the window closes (or closed), and isnullonly in that case. It is a statement about the token, not a heartbeat: for "when did this agent last do something", readlast_seenfromGET /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:manageis refused403on 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 Acceptedwithapproval_idand no credential; a different person holdingagents:manageapproves it atPOST /api/v1/approvals/{approval_id}/decide, and you then repeat the original call, which returns200and (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 answer503rather 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_levelcounts 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 returns404. Repeating theDELETEon an already-retired agent is404.
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,
PATCHit tostatus: "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_ENABLEDis 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 explicitjwks_uriis required:Profile Signature algs Max token validity Explicit jwks_urigithub-actionsRS256 15 min no — public discovery kubernetesRS256, ES256 1 h yes — cluster discovery is not reachable from outside awsRS256 1 h no gcpRS256 1 h no spiffeRS256, ES256 15 min yes — 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/spiffeissuer without ajwks_uriis a named400at 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'sissmust equal it exactly),profile, optionaljwks_uri. Max 10 issuers per organization. -
PATCH /…/issuers/{id}—enabled: falsestops all exchange for the issuer without deleting its configuration.DELETE /…/issuers/{id}— trust ends whole; bindings go with it. -
POST /…/issuers/{id}/bindings— Body: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 is409— 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.
permissionsmust be a subset of the agent's own. Ask for anything the agent does not currently hold and the whole mint is refused with403—task 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. budgetcounts 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 is403— 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_ENABLEDis off (the default), every route below returns404before 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 carriesresource— 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, andsteps[](kind,target,would,detail, plusgrants[]— the exact permission codes a role would carry or an invitation would confer). It isnullon 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, plusapprovals_recorded(multi-approver asks declarerequired_approvals). Needsagents:approvals:read— except 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), optionalreason(≤2000),break_glass(defaultfalse). Refusals are the design:403agents cannot decide approvals (the principal under judgement never judges),403you cannot decide your own request,403break-glass is owner-only, and403when you lack the required approver permission — which the message names.409if 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 server — POST /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), optionaldescription,max_calls_per_minute(default 30),max_calls_per_day(default 500).GET·PATCH·DELETE /api/v1/mcp/servers/{server_id}— read, reconfigure (enabled: falsestops 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; atools/listreply 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 a403naming the reason.404unknown server ·502when 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_ENABLEDis off (the default), every route below returns404— 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_.:-]+), optionalcontextobject. Returns the elevation plusmethods— the factors this user has actually enrolled.400 no_factorwhen they have none (a password-only user cannot step up). Floats incontextare 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, anddisplay— 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 a400listing 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":…}.401on a bad code or assertion, which burns an attempt; past the cap the ask is dead, not paused. For SCA,403 insufficient_factorswhen 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,contextexactly 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 coarsescoreandbandwith every factor that produced them — account age, session age, factor strength, open detections against the user and the source IP — plusunavailable(factors not yet computed, disclosed rather than omitted) anddegraded(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+severitynarrow together). A non-platform caller naming another organization'stenant_idgets403.actor_idis the per-principal drill-down — "what has this agent been doing". It matchesactor.idexactly, 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 a422.actor_typeis one ofhuman·agent·service·system. Anything else is a422naming the field, rather than a silently empty page.- Both narrow within your own scope: they never widen it, so an
actor_idthat also acts in another organization still returns only your events for it.
GET /api/v1/command/events/by-actor— event volume per principal, over the whole window. Parameters:actor_type(the same four-value vocabulary; defaultagent) anddays(1–90, default 7). Returns one entry per principal —total,by_outcome(every outcome the window contains),elevated(events at or abovewarn),first_seen/last_seen— plustotal/shown/hidden, because the roster is bounded and tells you what it cut. Ordered by volume, ties broken onactor_id.- Not derivable from
GET /command/events. That endpoint is capped atlimit, so counting its rows answers "…among the most recent N" while looking exactly like a total. This one is aGROUP BYover the window. - Attribution is by the envelope's actor, never by the action name.
content.reversal_recordedis 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.
- Not derivable from
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: keepaliveso proxies don't reap the connection, and frames carry noid:, so there is no Last-Event-ID resume — reconnect and re-page if you need the gap. It authenticates from theAuthorizationheader like every other endpoint, which means a browser's bareEventSourcecannot 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 itscompliance_tagsand itsentry_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_seqnames the first entry that doesn't match. This is the endpoint that answers "has anything been altered?", so it is deliberately narrow:command:readplus platform-operator membership (403otherwise), and it verifies globally rather than per tenant.
Detections & findings
GET /api/v1/command/detections— filtersseverity,rule_id,actor_id,limit(default 100, max 1000); each row is a deduplicated group withcount,first_seen/last_seenand status.actor_idis "what was raised about this principal": pass the bare id and the server matches every group-key spelling that names an actor (actor:<id>andagent:<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, defaultacknowledged), optionalnote.404unknown.GET /api/v1/command/findings— analyst-style findings over grouped detections. Filtersseverity,source(defaultanalyst),limit(default 50, max 500). Read-only.
Incidents
POST /api/v1/command/incidents(command:operate) — Body:title,severity, optionaldescription,detection_ids(≤100),assignee.201;422naming any detection id that is malformed or not visible to you.GET /api/v1/command/incidents— filtersstatus,severity,limit(default 100, max 1000).PATCH /api/v1/command/incidents/{id}(command:operate) — Body: any ofstatus(open·investigating·contained·resolved·closed),assignee,containment,remediation,root_cause.404unknown.GET /api/v1/command/incidents/summary?days=(1–365, default 90) — counts by status and severity, plus anunresolvedblock 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=…—frameworkis required (422listing the known set). Optionaldays(1–3650, default 90) andexport—export=trueraises the per-control sample cap from 3 to the full set, for handing an auditor the actual entries. Returns readiness, thegapslist, 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 overallposture.
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_ENABLEDis off (the default) the engine records what it would have done and executes nothing — the endpoints below all serve, and the ledger only ever containsshadowrows.
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}.modeis one ofdry_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-sentenceeffect, itsrule_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, andunattended(executed with no human in the loop).viewer_scopeistenantorplatform— 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), optionalreason(≤1000 chars — optional on approval, and worth writing: it is the oversight record an auditor reads). Returns{decision, executed, outcome, reason}.409if the decision already has a verdict or is not awaiting one;403if 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_modecounts, execution latency (mean + p95 over 30 days, withunattended_30d), per-class cap utilisation (rate_used/rate_cap,spread_used/spread_cap), andpending— count, how many are still actionable, andoldest_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 confidencebarand yourlower_boundagainst it,approvals/total,span_daysvsmin_days,reversals_recent, thecatalogueversion the evidence is scoped to, every unmet criterion named, andapprovals_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_principalis 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,8or24), optionalreason. The stop is stored as an expiry instant and auto-re-arms; the read returns{engaged, scope, until, indefinite, reason, offered_hours}wherescopeisglobal(the deployment gate) ortenant. Omittinghoursmeans indefinite, which is400without areason— 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 settings — autonomy.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/drillalso 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 flaggedcurrentso a user can tell which one they are reading this on.DELETE /{sid}— sign one device out.204·404for 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.
Activity — GET /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·404for 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}.editableisfalsewhen your organization provisions you through SCIM — render the form read-only rather than letting an edit fail.PATCH— Body:name,given_name,family_name.emailandpictureare not editable here (they are sourced from your sign-in identity).409when the profile is identity-provider-managed — edit it in the directory ·422if 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.
Cookie transport (browsers)
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-replaydetection — it is treated as theft, not a soft error.
The hosted login & consent endpoints
/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/complete— Body:request_id. The user said yes: records the consent and returns the redirect carryingcode+state.POST /api/v1/oauth/authorize/reject— Body:request_id. The user said no: returns the redirect carryingerror=access_denied, so the RP is told rather than left hanging.POST /api/v1/oauth/logout/complete— Body:request_id. Confirms a parked RP-initiated logout: ends the session, fans out the back-channellogout_tokens, and returns thepost_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_code—code,redirect_uri(exact, re-checked),code_verifier(PKCE re-verified),client_id. Returnsaccess_token,id_token(whenopenidis granted), andrefresh_token(when the client has that grant).refresh_token—refresh_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);subis theclient_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:
| Claim | Released |
|---|---|
sub | always |
tid, tenant_slug | always (see tenancy claims — tid is the key, the slug is display-only) |
email, email_verified | with the email scope |
name, given_name, family_name, picture, updated_at | with the profile scope (omitted when not stored) |
On the
profilescope. OIDC Core §5.4 definesprofileas 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_supportedin 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:
| Claim | Meaning | Stability |
|---|---|---|
tid | The organization's immutable UUID | Permanent. The only safe key. |
tenant_slug | Human-readable slug, e.g. acme-inc | Mutable — display only. |
⚠️ Key off
tid, nevertenant_slug. A slug can be changed by an administrator; anything that stores, authorizes, or routes on it will silently break when that happens. Usetenant_slugfor 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_secretandsecret_created_atare).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-onceclient_secretin the201response — store it now, it is never retrievable again. Redirect URIs are validated (absolute, no wildcards, no fragments,httpsexcept loopbackhttp). Grant rules:refresh_tokenrequiresauthorization_code;authorization_coderequires ≥1 redirect URI; public clients can't useclient_credentials.PUT /api/v1/tenants/oauth-clients/{id}— reconfigure (full replace of writable fields;client_typeis 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 (409for public clients).DELETE /api/v1/tenants/oauth-clients/{id}— unregister;/authorizeand/tokenrefuse 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) andhttps— plainhttponly 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, orenabled: falseto 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-channellogout_tokenis sent, and/tokenrefuses further refreshes for that user from that moment.204·404if 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, subscribedevent_types,enabled,disabled_reason,consecutive_failures,secret_rotated_at.POST /api/v1/tenants/webhooks— register one. Body:url,description,event_types.201returns thesecretonce — this is the only response that ever carries it.400for 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) ·409past 25 endpoints per organization.PATCH /api/v1/tenants/webhooks/{id}— changedescription,event_types,enabled. The URL is not editable — a new destination is a new endpoint. Re-enabling also clears the failure streak.404unknown.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, signedzynth.testdelivery sent synchronously. Returns{delivered, status_code, error, blocked_by_policy}—blocked_by_policydistinguishes "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 andnext_attempt_at.404on 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 sameZynth-Event-Idso 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 returns403naming the feature. Core IAM (auth, MFA, tokens, sessions, audit, the OpenID Provider) is never gated and never appears here as a requirement.caps— ceilings;nullmeans 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 get429withRateLimit-Policy: monthly-quota) andmembers(new invitations refused; pending invitations count against the seat total). See Plans and entitlements.source—plan(managed SaaS) orlicence(self-hosted install).degraded—trueonly 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}wherestatus∈valid·expiring·grace·degraded·invalid.nullon 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 optionalprofile(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 aprovisioned-mode beta invite; the response'sprofilecarries the claim code once.409on 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");403when the plan has no agent seat (the tenant and owner stay);422on an unknown permission. The response'sinvite_emailsays 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.200withinvite_email(sent|failed);409once the owner has set a password (a reset is then their own forgot-password flow);404unknown.
Agent claim — POST /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.422on 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 answers409naming 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|declinedfilters,?limit=bounds (default 200, max 1000). Every listing is audited asearly_access.listed.DELETE /{id}— hard-deletes one request and its consent record; answers200with an erasure receipt (id,erased_at— never the address);404if 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:modebootstrap|provisioned,expires_in_days1–90,note).201with the invite and the code, once; the row moves toinvited.409if 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.201with the invite and the code, shown once — it is never retrievable again.GET— every invite (handle, tester, door, statuslive|redeemed|revoked|expired, expiry, redemption, the tenant it created); never the code.POST /{id}/revoke— revoke a live invite (200;404unknown;409already 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 itsriskand aneditableflag saying whether the tenant can change it themselves. -
PATCH /{id}/config/{key}(operator:manage) — set one value. Body:value.Key Meaning 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-staledetection 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. Everyplan_codeis validated against the entitlements tier catalog (422on an unknown code), so pricing can never advertise a plan that doesn't exist. Only presentation is stored here; features and caps stay derived — seeGET /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, optionalmaintenance_days/features_extra/caps/tenant_id. The signed JWT is returned once inlicence_jwt— hand it to the customer immediately; it is never retrievable again.503if 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).409if 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, optionalexpires_days(1–1830, default 365 — there is no "never expires"). The plaintext is returned once incredential; only its SHA-256 is stored, so it is never retrievable again.404if the licence doesn't exist,409if 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.409if 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(strictvX.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_atis the commercial date the customer's maintenance term is compared against, not the write time.409if that version already exists — published versions are immutable, because a customer who verified it once must get the same bytes again.422on 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.409if 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).404— any 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'smaintenance_expires_atagainst the release'spublished_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 a404precisely 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 federation —
POST /api/v1/auth/sso/start(protocol-agnostic) and…/auth/sso/oidc/*; tenant-admin config atGET/PUT/DELETE /api/v1/tenants/sso/oidc, withPOST /api/v1/tenants/sso/oidc/testto prove a connection (discovery, credentials, reachability) before anyone signs in through it. OIDC back-channel logout at…/auth/sso/oidc/{tenant}/backchannel-logoutrevokes sessions. → Enterprise SSO (OIDC) - Completing an SSO sign-in —
POST /api/v1/auth/sso/completeredeems the one-time code from either protocol's callback. Returns exactly what password login returns: a200token 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.0 —
POST …/auth/sso/saml/start(not tenant-scoped: the tenant travels in the body) plus…/auth/sso/saml/{tenant}/{acs,metadata,slo}; tenant-admin config atGET/PUT/DELETE /api/v1/tenants/sso/saml, withGET /api/v1/tenants/sso/saml/sp(this tenant's SP entity id, ACS and SLO URLs — what you paste into the IdP) andPOST /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 enforcement —
GET/PUT /api/v1/tenants/sso/enforcement(tenant:manage). Once required,POST /api/v1/auth/loginanswers403for 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/PATCHfor the configuration,POST /api/v1/tenants/scim/tokento mint or rotate the provisioning token — the plaintext is returned once, alongside thebase_urlto configure, and unlike webhook secrets rotating immediately invalidates the previous token, so update the IdP in the same sitting — andDELETE /api/v1/tenants/scim/tokento revoke it outright (204;404if 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 answers409. → 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.