Private betaRequest early access →

Sign in

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 curated reference is the contract — and there is deliberately no machine-readable spec to fetch. GET /openapi.json and /docs are not served at the application origin: /openapi.json answers 404 and /docs answers a 301 to docs.zynthmedia.com. Both are decisions, not outages, and neither is a beta limitation that will lift. A generated document would publish the surface as it happens to be — including routes no customer can call — and nobody would be reviewing that; this page is curated so that what you can read is what you are meant to call.

The consequence for you: generate your client from this page, not from a spec download, and treat a 404 on /openapi.json as the documented answer. The page is hand-maintained and not exhaustive: every endpoint family is 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, and tell us, because a gap here is a defect in the contract.

Conventions, errors & limits

  • Errors are generic by design (no account/organization/tenant enumeration): most failures return {"detail": "…"} with a stable HTTP status. Authentication failures are a uniform 401 Invalid credentials.
  • Rate limiting. Repeated failed sign-ins are throttled per source IP and per account: once the threshold is hit, POST /auth/login returns 429 Too Many Requests. A successful login clears the account counter. (Magic-link issuance is separately rate-capped.) Registration is rate-capped too: POST /auth/signup and POST /auth/signup/customer share one per-IP budget and return 429 with the standard rate headers past it — retry after the window shown in Retry-After; redeeming the emailed link (/auth/signup/complete and /auth/signup/customer/complete) has its own per-IP floor, also fail-closed. Also throttled, all fail-closed (a budget that cannot be computed refuses rather than waves through): invite redemption — /invites/preview and /invites/accept deliberately share one per-IP budget, so preview is not a free guessing oracle; domain join — /auth/signup/domain-check and /auth/signup/domain, likewise shared; and the agent-bootstrap family — /onboard/bootstrap (per-IP, per-network and a global ceiling), /onboard/poll and /onboard/pow (one shared budget), and the human /onboard/verify/* legs; and the early-access waitlist 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: past api_calls_monthly the key's requests get 429 carrying RateLimit-Policy: monthly-quota — "you are over plan", not "slow down".
  • Rate-limit headers (v0.21.0). Every throttled 429 — sign-in, the OAuth /authorize and /token endpoints, release downloads, SDK telemetry — carries a computed Retry-After (seconds until one request will be accepted; previously a fixed 60) plus the draft IETF trio RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset, describing the most constrained limit that applies to your request. Well-behaved clients should honour Retry-After and may watch RateLimit-Remaining to back off before hitting the wall. Two deliberate exceptions: enumeration-safe endpoints (e.g. POST /auth/forgot-password) return 202 with no rate headers — exposing the cap would reveal whether an address is being throttled — and in the rare degraded state where limits cannot be computed, a 429 carries only a fixed short Retry-After.
  • An audited action does not complete unrecorded. For actions that change who can do what — grants, token issuance, revocations, organization settings, and the like — the audit record is written through the request's own database session, never through a queue that can be down. If the record cannot be written the request fails with 503 ({"detail": "The audit record for this request could not be written — retry"}) and a Retry-After: 2. Where the record joins the change's own transaction (the common case) the two commit or roll back together, so a 503 means nothing changed; where a change commits before its record is written, a 503 can follow a change that was applied, so read the resource before repeating a request that is not idempotent. Where an action has an effect that cannot be taken back (a call forwarded to your tool server, a subscription cancelled at the provider, an erasure queued), its record is written and committed before the effect, so a 503 there means the effect did not happen and the retry is safe; a record that can only be written afterwards never turns into a 503 for an effect that already ran. A 503 here means "not now", never "you did something wrong".
  • Request-body limit. Request bodies are capped at the edge (1 MB); a larger body is refused with 413 before it reaches the API. A malformed or excessively-nested JSON body returns a clean 400.
  • SCIM endpoints use application/scim+json and return RFC 7644 error documents (not the {"detail": …} shape) — see Directory provisioning.

The API inventory and the deprecation policy

The platform publishes a live inventory of its own API: every mounted route with the permission that guards it, the plan feature it belongs to, whether it is dormant on this deployment, and the actions it records — derived from the running application, never from a hand-kept list, so it cannot lag what is served. An operator reads it at GET /api/v1/command/api-inventory (command:read); the OpenAPI document at /openapi.json is the schema source of truth, and this reference is checked against it on every release.

A route is never removed or changed in an incompatible way without a deprecation period of at least one minor release: the deprecated route answers with a Deprecation header naming the replacement and the release that removes it, the change is listed in the release notes' "narrowed contracts" section, and the inventory marks it. A route you call that starts returning Deprecation has a successor you can move to before it goes.

Authorization

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

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

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

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

GET /api/v1/me/permissions

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

API keys

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

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

  • POST — mint a key: label, scopes, optional expires_in_days (default 365; null = explicitly non-expiring). The plaintext appears once, in this response. Store it; it is never retrievable again (we keep only a hash). Two bounds, both 403: the requested scopes must be a subset of the caller's own permissions (owners hold everything), and an AI agent principal may not mint a key at all — a key carries none of an agent's governance, so minting one would step outside it. Both apply to rotate. A third bound is 400, for everyone including owners: a key may carry a read-only permission or one of five named writes (agents:kill for engaging an agent's emergency stop only, hipaa:phi:log, identity:self:write, payments:verify, autonomy:disable) and nothing else; the response names the codes refused. A key created earlier keeps its reads and those writes and loses the rest at authentication.
  • GET — list keys: display handle (zyn_live_abcd…wxyz), scopes (and inactive_scopes, the ones a key still lists but no longer confers), expiry, last_used_at (coarse), revocation state. Never secrets.
  • POST /{id}/rotate — zero-downtime rotation: issues a successor with the same scopes; the old key keeps working until you revoke it. Migrate, then revoke.
  • POST /{id}/revoke — with a reason. Takes effect on the key's next request.
  • GET /usage — API-key request volume for a window, and the ceiling it is measured against. Optional period query param (defaults to the current one). Returns {period, total, quota, rows} — quota is null on an unlimited plan, and rows breaks the total down per key. This is the counter behind the monthly-quota 429.

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

POST /api/v1/auth/signup

Ask to create an organization. The mailbox is proved first: this call creates nothing and sends no password; it parks a request and mails the address a one-time link. Finish with POST /auth/signup/complete.

Body

FieldTypeNotes
emailstringValid email.
organization_namestring1–255 chars, no control characters; the slug is derived from it.

Responses — 202 {"status": "check_your_email"} for every address: a new one, one that already has an account, one that already asked a moment ago. An address that already has an account is mailed a notice that someone tried to sign up with it (and nothing is created); a repeat inside the resend cooldown is mailed nothing. The call therefore cannot be used to find out who has an account, and a taken organization name is not revealed either (it is checked when the link is used). 403 while public sign-up is closed · 429 past the per-network budget · 503 when the platform is holding its ceiling of waiting requests, whatever the address.

POST /api/v1/auth/signup/complete

Redeem the emailed link: choose the password, create the user (email already verified), the organization and the owner membership, and sign in. The link's secret is in the URL fragment (…/signup#token=…), so it is never sent in a request line or an access log; the page that reads it sends it here in the body.

Body: token, password (the password policy), optional organization_name (to use a different name than was asked for), optional use_cookie.

Responses — 201 token pair · 400 weak password (the link is not spent; try again) or an invalid, used or expired link (one answer for all three) · 409 organization name already taken (the link is not spent; send another organization_name) · 403 while public sign-up is closed · 429 past the per-network budget. A link works once, and expires after 24 hours.

POST /api/v1/auth/signup/customer

Ask to join an existing organization as a customer (CIAM end-user). Like the workforce call it creates nothing and answers 202 {"status": "check_your_email"} for every address and every organization (an unknown organization, one that has not enabled customer signup — a tenant setting, see tenant settings — and one that has all answer alike, so neither accounts nor organizations can be enumerated; the mail is only sent when the sign-up can be completed). Finish with POST /auth/signup/customer/complete.

Body: email, tenant_slug.

Responses — 202 · 429 past the per-network budget · 503 at the platform's ceiling.

POST /api/v1/auth/signup/customer/complete

Redeem a customer link: choose the password and register into the organization the link was issued for (email already verified). The same password policy applies as for workforce users.

Body: token, password, optional use_cookie.

Responses — 201 token pair (a session starts immediately) · 400 weak password (the link is not spent) or an invalid, used or expired link · 429.

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 at POST /api/v1/mfa/challenge with a code. See MFA.

Passkeys (WebAuthn)

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

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

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

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

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

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

Public, no principal: the form on the marketing site's early-access page. Body: email, full_name, role, organization; app_name, app_url, app_description, stage (idea|building|production), platforms (any of web|mobile|api|internal); integrations (at least one of agents_mcp|customer_oidc|workforce_sso|api_keys), test_goals; expected_users (lt_100|100_1k|1k_10k|gt_10k), expected_agents (0|1_5|6_50|gt_50), timeline (this_month|next_quarter|later); consent and acknowledgement (both must be 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 a platform-staff function.

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 (about 64 KiB at most — the bound is 65,536 characters of the parsed manifest, not bytes on the wire — 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 Gone expired / 404 unknown device_code / 429 slow_down (poll at the stated interval), exactly once approved — the one poll that carries the agent's own credential — and consumed on every later poll once that one release has happened. A polling loop must terminate on approved (or consumed/denied/410/404), never retry approved.

This is bootstrap-mode enrollment, and its approved shape is nested — an agent object alongside status and interval, distinct from the flat claim-mode response:

JSON
{
  "status": "approved",
  "interval": 5,
  "agent": {
    "agent_id": "…",
    "name": "…",
    "tenant_id": "…",
    "credential": "agt_…"
  }
}

tenant_id rides inside agent. The credential is the agent's long-lived secret, exchanged at POST /api/v1/agents/token for a short-lived bearer access token — it is not itself a token, so there is deliberately no token_type on it. Contrast the flat provisioned/claim shape from claim-mode enrollment.

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/agents/claim — provisioned-mode agent claim

Anonymous — the counterpart to bootstrap. When the platform pre-registers your agent (a provisioned-mode beta invite), this exchanges the one-time claim code for that agent's first credential. Body: claim_code, owner_email.

This is the provisioned/claim enrollment mode, and its 200 response is flat — deliberately distinct from the bootstrap poll's nested agent shape:

JSON
{ "agent_id": "…", "tenant_id": "…", "credential": "…", "token_type": "bearer" }

Read the two side by side before you write a parser: claim-mode returns these four fields at the top level and carries token_type; bootstrap-mode returns them inside an agent object and carries no token_type. A client that assumes one shape for both enrollment modes breaks on the other. The credential is shown once and authenticates at /api/v1/agents/token, where it is exchanged for a short-lived bearer access 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). Redeeming it end-to-end is joining the private beta.

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

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

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

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

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

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

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

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

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

POST /api/v1/auth/magic-link

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

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

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

Social login (Google · GitHub)

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

GET /api/v1/auth/social/providers

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

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

Public. 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 awaiting redemption.
  • POST /api/v1/members/invites — invite by email. Body: email, role (default member; a system-role code). 201 {invite_id, email, role, expires_at, reissued} — re-inviting a still-pending address re-issues the same invitation rather than stacking a second (reissued: true). 422 unknown role · 409 already a member · 403 when the plan's members seat cap is reached — the message names the limit, and pending invitations count against the total (caps).
  • DELETE /api/v1/members/invites/{invite_id} — revoke a pending invitation. 204 · 404.
  • PATCH /api/v1/members/{membership_id} — change a member's role. Body: role. 422 unknown role · 404 · and a 409 family that is the point of the endpoint: owner memberships change only via ownership transfer; a membership managed by your identity provider (SCIM) is edited in the directory, not here; and a change that would breach a separation-of-duties rule is blocked naming the rule.
  • POST /api/v1/members/{user_id}/mfa/reset — clear a member's authenticator, sign them out everywhere and email them; POST /api/v1/members/{user_id}/passkeys/reset — the same for their passkeys (200 {passkeys_removed}). Both are human-only (403 for an agent or an API key), refused with 409 on yourself and on an owner unless you are one, and tenant-scoped (404). Both are also 409 for a person who belongs to another organization where they can still sign in (their factor and sessions are theirs, not this organization's; a suspended seat or a deleted organization does not count). The passkey reset is also 409 for a member with no password and for a member with no passkeys. See authentication methods.
  • DELETE /api/v1/members/{membership_id} — remove a member. 204, and immediate: the removal and the user's session revocation (CAE) commit in one transaction, so membership dying and access dying are the same event. 409 for the sole owner (transfer ownership first) and for a SCIM-managed membership · 404.

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

  • POST /api/v1/invites/preview — Body: token. What the acceptance page shows.
  • POST /api/v1/invites/accept — redeem as a new user. Body: token, password, optional use_cookie. Returns a token pair — the invitee lands signed in. 400 weak password · 409 an account with that email already exists (sign in, then use accept-existing).
  • POST /api/v1/invites/accept-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 — otherwise 403. 200 {tenant_slug, role} · 409 already a member.
  • Across all three: 410 Gone for an expired invitation (ask for a new one — deliberately distinct from 404, which means invalid, revoked, or already redeemed).

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

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

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

  • POST /api/v1/auth/signup/domain-check — Body: email. What the signup page may offer.
  • POST /api/v1/auth/signup/domain — Body: email. The mailbox is proved first: this call creates nothing and takes no password. It answers 202 {"status": "check_your_email"} for every address: one on a domain an organization has verified and opened to auto-join, one on any other domain, one that already has an account. Only the first is mailed a one-time link (an existing account's owner is mailed a notice instead), so the call cannot be used to find out who has an account or which domains are open. 429 past the per-IP budget · 503 when the platform is holding its ceiling of waiting requests.
  • POST /api/v1/auth/signup/domain/complete — Body: token (from the link's fragment), password, optional use_cookie. Redeems the link: creates the account (email already verified) plus a baseline member membership and returns a token pair. 400 weak password (the link is not spent) or an invalid, used, expired or no-longer-open link (one answer for all of them).

Ownership transfer — /api/v1/ownership-transfers. The only way an organization's owner changes (the member endpoints above refuse to edit or remove an owner). A two-step offer: the owner proposes, a member accepts, and the old owner stays on as an administrator — nobody loses access in the same step, and the organization is never without an owner. Every act is by a person: an agent, a service principal or an API key is refused (403) on all five routes.

  • GET /api/v1/ownership-transfers — the offers where you are the proposer or the target, most recent first. identity:read. {offers: [{id, status, direction, from_user_id, to_user_id, expires_at, created_at}], count}; direction is outgoing (you proposed it) or incoming; status is pending, accepted, declined, cancelled or expired (an offer past its expiry reads expired whatever was stored).
  • POST /api/v1/ownership-transfers — an owner offers the organization. members:manage. Body: target_user_id. 201 returns the offer, valid for seven days. 403 with code recent_auth_required unless you signed in recently (re-authenticate, then repeat) · 403 you are not an owner · 403 while the organization is in bootstrap quarantine · 422 the target must be an active workforce member of the organization, not already an owner, with an active account and a membership not managed by your identity provider (SCIM) · 409 an offer is already open (an organization has one at a time).
  • POST /api/v1/ownership-transfers/{id}/accept — the target takes ownership. The target needs nothing beyond being a member (identity:self:write), and the same recent sign-in rule applies, and so does the quarantine refusal. One transaction: the target becomes an owner, then the old owner becomes an administrator. 403 the offer is not yours · 404 · 409 the offer has ended (declined, cancelled, expired), the proposer is no longer an owner, or the change would breach a separation-of-duties rule, which is named.
  • POST /api/v1/ownership-transfers/{id}/decline — the target turns it down. POST /api/v1/ownership-transfers/{id}/cancel — the proposing owner withdraws it (members:manage). Neither needs a recent sign-in. 403 for the wrong party · 404 · 409 the offer has ended.

Every act is recorded in the audit trail (member.ownership_transfer.proposed, .accepted, .declined, .cancelled). Today this is an API-only surface, and the target is not emailed: tell them an offer is waiting. If the owner who made an offer stops being an owner, or is erased, the offer ends.

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 platform operator (Zynth's own staff).

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

Currently tenant-editable:

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

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

POST /api/v1/auth/refresh

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

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

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) — 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 — and where your deployment requires it, they must use it. Both agent token mints — POST /api/v1/agents/token and the federated exchange — accept the same 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. The response says what you were given: token_type: DPoP for a bound token (present it as Authorization: DPoP <token> with a fresh proof on every call), bearer for an unbound one. A deployment can require sender-constrained agent tokens, and that is the default outside development: a request to either mint without a proof is then refused with 400 and a message naming the fix, and no token is issued — there is no unbound agent token to steal. An operator who needs bearer agent tokens states that choice explicitly with AGENT_DPOP_REQUIRED=false. Combined with federation this is the full 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. On Zynth's managed service this is currently closed and answers 403 naming who to contact (see below) — use the magic link to get back in. Where it is open, it 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, setup returns 503.

POST /api/v1/mfa/setup

Begin enrollment. Returns {secret, provisioning_uri, recovery_codes} — show the QR from provisioning_uri, and have the user save the 8 recovery codes now (shown only once). enrolment is started but MFA is inactive until verified. 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 Perihelium 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. A request is a person's right: an AI agent or an API key is refused 403.

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

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

Admin (compliance:read / compliance:admin)

  • GET /api/v1/gdpr/admin/requests — all requests in the tenant (filter by status/type).
  • POST /api/v1/gdpr/admin/requests/{id}/verify — confirm the requester's identity.
  • POST /api/v1/gdpr/admin/requests/{id}/reject — Body: reason (1 to 1000 characters). Describe the decision, not the person: any email address in reason is replaced with [address removed] when it is saved, and a longer text is refused with 422.
  • POST /api/v1/gdpr/admin/requests/{id}/process — optional Body: notes (up to 1000 characters, addresses removed on save like reason). Fulfill a verified request: access/export returns the subject's data; erasure scrubs the subject's personal data (refused with 409 while the subject is the only active owner of an organization that is still active; the request is not completed while that is the case). The tamper-evident audit log is retained (GDPR Art 17(3)) and never altered. From v0.45.0 it records a keyed pseudonym in place of an email address and the network an IP address sits in (the first three octets of an IPv4 address, the first three groups of an IPv6 address), so it holds no mailbox address and no host IP; entries recorded before that change remain as they were. Erasure also revokes the person's role assignments and every delegation they granted (and the sub-delegations those authorised), so an agent they authorised stops acting until someone re-delegates from a live person. A tenant that has been deleted no longer counts: its former sole owner can be erased, and their membership in it is revoked.

Deleted organizations. Deleting an organization is reversible for 30 days. After that the platform deletes every record the organization owned (memberships, settings, credentials, integrations, logs) and keeps only the tamper-evident audit log and an empty record of the organization's identifier. People are not deleted with an organization: a person leaves through their own erasure request. Backups age out on their own schedule, so the data is gone from every backup within 120 days of the deletion. Self-hosted: DELETED_TENANT_RETENTION_DAYS sets the 30 (0 never purges).

A 409 that refuses an erasure because the subject solely owns an organization that is still active identifies that organization only to its own administrators. An administrator of any other organization receives the same refusal without the organization's id or name. Erasure removes the identity provider's identifier (externalId) from the subject's memberships in every organization, including memberships that were already revoked; it also deletes the subject's passkeys, clears the source address from their consent history, revokes their connected-app consents (the applications' refresh tokens stop working in every organization, and their live sessions end, with a back-channel logout notice to each application that registered one), and replaces the email address, IP address and free-text description on the requests they opened. It also clears the email address, IP address and session identifier on the security events of their own actions, and deletes the address held for 30 days for security investigation after a failed sign-in or a reset request. The request itself keeps its type, status, dates and any notes the administrators recorded, as the record that it was handled.

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). Consent is a person's act: an AI agent or an API key is refused 403 here and on withdraw.
  • POST /api/v1/consent/{consent_type}/withdraw — withdraw consent (idempotent).
  • GET /api/v1/consent/history — the caller's consent change history (newest first).
  • GET /api/v1/consent/admin · GET /api/v1/consent/admin/history — tenant-wide review (tenant owner), filterable by user_id / consent_type.

Retention policies

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

  • GET /api/v1/retention — the tenant's retention policies. A policy is a declaration until the platform enables enforcement (self-hosted: RETENTION_ENFORCEMENT_ENABLED, off by default). When it is on, the platform deletes this tenant's events, closed data-subject requests and consent history older than the window, and records each run. A policy with archive_before_delete is not enforced (there is no archive), and legal_hold_exempt marks a class the retention worker leaves alone while a legal hold stands.
  • 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 (a person: an AI agent or an API key is refused 403). Every change is audited and mapped to ISO 27001 controls, feeding the ISO 27001 readiness report.

Enterprise plan only. Every route in this section answers 403 naming compliance.iso27001 on the Free and Pro plans, before the permission check. An unauthenticated call still answers 401. See Plans, entitlements & usage.

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

  • GET /api/v1/isms/risks (filter by status) · POST /api/v1/isms/risks — Body: title, likelihood (1–5), impact (1–5), optional description/category/owner_id.
  • GET /api/v1/isms/risks/{id} · PATCH /api/v1/isms/risks/{id} (partial update; 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 to treating.

Security policies (ISO 27001 A.5.1):

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

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

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

HIPAA

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

Enterprise plan only. Every route in this section answers 403 naming compliance.hipaa on the Free and Pro plans, before the permission check, and the console hides the HIPAA entry. An unauthenticated call still answers 401. See Plans, entitlements & usage.

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

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

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

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

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

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

Business associate agreements (§164.308(b)(1)) — hipaa:read / hipaa:admin. The register of who may receive PHI and under what agreement. Holds a pointer to the signed document in your own store, never the document and never PHI.

  • GET /api/v1/hipaa/baas (filter by status: active·terminated) · POST /api/v1/hipaa/baas — Body: associate_name, signed_on, optional service_description/expires_on/ document_ref. 201 returns the agreement, status: active.
  • PATCH /api/v1/hipaa/baas/{id} — service_description, expires_on, document_ref.
  • POST /api/v1/hipaa/baas/{id}/terminate — Body: optional reason. The agreement stays on the register as terminated with terminated_at; it is never deleted. 409 if already terminated · 404.

Workforce training records (§164.308(a)(5)) — hipaa:read / hipaa:admin. Append-only: a correction is a new record, and there is no edit or delete.

  • GET /api/v1/hipaa/training (filter by user_id, or expired_before for lapsed training) · POST /api/v1/hipaa/training — Body: user_id, course, completed_on, optional valid_for_days (default 365; omit to never lapse). 201 returns the record with its expires_on.

Minimum-necessary determinations (§164.502(b)) — hipaa:read / hipaa:admin. What each role may use or disclose of each kind of PHI, for which purpose, and why that is the minimum. A register of your documented standard; the PHI access log is not checked against it.

  • GET /api/v1/hipaa/minimum-necessary (filter by role_label/phi_type) · POST /api/v1/hipaa/minimum-necessary — Body: role_label, phi_type, purpose (treatment·payment·operations·other), allowed_access_types (a comma list of view·create·update·export·disclose), justification, reviewed_on, optional next_review_on.
  • PATCH /api/v1/hipaa/minimum-necessary/{id} — allowed_access_types, justification, reviewed_on, next_review_on. Each revision is recorded with its prior values.

Every write to the three registers is audited (hipaa.baa_recorded, hipaa.baa_terminated, hipaa.training_recorded, hipaa.minimum_necessary_recorded) and feeds the HIPAA readiness report. The registers are available through the API and in the HIPAA console.

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. For an AI agent principal the answer also carries decoy entries (and only for an agent; people and API keys never see them). An agent that names one in a request is reported as reaching past its brief, so an agent should build its requests from the permissions it was delegated, never from the whole catalogue (Decoys).
  • GET /api/v1/authz/roles · POST /api/v1/authz/roles — list / create custom roles. Body: code, name, permissions (catalog codes or domain:* / *:read / * patterns), description. Codes cannot shadow a system role.
  • GET /api/v1/authz/roles/{id} · PATCH /api/v1/authz/roles/{id} · DELETE /api/v1/authz/roles/{id} (soft-delete — existing assignments stop granting it), but 409 while an active separation-of-duties rule names the role, listing the rules by name. Same reasoning as the permission delete below: a rule compares the permissions that DISTINGUISH its roles, so deleting one member would leave the rule listed active and unable to fire. Retire or edit the rule first. This refusal is not overridable — see SoD.

Assignments — authz:assignments:read / authz:assignments:manage:

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

ABAC 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. A clause's field is subject.<attr> (user_id, tenant_id, role, is_owner, actor_type), resource.<attr> (whatever the checking route supplies) or one of three environment attributes, read from the server's clock in UTC: env.hour_utc (0–23), env.weekday_utc (Monday 1 … Sunday 7) and env.date_utc (YYYY-MM-DD). There is no location attribute — env.region and any other env.* field are refused with 422, as is an unknown operator or dimension: a clause that could never be evaluated would make the policy do nothing. A stored policy whose clause cannot be evaluated on a request (a resource.* field the route does not send) is applied in neither direction and raises a detection naming it.
  • 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, and a bounded loan of authority to a teammate. You delegate a bounded subset of your own authority; a delegation cannot grant a permission you don't hold (the C4 superset check).

A delegation to a person (delegate_type: "user") is read by the resolver like an agent's, with one difference: it confers, at every read, only what its delegator still holds. When the delegator loses a role, a just-in-time assignment lapses, or the delegator leaves the organisation, the delegate loses the code on their next request; the delegation row stays live and the console's effective-access view shows it with what it confers now. The delegate must be an active member of your organisation (404 otherwise). oversight_mode, max_risk_score and scope are accepted for a person, stored and returned, and tighten nothing: a person has no governance band and does not call the MCP gateway with a delegation. Removing a member revokes every delegation they granted (and everything under it) and every one they held. A delegation is a holder lending a slice of their own authority for a bounded time; when authority must be granted by someone who reviews it, use an access request instead.

  • GET /api/v1/authz/delegations (filter by delegate_id; limit 1–500 and the before / before_id cursor) · 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 reports cascaded, the number torn down). The delete of the human who started a chain no longer leaves the delegatees holding the grant to its expiry.

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

The band and the scope are bounds, not labels. oversight_mode and max_risk_score govern what the delegate may do with this grant: at decision time the effective band is the stricter of the delegation's and the delegate's own registration, so a delegation can narrow authority and never widen it. Both fields default to their safe values (HUMAN_APPROVE, max_risk_score: 5) and those defaults are enforced — omit them and the delegate is supervised and clamped to risk 5. Set them explicitly when you mean an agent to act unattended. An agent delegating to another agent cannot hand over a band wider than its own (422). And an AUTONOMOUS delegation to an agent 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), optional description. 201 · 422 if the OAuth resource isn't registered yet — register the audience first · 409 namespace collision, or that resource is already bound.
  • POST /api/v1/authz/resources/{resource_id}/permissions — mint one. Body: action, optional description, read_only (default false), risk (1–15). 201 {permission_id, code, risk} · 409 it already exists.
  • DELETE /api/v1/authz/resources/{resource_id} · DELETE /api/v1/authz/resources/permissions/{permission_id} — 204, but 409 while any role still grants it: revoke the grants first, so a deletion can never silently widen or narrow someone's access.

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

  • POST /api/v1/authz/check — for a principal asking about itself. Any valid session, API key or agent token; no permission needed. principal_id is optional and always resolved to the caller — naming anyone else is 403, which points you at the service lane. It is a question: it parks nothing and charges nothing. To authorize an action Zynth does not execute, and park a human approval for it, use POST /api/v1/authz/act.
  • POST /api/v1/authz/service-check — for a resource server asking about its users. Authenticates with an OP client_credentials token (Authorization: Bearer …) that carries the authz:check scope; the tenant comes from the token, not a header. 401 without a bearer token or with anything that isn't a client-credentials token · 403 without the authz:check scope · 422 if any check omits principal_id, which is required on this lane. A principal_id may name a member or an agent of the tenant: an agent is answered from its own delegations and oversight band — a supervised agent comes back allowed: true with obligations: ["human_approval"], and a killed one allowed: false, via: "agent_kill_switch". The answer is the agent's authority now, without any task-token narrowing (a check names a principal, not a token). Verifying that token at your own service is the MCP authorization guide's procedure; see also Verifying tokens.

Access governance — requests, SoD, certification

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

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

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

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

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

  • GET — the tenant's rules plus the shipped templates (operate-vs-attest, audit-vs-admin). POST — Body: name, optional description, roles (2–8 distinct role codes that may never be held together). DELETE /{rule_id} .
  • There is no separate violation-check endpoint by design — the rule is enforced inline at every grant, so assignments and request decisions both return 409 naming the rule. The only way past a blocked grant is sod_override: true on a request decided by a different approver — an override that leaves a record, rather than a quiet exception.
  • A second, different 409: deleting a role a rule names. DELETE /api/v1/authz/roles/{id} refuses while an active rule names that role, because deleting it would leave the rule listed active and unable to fire. sod_override does not apply — it overrides a grant, and nothing is being granted here. The way past is to retire the rule (DELETE /{rule_id}) — rules cannot be edited, so to keep the constraint, create a replacement rule naming a different role first — then delete the role.

Certification campaigns — /api/v1/authz/campaigns.

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

AI agents

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

Risk level → governance band. risk_level is minimal, limited, or high. It derives a band — a max risk-score ceiling, a minimum oversight mode (AUTONOMOUS / HUMAN_IN_LOOP / HUMAN_APPROVE), and a confidence floor — that you can tighten but never weaken (high locks HUMAN_APPROVE). Omitted config fields default from the band. An AUTONOMOUS agent's max_risk_score is at most 10, the same cap a delegation to it carries; a registration or update above it is 422, and the minimal band's default is 10. A HUMAN_IN_LOOP or HUMAN_APPROVE agent keeps the band's full ceiling (20 in the minimal band, 12 in limited, 8 in high). An agent registered above 10 earlier keeps its value: it can stay or move down, never up. 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 (newest first; limit 1–1000, default 500; count is the page and total every live agent; page on with before and before_id from the response's next_before / next_before_id) / register. Body: name, risk_level, optional description, max_risk_score, oversight_mode, confidence_floor. The response includes the credential secret once — store it now; it is never retrievable again. 409 when the name is already taken in your organization (a re-run setup script hits this constantly; the honest answer is "you already have one", not an error) · 422 for a config weaker than the risk band.
  • GET /api/v1/agents/{id} · PATCH /api/v1/agents/{id} — fetch / update (never returns the credential). A config weaker than the risk band is rejected (422). Tightening applies immediately; loosening takes a second person — see the dual-control note below.
  • POST /api/v1/agents/{id}/rotate-credential — issue a fresh credential (old one stops working); returned once. Requires dual control — see below.
  • GET /api/v1/agents/{id}/containment (agents:read) — the agent's containment in one read: the kill switch (engaged, by whom, why), what is left of its mutation and denial budgets in the current window (limit, remaining, window_seconds), and contained. The levers stay on the routes below; this is the view the console's agent-containment card shows.
  • POST /api/v1/agents/{id}/kill-switch — Body: engaged (bool), optional reason (≤ 200 chars, recorded on the agent). Engage the emergency stop or release it (agents:kill). Engaging is open to any holder of that permission; releasing is human-only, because it restores a stopped principal's authority.

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

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

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

Human-only is a property of the permission. Six permissions are flagged human_only in the catalog (GET /api/v1/authz/catalog returns the flag on every row): authz:policies:manage, authz:roles:manage, authz:assignments:manage, authz:resources:manage, agents:manage and mcp:servers:manage. A non-human principal — an AI agent or an API key — is refused 403 for any of them whatever it holds, on every path: a route, an MCP tool, and POST /api/v1/authz/check (which answers allowed: false for it). That covers writing ABAC policies, roles, assignments, Separation-of-Duties rules and customer resources/permissions, deciding access requests and reading the deciders' inbox, creating/closing certification campaigns, and registering MCP servers. Two switches are direction-dependent: any holder of agents:kill / autonomy:disable may engage the agent kill switch / stop autonomous response, but only a human may release / re-enable it — a stop that a non-human could undo is not a stop.

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

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

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

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

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

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

Attestation issuers (federated workload identity)

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

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

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

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

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

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

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

  • POST /…/issuers/{id}/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 is 409 — a binding that matched more than one agent, or whole classes of foreign workloads, is a misconfiguration this API will not accept. Max 25 bindings per issuer. DELETE /…/issuers/{id}/bindings/{binding_id} removes one.

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

What must hold, or the exchange refuses: the token's iss equals a registered, enabled issuer exactly; the signature verifies against the issuer's published keys (RS256/ES256 only); aud equals this deployment's issuer URL — mint your attestation for Zynth, e.g. GitHub's audience: input; exp − iat within the issuer profile's limit (15 minutes for github-actions and spiffe, 1 hour for the others — see the table above); 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 }). Send a DPoP proof in the DPoP header and the token is bound to your key: token_type is DPoP and the agent calls the API with Authorization: DPoP <token> plus a fresh proof on every request. Without a proof the token is a bearer token (token_type: bearer, Authorization: Bearer <token>) — unless the deployment requires DPoP-bound agent tokens, which is the default outside development, in which case a request without a proof is refused 400 and no token is issued. 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:

jsonc
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:

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

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

  • permissions must be a subset of the agent's own. Ask for anything the agent does not currently hold and the whole mint is refused with 403 — 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.
  • budget counts authorized requests, not permission checks: one API call spends at most one unit, and a request that is refused for any reason spends nothing. When it runs out, every further action is 403 — mint a new token (with a new task) to continue.
  • The declared task is recorded and shown to humans. It appears on the agent's audit trail and, when an action needs human approval, in the approver's queue as the request's justification, so the person deciding sees what it is for beside what is being asked. It is your text, displayed as your text: it informs the decision and grants nothing.
  • Nothing else changes. The kill switch, the token epoch, the risk ceiling, the oversight mode and the mint's rate limits all apply exactly as they do to an ordinary agent token — a task token is weaker in every dimension and stronger in none.

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

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

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

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

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

API keys are metered by the same budget. A key has no kill switch, so a key that exhausts either bound is revoked instead — the reason is recorded on the key, its next request is refused, and a person mints a new one. People are never metered by it. The bound is deliberately generous: it exists so that a compromised or runaway agent or key cannot rewrite your organization faster than you can notice, not to shape ordinary traffic.

If the budget's backing store is briefly unavailable, nothing is refused and nothing is contained. If it stays unavailable for several consecutive hours, non-human writes are refused with 403 and a message saying the meter is unavailable (reads are unaffected, and no agent or key is contained by it); they resume on their own the moment the meter answers again.

AI governance registers

The AI-system inventory and impact assessments (guide: AI system inventory and impact assessments). Every plan. Reads need aigov:read; writes aigov:write and decisions aigov:approve, both people only (an agent's token is refused). Lists page with limit and the before / before_id cursor.

  • GET /api/v1/aigov/profiles · GET /api/v1/aigov/profiles/{agent_id} · PUT /api/v1/aigov/profiles/{agent_id} — the inventory profile, written whole. Body: business_owner_id (an active member), technical_owner_id, purpose, intended_use, out_of_scope_uses, deployment_context (internal/customer_facing/public), interacts_with_persons, affects_decisions_about_persons, model_provider, model_name, model_version, data_categories, personal_data, special_category_data. 404 for an unknown agent, 422 for an owner who is not a member.
  • GET /api/v1/aigov/assessments (filter agent_id, status) · GET .../{id} · POST /api/v1/aigov/assessments (draft; Body agent_id plus the assessment) · PUT .../{id} (replace a draft; author only) · POST .../{id}/submit (author only; 422 without a fairness review when people are affected) · POST .../{id}/withdraw (reason, author only). The assessment: classification (prohibited/high_risk/limited/minimal/gpai), annex_iii_area, eu_ai_act_role, purpose, intended_use, affected_persons, human_oversight_measures, data_governance, fairness {method, attributes_considered, findings, outcome, evidence_refs}, residual_risk, decision (accept/accept_with_conditions/reject), conditions, evidence_refs [{label, uri, sha256}], risks [{category, description, likelihood, severity, mitigation, residual, owner_id}]. A prohibited classification with an accepting decision, and an unacceptable residual risk with one, are refused (422).
  • POST /api/v1/aigov/assessments/{id}/approve (note) · POST .../{id}/reject (reason, at least 10 characters) — aigov:approve, never by the author (403, recorded). Approval records the agent's band and the review date (180 / 365 / 730 days by classification) and supersedes the agent's previous approved version. A decided assessment is final (409).
  • GET /api/v1/aigov/coverage — per agent: has_profile, the current assessment_id and classification, review_due_on, overdue, stale (the band changed since approval), in_progress; covered counts agents with a profile and a current, in-date assessment.
  • GET /api/v1/aigov/export/assessments (optional agent_id, limit up to 5 000) — every version with its risks; truncated when there were more.
  • GET /api/v1/aigov/disclosures (filter agent_id, status) · GET .../report · GET .../{id} · POST /api/v1/aigov/disclosures (draft: agent_id, obligation, audience, channel, disclosure_text, locale, location_ref, evidence_refs) · PUT .../{id} (a draft, author only) · POST .../{id}/approve (aigov:approve, never the author; Body: content_digest, the value the read returned, so the approval is of the text reviewed — 409 if the draft changed since, or if a newer version is in force; retires the version it replaces) · POST .../{id}/retire (reason). An active disclosure is changed by a new version (409 on an edit). Every disclosure carries content_digest; the report carries truncated.
  • GET /api/v1/aigov/evaluations (filter agent_id, model_name, kind, outcome) · POST /api/v1/aigov/evaluations (aigov:evaluations:submit; agent-capable, never about the submitting agent itself, 403): agent_id and/or model_*, suite, suite_version, kind, run_at (not in the future), dataset_ref, metrics (1 to 50 numbers), thresholds ({min, max} per reported metric), evidence_ref, and supersedes with supersede_reason for a correction. The response carries gate_outcome (passed/failed/no_gate) and gate_failures.

Agent approvals (human-in-the-loop)

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

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

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

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

External actions — POST /api/v1/authz/act (same flag, same 404 on every method). Where an agent's own executor asks Zynth to authorize an action Zynth never performs — a shell command, a push to a release branch, a deploy — and parks a human approval when the agent's oversight mode requires one. Body: permission (a code from your own catalogue, such as shell:exec) and context (the action as the executor describes it: a flat object, at most 32 keys and 8 KiB, never a secret). Nothing else is accepted — a body naming a tenant is 422. The caller needs authz:act as well as the action's own permission, both by delegation. Answers, all sharing one body (status, decision_id, permission, via, approval_id, approval_status, request_expires_at, message): 200 authorized (run it; approval_id names the approval this call spent, or is null) · 202 pending (a human must decide; poll GET /api/v1/approvals/{approval_id}, then send the identical request again) · 403 denied (nothing is parked). Refused before the engine runs: 422 — the permission is a Zynth permission or not one of yours (reason: platform_permission, unregistered_permission), or the context is malformed or carries an obvious secret (reason names the shape, field the key; the value is never echoed) · 429 pending_cap — the caller already has 10 live asks waiting. The ask is stored with resource_type: "external_action" and the context as its resource, so it appears in the queue above with everything the reviewer is deciding. An approval binds to the agent, the permission and the whole context, and is spent once. Unlike POST /api/v1/authz/check it parks, charges the task-token budget and counts toward the blast-radius meter. Never plan-gated. GET /api/v1/authz/act/pending/{subject_id} → {subject_id, pending, cap, reached} — a principal's own live asks against the cap (reading another's needs agents:approvals:read). Walkthrough, the trust boundary and the reference executor: Approvals for actions Zynth does not execute.

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), optional description, max_calls_per_minute (default 30), max_calls_per_day (default 500).
  • GET · PATCH · DELETE /api/v1/mcp/servers/{server_id} — read, reconfigure (enabled: false stops calls without deleting), unregister.
  • PUT /api/v1/mcp/servers/{server_id}/tools · DELETE /api/v1/mcp/servers/{server_id}/tools/{tool_name} — the per-tool allow-list and its risk scoring; a tools/list reply is filtered down to it. Body: tool_name, risk_score (1–20), optional description, enabled (default true), read_only (default false — a call to a read-only tool does not spend the agent's hourly write budget; a tool you have not declared counts as a write). The server detail returns read_only per tool.
  • POST /api/v1/mcp/call/{server_name} — the proxied JSON-RPC call. Agent principals only (403). Each call passes the full governance stage — kill switch, per-tool risk ceiling, oversight mode, rate caps — and a refusal is a 403 naming the reason. 404 unknown server · 502 when the upstream itself fails, so an upstream outage never reads as a denial · 503 with Retry-After when the call's audit record could not be written, which means the call was not forwarded and is safe to retry (a call that ran is never answered 503) · 429 the gateway's own request budget (per agent, per organization, per source host; fail-closed; charged on every frame, initialize and unknown servers included) with the standard RateLimit-* + Retry-After headers.

Payment security

Switched off on the managed service (PAYMENTS_STEPUP_ENABLED; ask us to enable it), so every /api/v1/payments/* route answers 404. Self-hosted: ships off; set the flag to enable.

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

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

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

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

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

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

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

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

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

Command Center

The security-operations read plane: the raw event stream, the tamper-evident audit chain, detections, findings, incidents, and compliance evidence. All under /api/v1/command, all gated by command:read, with the three mutations needing command:operate. Scoping is automatic — a platform-operator tenant reads across organizations, everyone else reads only their own. Also the Command Center console in the app. The events, audit, detections, incidents and health surfaces are always on and are never gated by a plan; the Compliance dashboards are part of the Enterprise plan.

Events

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

Audit

  • GET /api/v1/command/audit — the append-only audit log. Filters: action, actor_id, since, until, before_seq (page backwards; the response's next_before_seq), limit (default 100, max 1000). Each entry carries its compliance_tags and its entry_hash.
  • GET /api/v1/command/audit/verify — the tamper-evidence check. The platform verifies the entire hash chain from genesis every hour on its worker and records the verdict; this returns that record: {verified, entries, broken_seq, verified_at, age_seconds, stale}. verified is null and stale is true until the first walk has run; stale is true when the last walk is more than two hours old; broken_seq names the first entry that did not match. A broken chain also raises a critical detection the moment it is recorded. Deliberately narrow: command:read plus platform-operator membership (403 otherwise).
  • POST /api/v1/command/audit/verify — walk the chain now. For the operator who cannot wait for the hourly walk (an incident). Walks the whole chain, records the verdict like the hourly walk does, and returns the same shape with stale: false. command:operate plus platform-operator membership; limited to 6 walks an hour per principal and 12 for the platform (429, and it fails closed: a limiter that cannot be consulted refuses).

Forensics — one person's recorded history, for an investigation:

  • GET /api/v1/command/subjects/{user_id}/timeline (command:read) — the person's events by actor and by any of their active sessions, and their entries in the audit chain, each newest first and paged (before/before_id for events, before_seq for the chain, since). Reading a person's history is itself recorded. The pre-authentication leg (a failed sign-in naming an address) is not joined: addresses in events are pseudonyms.
  • POST /api/v1/command/subjects/{user_id}/export (command:operate) — Body: reason (at least 20 characters), optional since. The same history up to 5 000 rows each, with truncated set honestly. The export is audited with its reason and surfaces a detection on the first occurrence, so an investigator's reason sits beside the request that prompted it.

Rate limit. Every read under /api/v1/command and /api/v1/observability is limited per principal (240 a minute); beyond it, 429 with Retry-After.

Detections & findings

  • GET /api/v1/command/detections — filters severity, rule_id, status (open·acknowledged·resolved), actor_id, limit (default 100, max 1000), before/before_id; each row is a deduplicated group with count, first_seen/last_seen and status. actor_id is "what was raised about this principal": pass the bare id and the server matches every group-key spelling that names an actor (actor:<id> and agent:<id>), so a rule that changed grouping does not appear to have lost its history.
  • GET /api/v1/command/detections/summary?days= (1–90, default 7) — roll-up by severity and by rule, each with an open count.
  • POST /api/v1/command/detections/{id}/acknowledge (command:operate) — Body: status (acknowledged | resolved, default acknowledged), optional note. 404 unknown.
  • GET /api/v1/command/findings — analyst-style findings over grouped detections. Filters severity, source (default analyst), limit (default 50, max 500). Read-only. Each analyst finding's summary is written inside the platform by default, and no detection data leaves it. Where the operator has switched on AI-written summaries, the language model provider receives only rule titles, severities, counts and times; each source a detection is grouped on (an IP address, an account, a session, a client) is replaced by a keyed pseudonym that is unique to your organization, and the real values are restored inside the platform before the summary is stored. The same source appears as the same token across your detections and is unrelated to any other organization's tokens.

Incidents

  • POST /api/v1/command/incidents (command:operate) — Body: title, severity, optional description, detection_ids (≤100), assignee. 201; 422 naming any detection id that is malformed or not visible to you.
  • GET /api/v1/command/incidents — filters status, severity, agent_id (the incidents an agent is linked to), serious (true/false), limit (default 100, max 1000) and the before / before_id cursor. Each incident carries agent_ids and its Art 73 fields: serious, serious_category, aware_at, regulator_due_at, reported_at, report_ref, reported_late, report_overdue. PATCH /api/v1/command/incidents/{id} (command:operate) — Body: any of status (open · investigating · contained · resolved · closed), assignee, containment, remediation, root_cause. 404 unknown.
  • AI incidents (command:operate, people only): PUT /api/v1/command/incidents/{id}/agents (agent_ids, live agents of the incident's organization, 422 otherwise) · POST .../{id}/serious (category: widespread_or_critical_infrastructure (report due in 2 days), death (10), serious_harm (15); aware_at, default now, with a timezone, 422 without one; 409 if already serious) · POST .../{id}/regulator-report (reported_at with a timezone, not before aware_at nor in the future, 422 otherwise; report_ref; 409 unless serious and unreported; the incident keeps reported_late). GET /api/v1/command/detections?family=agents narrows detections to the agent, approval and AI-governance rules.
  • GET /api/v1/command/incidents/summary?days= (1–365, default 90) — counts by status and severity, plus an unresolved block that deliberately ignores the window (it reports all-time backlog and the age of the oldest open incident — the number to alert on).

Compliance {#command-compliance}

Enterprise plan only. The three routes below answer 403 naming compliance.dashboards on the Free and Pro plans, before the permission check; an unauthenticated call still answers 401. They are reporting built on the audit trail, not the trail itself: GET /api/v1/command/audit and the audit verification stay on every plan, and every audit and compliance record is written on every plan. See Plans, entitlements & usage.

  • GET /api/v1/command/compliance/controls — the control catalog: each framework, and per control the actions that evidence it.
  • GET /api/v1/command/compliance/evidence?framework=… — framework is required (422 listing the known set). Optional days (1–365, default 90) and export — export=true raises the per-control sample cap from 3 to 10 000, for handing an auditor the actual entries; each control says whether its entries were cut (truncated). Returns readiness, the gaps list, satisfied, attested and applicable, and per control its status, detail, closable and audit samples with their hashes (controls_with_evidence and total_evidence count the audit rows that count). An export adds a manifest: digest (SHA-256 of the response's canonical JSON without the manifest, exactly as Python's json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=True) writes it; the same digest is recorded on the command.evidence_generated audit entry) and audit_chain (verified, verified_at, stale: the chain's last recorded verification). Rate-limited per principal (429).
  • GET /api/v1/command/compliance/report?framework=… — the same window rendered as a readiness report, grouped by family (each family with satisfied, attested, not_applicable and its readiness_pct). Each control has a status: satisfied (an audit entry in the window that counts: one that succeeded, or the record of a refusal), attested (a statement in force, below), not_applicable (a register-backed control with nothing to judge: no agents, no agent that talks to people, no high-risk agent, no serious incident), or gap, with detail and closable (in_platform or attest). The six register-backed controls (EU AI Act Art 15, 26, 27, 50, 73; ISO/IEC 42001 8.4) are judged from the AI-governance registers. readiness_pct is (satisfied + attested) over the controls that apply (applicable), with satisfied, attested, not_applicable, gaps, open incidents (for an AI framework, only those linked to an agent or serious) and an overall posture.
  • GET /api/v1/command/compliance/history?framework=… (days 1–365, default 90) — readiness as it stood each day: the worker's daily snapshots (day, readiness_pct, the counts by status, each control's status), oldest first.
  • GET /api/v1/command/compliance/attestations (optional framework) · POST …/attestations (compliance:admin, people only; control_id, statement 20–4 000 characters, owner_id an active member, evidence_refs, review_due_on within the next 366 days; 422 for an unknown control or one judged from the registers; 409 if attested at the same moment; it supersedes the attestation in force) · POST …/attestations/{id}/revoke (reason). Each attestation carries in_force (active and not past its review date).
  • Identifiers. OWASP agentic controls are OWASP26_ASI01 … OWASP26_ASI10 (the published 2026 list); ISO/IEC 42001's impact assessment is ISO42001_AIIA (clause 8.4) and the operation evidence once filed under 8.4 is ISO42001_8.1. Audit entries sealed with an older identifier count toward the control it now names.

POST /api/v1/telemetry/sdk — report a security anomaly

The one way to push an event into Zynth from your own SDK or gateway. Returns 202. Requires identity:self:write; the tenant is taken from the caller's token, never from the body. Rate-capped per tenant (429 beyond it).

FieldValues
kindrate_limit_exceeded, csrf_rejected, input_rejected, auth_failure_burst, suspicious_request, prompt_injection_suspected, custom
subjectfree text, ≤ 1000 chars
detailfree text, ≤ 5000 chars
metadataan object
severitylow · medium · high — accepted and currently ignored: every report is recorded at warning severity

Reports land as sdk.anomaly_reported and feed detection and the Command Center. This is one fixed action, not a general event API for your own action names — see Integrating an agent platform §6.

Recovery, platform posture and the API inventory — /api/v1/observability/*, platform operator only (command:read plus platform-operator membership; 403 otherwise), because each describes the platform rather than a tenant:

  • GET /api/v1/observability/recovery — backup freshness by job (base, WAL, logical, Redis, recent failures), the last tested restore (tested_at, age_days, overdue past 100 days, its measured rto_seconds/rpo_seconds and whether each met the target) and the targets themselves (RPO 600 s, RTO 3 600 s). POST /recovery/drills (command:operate) records a drill the operator ran — Body: kind (base·logical·redis), object, passed, rto_seconds, rpo_seconds, optional notes; scripts/restore-drill.sh prints the call.
  • GET /api/v1/observability/posture — the running build and the last verified deploy (version, every image digest, which attestations were checked, whether the unattested override was used, scan waivers in force, the cosign version), whether the two match, the Assessor's open findings by severity, and the evidence the pipeline produces.
  • GET /api/v1/observability/api-inventory — every mounted route with the permissions that gate it, whether a gate is human-only, the plan feature, the dormancy flag and the actions it may emit; prefix, offset, limit (max 1000). Derived from the live application, never a list.

Autonomous response

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

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

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

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

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

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

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

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

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

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

Autonomy levels are organization 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/drill also exists and is platform-operator only — it reports a soak drill running in a disposable tenant, and is not part of the tenant-facing surface.

GET /api/v1/recovery/agents/{agent_id}/journal · POST /api/v1/recovery/agents/{agent_id}/rollback

Every mutation an agent makes is journalled, so it can be read back and undone.

  • Read the journal — GET …/journal (agents:read). Newest first, windowed with since / until (422 when the window ends before it starts) and paged (limit 1–500, default 100, and the before / before_id cursor). Each entry carries journal_id, action, resource_type, resource_id, reversible (with an irreversible_reason when it is not), and rolled_back_at / rolled_back_by once undone.
  • Roll back — POST …/rollback with an optional window {since, until}; omit both to undo every pending mutation. Needs agents:kill and a human — reversing an agent's writes is remediation a non-human principal may not perform, the same rule as releasing a kill switch.

API keys have the same two routes: GET /api/v1/recovery/api-keys/{key_id}/journal and POST /api/v1/recovery/api-keys/{key_id}/rollback, both behind tenant:manage (a permission no key can carry, so a key never reads or rewrites its own history), 404 for a key that is not yours, and the rollback human-only like an agent's. Every write a key makes with one of its five eligible write permissions is journalled under the key's own id, so after a leak you can read exactly what it did.

The rollback report is deliberately honest, in three buckets: restored, skipped and irreversible, each with counts and a per-entry reason. It never reports an action as undone when it was not — a mutation with no clean inverse is named as irreversible rather than silently passed over. One is irreversible by design: an engaged kill switch. A rollback must never be the path that re-enables a contained agent; releasing the switch is a separate, deliberate human act.

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:

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

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

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

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

Sessions — /api/v1/me/sessions

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

Sessions across the organization — /api/v1/tenants/sessions (administrators; sessions:read to see, sessions:revoke to end; the admin and security_operator roles hold both, auditor sees):

  • GET — every active session in the organization, newest first (user_id to narrow to one member; offset/limit, max 500): sid, user_id, when it started and was last used, how the person authenticated (amr), the connected app if any, and the client that holds it — ip, user_agent, network (the autonomous system, or the /24 and /48 block). Your own GET /me/sessions shows the same client fields for your sessions. truncated is true if the organization has more than 2 000 sessions and the page could not consider them all (a session past that point is still reachable by id for /revoke). A session ended by sign-out-everywhere or a password change is not listed. A revoked session keeps no IP or user agent. Rate-limited per principal (429).
  • POST /{sid}/revoke — end one session now. Body: reason (at least 10 characters, on the record). 404 for a session that is not in your organization or is already dead.
  • POST /users/{user_id}/revoke-all — end every session the member holds in your organization: each ends now, and the tokens bound to those sessions are refused on their next request. Sessions the same person holds in other organizations are not touched, and their account-wide sign-out marker is not moved; that is theirs. Works on a suspended member too. Body: reason.
  • Both revocations are human-only (an agent or an API key is refused whatever it holds), refuse your own sessions (409; use /me/sessions or /auth/sign-out-all), and refuse an owner's unless you are an owner (409). Each is audited and watched: a sweep of revocations raises a critical detection.
  • A session that continues from a different network than it started on is recorded (session.network_changed) and raises a warning detection; nothing is revoked automatically.

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 · 404 for a delegation that isn't yours, already revoked, or not to an agent. Revoking authority you delegated is self-service by definition; granting it is not — that stays on the governed admin surface.

Profile — /api/v1/me/profile

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

GET /.well-known/jwks.json

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

GET /.well-known/security.txt

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

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

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

GET /api/v1/pricing

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

JSON
{
  "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

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

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

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

OpenID Provider (OAuth 2.1 + OIDC Core)

Perihelium 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: code response type only, PKCE S256 mandatory, four grants (the code grant, refresh, client credentials and the RFC 8628 device grant), 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", "urn:ietf:params:oauth:grant-type:device_code"], id_token_signing_alg_values_supported: ["RS256"], scopes_supported: ["openid", "profile", "email", "roles"], and the authorize/token/userinfo/jwks/end-session endpoints below, plus device_authorization_endpoint, introspection_endpoint (with introspection_endpoint_auth_methods_supported) and revocation_endpoint (with revocation_endpoint_auth_methods_supported).

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; the one qualification is a public client's loopback IP literal registered without a port, which accepts any port, RFC 8252 §7.3), 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 to the sign-in screen, which pre-fills the address. Full table in the OIDC guide.

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

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

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

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

POST /api/v1/oauth/token

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

client_secret_basic encoding (RFC 6749 §2.3.1). Inside the Basic credential the client id and secret are each application/x-www-form-urlencoded before being joined with : and base64-encoded. Strict libraries — oauth4webapi, and openid-client on top of it — encode the _ in every zc_… client id as %5F. Perihelium decodes both halves, so encoded and raw credentials both authenticate; because minted ids and secrets contain no % or +, the decode is a no-op for a client that sends them raw (curl -u). If you see invalid_client / "unknown or disabled client" from a library that authenticates elsewhere, you are on a version that predates this decode — switch that client to client_secret_post, which is decoded by the form parser, until the upgrade lands.

Serves four grant_types:

  • authorization_code — code, redirect_uri (exact, re-checked), code_verifier (PKCE re-verified), client_id. Returns access_token, id_token (when openid is granted), and refresh_token (when the client has that grant).
  • refresh_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); sub is the client_id.
  • urn:ietf:params:oauth:grant-type:device_code — device_code, client_id (RFC 8628). Answers authorization_pending, slow_down, access_denied or expired_token until the person has decided; then the same tokens as the code grant (id_token with openid, without a nonce; refresh_token when the client has that grant).

authorization_code, refresh_token and client_credentials 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. Credential verification itself runs off the request loop, bounded by the server's CPU: a burst inside the limits queues and answers slower 200s, and never delays unrelated requests.

POST /api/v1/oauth/device_authorization

RFC 8628 §3.1 — the device grant's first call, for a CLI or headless session that cannot receive a redirect. Form-encoded: client_id (and client_secret for a confidential client), optional scope (a subset of the client's allowed scopes). The client must be registered for urn:ietf:params:oauth:grant-type:device_code; otherwise unauthorized_client. Response 200, Cache-Control: no-store:

JSON
{ "device_code": "zdc_…", "user_code": "BCDF-GHJK",
  "verification_uri": "https://<console>/oauth/device",
  "verification_uri_complete": "https://<console>/oauth/device?user_code=BCDF-GHJK",
  "expires_in": 600, "interval": 5 }

Both codes expire after 600 seconds; poll /token no faster than interval (a faster poll is slow_down and adds 5 seconds). Rate limited fail-closed by the token endpoint's per-IP floor, shared, so alternating endpoints does not buy a second budget. Errors are RFC 6749 §5.2 JSON (invalid_client 401, unauthorized_client, invalid_scope).

The person's half is the console's device page, backed by three calls that require the signed-in person's own session (a person, never an API key or an agent): POST /api/v1/oauth/device/lookup, …/approve and …/deny, each with body {"user_code"}. A lookup returns the application's name, its organization, the scopes and the request's age; nothing approves by itself. A code that matches no live request answers 404 and charges a budget of 5 per 15 minutes per person and 20 per IP, after which the calls answer 429. Full walkthrough: Signing in from a CLI.

POST /api/v1/oauth/introspect

RFC 7662 — is this access token live now? Form-encoded token (and token_type_hint, ignored). The caller is a resource server of the same organization, authenticated either with Authorization: Bearer|DPoP <client-credentials token whose scope includes authz:check>, or with the client_secret_basic / client_secret_post credentials of a confidential client allowed authz:check and client_credentials. A caller that fails gets 401 or 403 and learns nothing about the token; a missing token is 400 invalid_request. Both lanes draw on the client's own introspection allowance, resolved from your plan (free 600 / pro 6,000 / enterprise 60,000 requests per 5-minute window; negotiable per organization), separate from /token; 429 with Retry-After past it, and the limit fails closed.

Response 200, Cache-Control: no-store. A live token: {"active": true, "scope", "client_id", "sub", "tid", "aud", "iss", "exp", "iat", "token_type", "cnf"?} — iss is the access token's issuer (zynth-auth), not the OIDC issuer, and token_type is DPoP with cnf.jkt for a bound token. Anything else, including every token of another organization, is exactly {"active": false}: a bad signature, an expired or refresh token, an unknown audience, a revoked session, a principal that no longer stands, a disabled client, and an agent that is retired, killed or whose containment epoch is later than the token. Guide: Token introspection and revocation.

POST /api/v1/oauth/revoke

RFC 7009 — a client ends its own grant. Form-encoded token (and token_type_hint, ignored); the client authenticates exactly as at /token. A refresh token or a user access token issued to the client revokes the grant's session, so every token of that grant stops at once (the platform API, /userinfo, introspection): 200 {}. A client_credentials access token is 400 unsupported_token_type (there is no session to end; disable the client). An invalid, expired or unknown token, and a token issued to another client, get the same 200 {} and nothing is done — a deliberate difference from RFC 7009 §2.1, so the endpoint cannot confirm a token exists.

GET / POST /api/v1/oauth/userinfo

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

Claims released:

ClaimReleased
subalways
tid, tenant_slugalways (see tenancy claims — tid is the key, the slug is display-only)
email, email_verifiedwith the email scope
name, given_name, family_name, picture, updated_atwith the profile scope (omitted when not stored)
roles (or roles_truncated)with the roles scope, when the client is also allowed it (role claims)

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

GET /api/v1/oauth/logout

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

The id_token

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

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

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

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

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

Role claims — present only when the roles scope was granted and the client's administrator allows it. roles is an array of role codes held in the organization that signed in (tid), at most 64; past that the array is omitted and roles_truncated: true is sent. Both are also on /userinfo. See Role claims.

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 Perihelium and the resource server, and its claims are not part of this API contract — they may change at any time, without notice, in any release, including patch releases.

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

OAuth clients (tenant admin)

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

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

OAuth resources — audience-bound tokens (tenant admin)

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

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

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

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

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

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

OAuth installations — admitting a platform application (tenant admin)

An application Zynth registers at the platform level can sign in to your organization only if your admin admits it here. A platform-level client grants itself nothing anywhere; the absence of an installation is a refusal, so the default for every organization is no.

All four require tenant:manage:

  • GET /api/v1/tenants/oauth-installations — what this organization has admitted.
  • GET /api/v1/tenants/oauth-installations/installable/{client_id} — what an application would be admitted as, before you admit it.
  • POST /api/v1/tenants/oauth-installations — admit an application. Body: client_id, and scopes — the ceiling this organization is willing to grant it. Leaving scopes empty means "whatever the application is registered for", which is the weaker choice; name the scopes.
  • DELETE /api/v1/tenants/oauth-installations/{client_id} — withdraw it.

Withdrawal takes effect immediately, not eventually. It is recorded as an instant, and from then on the application is refused at the next /authorize and at the next refresh of every grant already outstanding — there is no fan-out to wait for.

Two switches, two owners. The platform can disable an application everywhere; your installation stops it for your organization alone. Neither substitutes for the other. See the console flow for the admin experience.

Connected apps (self-service consent)

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

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

Webhooks

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

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

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

Alert channels

Where an organization's own detections are sent. All endpoints live under /api/v1/alert-channels; reading needs alert_channels:read, every write needs alert_channels:manage, which is human-only (an agent or API key is refused whatever it holds), and creating one needs the plan that includes alert channels (403 on Free). Walkthrough: Alert channels.

  • GET /api/v1/alert-channels — every channel with its recipients as they stand today (eligible: false for a member who has since left or changed address), its webhook endpoint (endpoint_id, and endpoint_enabled) and its last delivery result (last_result: delivered, queued, failed or capped, with a classified last_error).
  • POST /api/v1/alert-channels — Body: name, optional kind (email or webhook; default email) and min_severity (info, warn or critical; default critical), and either recipient_user_ids (email) or endpoint_id (webhook), never both. 201 · 403 not on the plan · 409 past 20 channels per organization · 422 when any recipient is not an active member of the organization with a verified email address, or the endpoint is not one of the organization's enabled webhook endpoints — the message never says which, so it cannot be used to learn who is a member, or which endpoint ids exist, elsewhere.
  • PATCH /api/v1/alert-channels/{id} — change name, min_severity, enabled, and recipient_user_ids (email) or endpoint_id (webhook). Redirecting a channel (a different recipient set or endpoint), disabling it or raising its floor mails every owner. 404 unknown.
  • DELETE /api/v1/alert-channels/{id} — 200 · 404. Mails every owner.
  • POST /api/v1/alert-channels/{id}/test — sends a marked test to the channel's eligible recipients (email), or posts a real signed alert.raised to the endpoint (webhook). Returns {recipients, sent, error}; error is the classified reason a webhook test failed. At most ten an hour per organization, and each recipient counts against the day's alert limit like a real alert (429 either way).

Delivery contract. A detection of the organization's own (never a platform-wide one) at or above a channel's floor is mailed once per rule and group inside a short window, to each recipient who is still an active member with a verified address, and counted against the plan's daily alert email limit (Pro 100, Enterprise 1000; past it nothing more is sent that day and the channel shows capped). The mail carries the rule, severity, count, window and a link to the Command Center; it never carries the detection's group, description or any event data. A webhook channel queues one signed alert.raised notice (id, type, schema_version, and data: detection_id, rule_id, title, severity, count, window_seconds, first_seen, last_seen, console_url, and nothing more) on the webhook delivery plane, and the channel's last_result is the delivery's outcome.

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:

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

Billing (tenant self-service)

Subscription and metered usage for your own organization. Requires tenant:manage. Card data never reaches Perihelium — 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.

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); 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's maintenance_expires_at against the release's published_at.)
  • 429 — rate-limited (per credential and per source IP); retry later.
  • 503 — the server has no object storage configured. This is an install-side misconfiguration, not an entitlement problem — tell your operator; it is not a 404 precisely so it can't be mistaken for "no such release".

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

Enterprise SSO & SCIM

Switched off on the managed service (SSO_FEDERATION_ENABLED; ask us to enable it). On the managed service every route below answers 404, the same as an absent route. Self-hosted: ships off; set the flag to enable.

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

Password policy

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