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.jsonand/docsare not served at the application origin:/openapi.jsonanswers404and/docsanswers a301to 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
404on/openapi.jsonas 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 uniform401 Invalid credentials. - Rate limiting. Repeated failed sign-ins are throttled per source IP and per account: once
the threshold is hit,
POST /auth/loginreturns429 Too Many Requests. A successful login clears the account counter. (Magic-link issuance is separately rate-capped.) Registration is rate-capped too:POST /auth/signupandPOST /auth/signup/customershare one per-IP budget and return429with the standard rate headers past it — retry after the window shown inRetry-After; redeeming the emailed link (/auth/signup/completeand/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/previewand/invites/acceptdeliberately share one per-IP budget, so preview is not a free guessing oracle; domain join —/auth/signup/domain-checkand/auth/signup/domain, likewise shared; and the agent-bootstrap family —/onboard/bootstrap(per-IP, per-network and a global ceiling),/onboard/polland/onboard/pow(one shared budget), and the human/onboard/verify/*legs; and the early-access waitlist door —POST /early-access(per-IP and a global ceiling). The agent surfaces are throttled the same way: the token mint and the MCP surface each carry per-agent, per-organization and per-source budgets — see those sections for the numbers a client should expect to live inside. Separately, API-key requests are metered against the plan's monthly quota: pastapi_calls_monthlythe key's requests get429carryingRateLimit-Policy: monthly-quota— "you are over plan", not "slow down". - Rate-limit headers (v0.21.0). Every throttled
429— sign-in, the OAuth/authorizeand/tokenendpoints, release downloads, SDK telemetry — carries a computedRetry-After(seconds until one request will be accepted; previously a fixed60) plus the draft IETF trioRateLimit-Limit,RateLimit-Remaining, andRateLimit-Reset, describing the most constrained limit that applies to your request. Well-behaved clients should honourRetry-Afterand may watchRateLimit-Remainingto back off before hitting the wall. Two deliberate exceptions: enumeration-safe endpoints (e.g.POST /auth/forgot-password) return202with no rate headers — exposing the cap would reveal whether an address is being throttled — and in the rare degraded state where limits cannot be computed, a429carries only a fixed shortRetry-After. - 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 aRetry-After: 2. Where the record joins the change's own transaction (the common case) the two commit or roll back together, so a503means nothing changed; where a change commits before its record is written, a503can 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 a503there means the effect did not happen and the retry is safe; a record that can only be written afterwards never turns into a503for an effect that already ran. A503here 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
413before it reaches the API. A malformed or excessively-nested JSON body returns a clean400. - SCIM endpoints use
application/scim+jsonand return RFC 7644 error documents (not the{"detail": …}shape) — see Directory provisioning.
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), andcustomer(the CIAM end-user baseline — self-service identity only; it is never grantable by invitation).GET /api/v1/authz/catalogreturns the live set. - Read permissions cannot reach a write. Every permission is flagged read-only or not, and
no endpoint that changes state is reachable by read-only permissions alone — enforced against
the running application, not by convention. This is why acting on your own record —
granting or withdrawing consent, opening or cancelling a data-subject request, acknowledging a
policy, reporting an SDK signal, revoking a delegation you granted — needs
identity:self:writeand notidentity:read. Both are in thememberandcustomerbaselines, so every person holds them; an API key or agent delegation holds only what it was given by name, so a key that reports SDK telemetry must carryidentity:self:write. - The tenant owner always has every permission. A regular member starts with only the
memberbaseline until granted a role.
A request that lacks the required permission returns 403 (vs 401 for missing/invalid
authentication). Self-service endpoints (your own data, consent, DSRs about yourself) need no
special permission — just a valid token. Endpoints below note the permission they require.
Role management (assigning roles, defining custom roles, ABAC policies) is available through the authorization admin API and the Access Control console (
/rolesin the app — People / Roles / Policies), which gates its controls offGET /me/permissions.
GET /api/v1/me/permissions
The caller's own effective permissions — use it to hide or disable UI controls the user lacks.
Returns: { is_owner, permissions: ["compliance:read", …] }. Needs only a valid token.
API keys
Programmatic credentials for integrations (v0.21.0). A key looks like
zyn_live_<secret>_<check> (zyn_test_… outside production — a test key can never
authenticate against production, and vice versa). Use it exactly like an access token:
Authorization: Bearer zyn_live_… on any endpoint. What a key may do is defined by its
scopes, drawn from the same permission vocabulary as roles (e.g. compliance:read) —
a key with no scopes authenticates but can call nothing.
Managed under /api/v1/tenants/api-keys (requires tenant:manage):
POST— mint a key:label,scopes, optionalexpires_in_days(default 365;null= explicitly non-expiring). The plaintext appears once, in this response. Store it; it is never retrievable again (we keep only a hash). Two bounds, both403: the requested scopes must be a subset of the caller's own permissions (owners hold everything), and an AI agent principal may not mint a key at all — a key carries none of an agent's governance, so minting one would step outside it. Both apply torotate. A third bound is400, for everyone including owners: a key may carry a read-only permission or one of five named writes (agents:killfor 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 (andinactive_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 areason. Takes effect on the key's next request.GET /usage— API-key request volume for a window, and the ceiling it is measured against. Optionalperiodquery param (defaults to the current one). Returns{period, total, quota, rows}—quotaisnullon an unlimited plan, androwsbreaks the total down per key. This is the counter behind the monthly-quota429.
A rejected key — malformed, wrong environment, unknown, expired, or revoked — always gets
the same generic 401. Treat a 401 on a previously working key as "rotate now".
POST /api/v1/auth/signup
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
| Field | Type | Notes |
|---|---|---|
email | string | Valid email. |
organization_name | string | 1–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 atPOST /api/v1/mfa/challengewith a code. See MFA.
Passkeys (WebAuthn)
Phishing-resistant sign-in. A user-verified passkey carries two factors in one ceremony (possession + biometric/PIN), so passkey sign-ins do not additionally prompt for a TOTP code. Users may register multiple passkeys; ceremonies use single-use, short-lived challenges, and all failures are generic (no credential or account enumeration).
POST /api/v1/webauthn/credentials/options · POST /api/v1/webauthn/credentials
Authenticated enrollment: fetch creation options (pass them to
navigator.credentials.create()), then submit the browser's response with an optional
name. 201 returns the credential summary · 400 invalid/expired/replayed ceremony.
GET /api/v1/webauthn/credentials · POST /api/v1/webauthn/credentials/{id}/remove
List the caller's passkeys; removing one re-proves the account password in the body
when one exists (a stolen session alone cannot strip factors). A passwordless account
(e.g. a passkey-first owner) sends no password: removal is allowed only while another
passkey remains — the last way to sign in can never be removed. 204 removed · 400
wrong password / last credential · 404 unknown credential.
POST /api/v1/early-access — the private-beta waitlist
Public, no principal: the form on the marketing site's early-access page. Body: email,
full_name, role, organization; app_name, app_url, app_description, stage
(idea|building|production), platforms (any of web|mobile|api|internal);
integrations (at least one of agents_mcp|customer_oidc|workforce_sso|api_keys),
test_goals; expected_users (lt_100|100_1k|1k_10k|gt_10k), expected_agents
(0|1_5|6_50|gt_50), timeline (this_month|next_quarter|later); consent and
acknowledgement (both must be true — 422 otherwise, and nothing is stored) with their
consent_text_version / acknowledgement_text_version; optional intended_use, source. Answers 202 with {"status": "accepted"} whether the
address is new or already on the list — a repeat submission refreshes the consent record on the
same row, and the answer is the same bytes either way, so the list is not enumerable. Refusals:
429 past the fail-closed per-IP budget or the global ceiling (standard rate headers);
400 for a disposable email domain or a domain with no mail exchanger (the same screening as
agent onboarding, under its own switch). What is stored, why, and how it is erased:
Website analytics & privacy and the privacy policy;
the operator's read and erasure path is 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:
{
"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:
{ "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).
Magic-link sign-in
Passwordless email sign-in. Links are single-use, expire after ~15 minutes, and requesting a new link replaces the outstanding one. Magic-link sign-in does not bypass an enrolled TOTP factor.
POST /api/v1/auth/magic-link
Public. Body: email, tenant_slug. Always returns 202 with the same body
whether or not the address is registered (no enumeration). Issuance is rate-capped
server-side; over the cap the response is unchanged and no email is sent.
POST /api/v1/auth/magic-link/verify
Public. Body: token (from the emailed link), optional
use_cookie. Returns exactly what password login returns:
200 token pair, or the MFA challenge (mfa_required: true, mfa_token) ·
401 generic — invalid, expired, already used, or superseded by a newer link.
Social login (Google · GitHub)
"Continue with Google/GitHub" (authorization-code + PKCE, server-orchestrated). New users are JIT-provisioned as customers into the target organization iff it has customer self-signup enabled; an existing account with a matching provider-verified email is linked automatically (an unverified provider email is rejected — no account takeover). Social sign-in does not bypass an enrolled TOTP factor. All failures collapse to one generic redirect (no enumeration).
GET /api/v1/auth/social/providers
Public, no body. Returns {providers: ["google", …]} — the providers this deployment
actually has configured, so a sign-in page renders only buttons that work.
POST /api/v1/auth/social/{provider}/start
Public. provider ∈ google | github. Body: tenant_slug. Returns 200
{authorization_url} — navigate the browser there · 404 provider not configured.
GET /api/v1/auth/social/{provider}/callback
The provider's redirect target (browser navigation; not called directly). On success it 302-redirects to the app with a one-time completion code (60-s TTL); on any failure it redirects with a generic error flag.
POST /api/v1/auth/social/complete
Redeem the completion code. Body: code, optional use_cookie.
Returns exactly what password login returns: 200 token pair, or the MFA
challenge (mfa_required: true, mfa_token) when the account has TOTP enrolled ·
401 invalid/expired/replayed code.
GET /api/v1/auth/social/identities · POST /api/v1/auth/social/{provider}/link/start · POST /api/v1/auth/social/{provider}/unlink
Authenticated identity management: list linked provider accounts; link a new provider
(same browser flow, bound to the session user); unlink one. Unlink requires the
account password when one exists, and refuses to remove a passwordless account's last
sign-in method (409 — set a password first). 204 unlinked · 404 not linked.
Members, invitations & domain join
How people get into an organization and what they may do once in. Three surfaces: the authenticated admin lifecycle, the anonymous redemption trio that turns an invitation into an account, and auto-join domains for organizations that would rather not invite one address at a time. Walkthrough: Team members.
Admin — /api/v1/members (members:read to list, members:manage to change). The whole
family is refused with 403 while the tenant sits in bootstrap quarantine.
GET /api/v1/members— the roster.GET /api/v1/members/invites— invitations awaiting redemption.POST /api/v1/members/invites— invite by email. Body:email,role(defaultmember; a system-role code).201{invite_id, email, role, expires_at, reissued}— re-inviting a still-pending address re-issues the same invitation rather than stacking a second (reissued: true).422unknown role ·409already a member ·403when the plan'smembersseat cap is reached — the message names the limit, and pending invitations count against the total (caps).DELETE /api/v1/members/invites/{invite_id}— revoke a pending invitation.204·404.PATCH /api/v1/members/{membership_id}— change a member's role. Body:role.422unknown role ·404· and a409family that is the point of the endpoint: owner memberships change only via ownership transfer; a membership managed by your identity provider (SCIM) is edited in the directory, not here; and a change that would breach a separation-of-duties rule is blocked naming the rule.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 (403for an agent or an API key), refused with409on yourself and on an owner unless you are one, and tenant-scoped (404). Both are also409for 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 also409for 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.409for the sole owner (transfer ownership first) and for a SCIM-managed membership ·404.
Redemption — /api/v1/invites (public; the token travels in the body, never a URL, so
it never reaches an access log). Preview and accept share one fail-closed per-IP budget → 429.
POST /api/v1/invites/preview— Body:token. What the acceptance page shows.POST /api/v1/invites/accept— redeem as a new user. Body:token,password, optionaluse_cookie. Returns a token pair — the invitee lands signed in.400weak password ·409an account with that email already exists (sign in, then useaccept-existing).POST /api/v1/invites/accept-existing— authenticated; joins the invited organization with the account you are already signed in as. Any organization's session works, but the session's email must be the invited email — otherwise403.200{tenant_slug, role}·409already a member.- Across all three:
410 Gonefor an expired invitation (ask for a new one — deliberately distinct from404, which means invalid, revoked, or already redeemed).
Auto-join domains. Admin lifecycle under /api/v1/members/domains (members:read /
members:manage, quarantine-gated like the rest):
GET— claimed domains.POST— claim one. Body:domain.201returns the TXT record to publish.422for a malformed domain or a public email provider (gmail.com and friends can never be claimed) ·409another organization already holds it verified.POST /{domain_id}/verify— run the DNS check; returns{domain, verified}.DELETE /{domain_id}— release the claim (204).
And the anonymous join pair, which shares its own fail-closed per-IP budget (429):
POST /api/v1/auth/signup/domain-check— Body:email. What the signup page may offer.POST /api/v1/auth/signup/domain— Body:email. The mailbox is proved first: this call creates nothing and takes no password. It answers202 {"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.429past the per-IP budget ·503when the platform is holding its ceiling of waiting requests.POST /api/v1/auth/signup/domain/complete— Body:token(from the link's fragment),password, optionaluse_cookie. Redeems the link: creates the account (email already verified) plus a baselinemembermembership and returns a token pair.400weak 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};directionisoutgoing(you proposed it) orincoming;statusispending,accepted,declined,cancelledorexpired(an offer past its expiry readsexpiredwhatever was stored).POST /api/v1/ownership-transfers— an owner offers the organization.members:manage. Body:target_user_id.201returns the offer, valid for seven days.403with coderecent_auth_requiredunless you signed in recently (re-authenticate, then repeat) ·403you are not an owner ·403while the organization is in bootstrap quarantine ·422the 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) ·409an 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.403the offer is not yours ·404·409the 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.403for the wrong party ·404·409the 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 itskey,summary,type(boolean|integer|string),default, currentvalue,overridden,risk, and anybounds/allowedvalues. This is what the console renders.PATCH /api/v1/tenants/config/{key}— set one value. Body:value.404for a key the registry doesn't know (unknown means refuse, never create),400when the value violates its declared type/bounds/allowed set — with the rule stated.GET /api/v1/tenants/config/{key}/history— who changed what, old → new, when; newest first.
Currently tenant-editable:
| Key | Meaning |
|---|---|
auth.magic_link_enabled | Allow members to sign in with emailed magic links. Turning it off also stops already-issued links from redeeming. |
auth.social_login_enabled | Allow "Continue with Google / GitHub" for this organization. |
auth.ciam_signup_enabled | Allow customers to self-register (customer self-signup). Default false. |
members.domain_join_enabled | Allow anyone with an email address on a verified domain to join this organization without an invitation (team members). Default false. |
autonomy.level_contain_source · autonomy.level_contain_non_human · autonomy.level_raise_assurance · autonomy.level_revoke_access · autonomy.level_disable_principal | The oversight level for each class of autonomous response, default manual (nothing acts). Each key's allowed set is its class ceiling: contain_source and raise_assurance accept manual|recommend|graduated|full, contain_non_human (stopping an AI agent) and revoke_access stop at graduated, disable_principal at recommend. GET …/config/schema returns the exact set per key. |
autonomy.disabled_until | The autonomous-response emergency stop, as an expiry instant (empty = enabled). Prefer POST /api/v1/autonomy/kill-switch, which offers the 1/8/24h options and enforces the reason requirement. |
Legacy route.
GET/PATCH /api/v1/tenants/settingsstill servesciam_signup_enabled(same permission, same behaviour, same audit trail — it reads and writes through the registry underneath). New integrations should use the config routes above; the legacy pair is kept for compatibility and is not extended.
POST /api/v1/auth/refresh
Exchange a valid refresh token for a new pair. Re-validates tenant membership first, and rotates the refresh token (single-use). Reusing an already-rotated token revokes the whole session — see rotation & reuse detection.
Body: refresh_token — or omit it to use the cookie transport
(the httpOnly cookie set at login is used instead, and the response rotates the cookie).
Responses — 200 token pair (a new refresh token; discard the old) · 401
invalid/expired/already-used refresh token, revoked session, or membership no longer active
(in cookie mode the dead cookie is also cleared).
POST /api/v1/auth/sign-out-all
Authenticated. Signs the user out of every device at once (CAE) — 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,setupreturns503.
POST /api/v1/mfa/setup
Begin enrollment. Returns {secret, provisioning_uri, recovery_codes} — show the QR from
provisioning_uri, and have the user save the 8 recovery codes now (shown only once).
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 bystatus/type).POST /api/v1/gdpr/admin/requests/{id}/verify— confirm the requester's identity.POST /api/v1/gdpr/admin/requests/{id}/reject— Body:reason(1 to 1000 characters). Describe the decision, not the person: any email address inreasonis replaced with[address removed]when it is saved, and a longer text is refused with422.POST /api/v1/gdpr/admin/requests/{id}/process— optional Body:notes(up to 1000 characters, addresses removed on save likereason). Fulfill a verified request: access/export returns the subject's data; erasure scrubs the subject's personal data (refused with409while 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.
Consent (GDPR Art 7 / APAC)
Per-purpose consent, granted and withdrawn by the subject. A consent_type is a lowercase
purpose slug (^[a-z0-9_-]{1,64}$, e.g. marketing_email). Every change is recorded in an
append-only history and sealed into the audit log.
GET /api/v1/consent— the caller's current consent state (one entry per purpose).POST /api/v1/consent/{consent_type}/grant— grant consent (idempotent). Consent is a person's act: an AI agent or an API key is refused403here and onwithdraw.POST /api/v1/consent/{consent_type}/withdraw— withdraw consent (idempotent).GET /api/v1/consent/history— the caller's consent change history (newest first).GET /api/v1/consent/admin·GET /api/v1/consent/admin/history— tenant-wide review (tenant owner), filterable byuser_id/consent_type.
Retention policies
Requires compliance:admin (read with compliance:read). Declares how long each governed data class is kept (GDPR Art 5(1)(e) storage
limitation). data_class is one of events · dsr · consent_events (the tamper-evident audit
log is exempt and never purged).
GET /api/v1/retention— the tenant's retention policies. 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'sevents, closed data-subject requests and consent history older than the window, and records each run. A policy witharchive_before_deleteis not enforced (there is no archive), andlegal_hold_exemptmarks 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 bystatus) ·POST /api/v1/isms/risks— Body:title,likelihood(1–5),impact(1–5), optionaldescription/category/owner_id.GET /api/v1/isms/risks/{id}·PATCH /api/v1/isms/risks/{id}(partial update;status∈identified·assessed·treating·accepted·closed).POST /api/v1/isms/risks/{id}/treatments— Body:option(mitigate·accept·avoid·transfer),description. Recording a treatment moves an untreated risk totreating.
Security policies (ISO 27001 A.5.1):
GET /api/v1/isms/policies(any member) ·POST /api/v1/isms/policies(owner) — Body:title, optionalversion(default1.0),category,body.PATCH /api/v1/isms/policies/{id}(owner).POST /api/v1/isms/policies/{id}/acknowledge(any member) — idempotent per policy version; a version bump requires a fresh acknowledgment.GET /api/v1/isms/policies/{id}/acknowledgments(owner) — who has acknowledged.
Information-asset register (ISO 27001 A.5.9) — isms:read / isms:write:
GET /api/v1/isms/assets(filter byclassification/status) ·POST /api/v1/isms/assets— Body:name, optionalasset_type,classification(public·internal·confidential·restricted),owner_id,description.GET /api/v1/isms/assets/{id}·PATCH /api/v1/isms/assets/{id}.
HIPAA
HIPAA compliance surfaces for tenants handling protected health information. Every mutating action is audited and mapped to HIPAA controls, feeding the HIPAA readiness report. No PHI is stored — only opaque tenant-side identifiers.
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), optionalrecord_id/patient_ref/reason,is_emergency(break-glass access is flagged as awarnevent).GET /api/v1/hipaa/phi-access(owner / privacy officer; filter byrecord_id/is_emergency).
Breach notifications (Subpart D) — hipaa:read / hipaa:admin:
GET /api/v1/hipaa/breaches(filter bystatus) ·POST /api/v1/hipaa/breaches— Body:title,individuals_affected, optionaldescription/breach_type/discovered_at. The 60-daynotification_deadlineand theis_large_breachflag (≥500 individuals → HHS + media duties) are computed server-side.GET /api/v1/hipaa/breaches/{id}·PATCH /api/v1/hipaa/breaches/{id}(status,risk_level,individuals_affected,resolution_notes).POST /api/v1/hipaa/breaches/{id}/notifications— record a statutory notification sent. Body:notification_type(individual·hhs·media).
Encryption verification (§164.312(a)(2)(iv) / (e)(2)(ii)) — hipaa:read / hipaa:admin:
GET /api/v1/hipaa/encryption(filter byresource_type/compliant) ·POST /api/v1/hipaa/encryption— Body:resource_type,resource_name,encryption_at_rest,encryption_in_transit, optionalalgorithm/notes/next_verification_date.is_compliantis derived — true only when encrypted both at rest and in transit.PATCH /api/v1/hipaa/encryption/{id}.
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 bystatus:active·terminated) ·POST /api/v1/hipaa/baas— Body:associate_name,signed_on, optionalservice_description/expires_on/document_ref.201returns the agreement,status: active.PATCH /api/v1/hipaa/baas/{id}—service_description,expires_on,document_ref.POST /api/v1/hipaa/baas/{id}/terminate— Body: optionalreason. The agreement stays on the register asterminatedwithterminated_at; it is never deleted.409if 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 byuser_id, orexpired_beforefor lapsed training) ·POST /api/v1/hipaa/training— Body:user_id,course,completed_on, optionalvalid_for_days(default365; omit to never lapse).201returns the record with itsexpires_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 byrole_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 ofview·create·update·export·disclose),justification,reviewed_on, optionalnext_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 ordomain:*/*:read/*patterns),description. Codes cannot shadow a system role.GET /api/v1/authz/roles/{id}·PATCH /api/v1/authz/roles/{id}·DELETE /api/v1/authz/roles/{id}(soft-delete — existing assignments stop granting it), but409while 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 byuser_id) — active assignments, each with its role label andexpires_at. Passinclude_revoked=trueto include revoked grants, each carryingrevoked_at,revoked_byandrevoke_reason— this is how you answer "who removed this access, and why" without a database query.POST /api/v1/authz/assignments— grant a role. Body:user_id, exactly one ofrole_code(system) orrole_id(custom), optionalexpires_at(ISO-8601, must be in the future → JIT).DELETE /api/v1/authz/assignments/{id}— revoke. Optionalreasonquery parameter (≤280 chars) is stored on the revocation record and returned by the listing above; it defaults to "revoked by administrator". Revoking your own assignment is refused (400) to prevent self-lockout — another admin must do it.GET /api/v1/authz/effective-access/{user_id}— what a user can actually do, and via which role: their effective permissions plus asourcesbreakdown (owner bridge / member baseline / each assignment, with expiry surfaced). Platform-scoped permissions (operator:*,assurance:*— Zynth's own operator and assurance planes) are excluded from every source and from the effective set outside the platform-operator tenant, so this view always matches what the engine would decide.
ABAC policies — authz:policies:read / authz:policies:manage:
GET /api/v1/authz/policies·POST /api/v1/authz/policies— Body:name,resource,action,effect(allow/deny),conditions(list of{field, operator, value}clauses, ANDed),priority(lower evaluated first). Policies overlay RBAC with deny-overrides. A clause'sfieldissubject.<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) andenv.date_utc(YYYY-MM-DD). There is no location attribute —env.regionand any otherenv.*field are refused with422, 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 (aresource.*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 bydelegate_id;limit1–500 and thebefore/before_idcursor) ·POST /api/v1/authz/delegations— Body:delegate_id,delegate_type(user/agent),permissions,scope,oversight_mode,max_risk_score(1–20),expires_in_days(1–30).DELETE /api/v1/authz/delegations/{id}revokes — and cascades: revoking a delegation also revokes every sub-delegation made under it (the response reportscascaded, the number torn down). The delete of the human who started a chain no longer leaves the delegatees holding the grant to its expiry.
Bounds on a chain (422). A delegation may be sub-delegated, but only so far: the chain
depth is capped at 3 (a delegation you grant is link 1; the fourth link down the chain is
refused). Separation-of-duties rules are enforced on the delegation path as well as at role
assignment — a delegation that would hand a delegate a permission distinguishing one role in an
SoD pair, while they already hold one distinguishing the other, is refused with the rule's name.
The C4 superset check is always applied, whoever the delegator is.
The band and the scope are bounds, not labels. oversight_mode and max_risk_score govern
what the delegate may do with this grant: at decision time the effective band is the stricter
of the delegation's and the delegate's own registration, so a delegation can narrow authority and
never widen it. Both fields default to their safe values (HUMAN_APPROVE, max_risk_score: 5)
and those defaults are enforced — omit them and the delegate is supervised and clamped to risk 5.
Set them explicitly when you mean an agent to act unattended. An agent delegating to another
agent cannot hand over a band wider than its own (422). And an AUTONOMOUS delegation 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), optionaldescription.201·422if the OAuth resource isn't registered yet — register the audience first ·409namespace collision, or that resource is already bound.POST /api/v1/authz/resources/{resource_id}/permissions— mint one. Body:action, optionaldescription,read_only(defaultfalse),risk(1–15).201{permission_id, code, risk}·409it already exists.DELETE /api/v1/authz/resources/{resource_id}·DELETE /api/v1/authz/resources/permissions/{permission_id}—204, but409while any role still grants it: revoke the grants first, so a deletion can never silently widen or narrow someone's access.
The check API. Both lanes take {"checks": [{permission, principal_id?}, …]} — at most
50 per call (422 past that) — and answer
{results: [{principal_id, permission, allowed, via, reason, obligations}], count}. via is
rbac · policy · owner · default, so a denial explains itself. Only denials are recorded.
POST /api/v1/authz/check— for a principal asking about itself. Any valid session, API key or agent token; no permission needed.principal_idis optional and always resolved to the caller — naming anyone else is403, which points you at the service lane. 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, usePOST /api/v1/authz/act.POST /api/v1/authz/service-check— for a resource server asking about its users. Authenticates with an OPclient_credentialstoken (Authorization: Bearer …) that carries theauthz:checkscope; the tenant comes from the token, not a header.401without a bearer token or with anything that isn't a client-credentials token ·403without theauthz:checkscope ·422if any check omitsprincipal_id, which is required on this lane. Aprincipal_idmay name a member or an agent of the tenant: an agent is answered from its own delegations and oversight band — a supervised agent comes backallowed: truewithobligations: ["human_approval"], and a killed oneallowed: 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 flagAUTHZ_GOVERNANCE_ENABLED. With either missing every route answers404, not403— on every HTTP method, before authentication is considered, byte-identical to a path that was never registered. A404here means "not available on this deployment or plan", not "wrong id".
Access requests — /api/v1/authz/requests. Filing and withdrawing are self-service for
human principals (an agent gets 403); deciding needs authz:assignments:manage.
POST /api/v1/authz/requests— ask for a role. Body: exactly one ofrole_code/role_id,justification(10–2000 chars — the record an auditor reads),requested_days(1–90, default 7).201. A live duplicate ask is returned rather than doubled.422on a malformed ask.GET /api/v1/authz/requests?status=— the approver inbox (authz:assignments:manage).GET /api/v1/authz/requests/mine— your own asks.POST /api/v1/authz/requests/{id}/decide— approve or deny in one call. Body:approve(bool), optionalreason,sod_override(defaultfalse). Approval mints a time-bounded role assignment.403you cannot decide your own request ·409when it already has a verdict, or when a SoD rule blocks the grant ·404.POST /api/v1/authz/requests/{id}/cancel— withdraw your own. Someone else's request is a404, not a403(no enumeration). Unanswered requests expire on their own.
Separation of duties — /api/v1/authz/sod-rules (authz:policies:read /
authz:policies:manage). Rules are create and delete only; there is no edit, because
silently rewriting a control is how a control stops being one.
GET— the tenant's rules plus the shippedtemplates(operate-vs-attest,audit-vs-admin).POST— Body:name, optionaldescription,roles(2–8 distinct role codes that may never be held together).DELETE /{rule_id}.- There is no separate violation-check endpoint by design — the rule is enforced inline at
every grant, so assignments and request decisions both return
409naming the rule. The only way past a blocked grant issod_override: trueon 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_overridedoes 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(defaulttrue), optionalreviewer_permission(defaults toauthz:assignments:manage). Returns the campaign withitem_count— how many grants were snapshotted.422on a bad scope, an unparseable or past deadline, or a duplicate name.GET(authz:assignments:read) — campaigns.GET /{id}— one campaign with its items (the review-item list — the create response reports only the count, asitem_count).POST /{id}/items/{item_id}/decide— attest or revoke. Body:attest(bool), optionalreason. Authorized by the campaign's ownreviewer_permission(or ownership), not a fixed one.403you cannot review your own access ·409if the campaign is closed or the item already decided.POST /{id}/close— close it, returning the decision counts. Withauto_revoke, grants nobody attested are revoked at close: the default outcome of an unanswered review is losing the access, not keeping it.409if already closed.
AI agents
Register and govern AI agents as first-class principals. An agent is created by a human, gets its
own credential (never a human's), and carries a risk profile plus an emergency kill
switch. Gated by agents:read / agents:manage, with the kill switch on its own agents:kill
permission (a security operator can stop a rogue agent without full manage rights).
Risk level → governance band. risk_level is minimal, limited, or high. It derives a band
— a max risk-score ceiling, a minimum oversight mode (AUTONOMOUS / HUMAN_IN_LOOP /
HUMAN_APPROVE), and a confidence floor — that you can tighten but never weaken (high locks
HUMAN_APPROVE). Omitted config fields default from the band. 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;limit1–1000, default 500;countis the page andtotalevery live agent; page on withbeforeandbefore_idfrom the response'snext_before/next_before_id) / register. Body:name,risk_level, optionaldescription,max_risk_score,oversight_mode,confidence_floor. The response includes thecredentialsecret once — store it now; it is never retrievable again.409when the name is already taken in your organization (a re-run setup script hits this constantly; the honest answer is "you already have one", not an error) ·422for a config weaker than the risk band.GET /api/v1/agents/{id}·PATCH /api/v1/agents/{id}— fetch / update (never returns the credential). A config weaker than the risk band is rejected (422). Tightening applies immediately; loosening takes a second person — see the dual-control note below.POST /api/v1/agents/{id}/rotate-credential— issue a fresh credential (old one stops working); returned once. Requires dual control — see below.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), andcontained. 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), optionalreason(≤ 200 chars, recorded on the agent). Engage the emergency stop or release it (agents:kill). Engaging is open to any holder of that permission; releasing is human-only, because it restores a stopped principal's authority.
The containment record is returned on every agent read.
kill_switch_engaged_at,kill_switch_engaged_by,kill_switch_reasonandtokens_revoked_ataccompanykill_switch_engagedonGET /api/v1/agentsandGET /api/v1/agents/{id}. All four survive a release, deliberately — "who stopped this, and why" has to outlive switching the agent back on — so a non-nullkill_switch_reasonmeans "has been stopped at some point", not "is stopped now". Onlykill_switch_engagedsays that.tokens_revoked_atis the containment epoch: every token minted at or before that instant is refused, and it is monotonic, so releasing the switch does not resurrect them.
Is it live right now? Every agent read also returns
liveness("live","idle"or"never") andlive_until. Agents are sessionless — nothing is written when a token is used, andlast_authenticated_atis overwritten on every mint — so that timestamp alone answers the wrong question in both directions.livenessanswers the right one: would a credential this agent currently holds still be accepted? It is"live"only while a token minted at the last authentication is inside its lifetime and nothing would refuse it — so containment, suspension, retirement and thetokens_revoked_atepoch each flip it to"idle"immediately, in the same breath as the refusal itself."never"means the credential has never been used;live_untilis when the window closes (or closed), and isnullonly in that case. It is a statement about the token, not a heartbeat: for "when did this agent last do something", readlast_seenfromGET /api/v1/command/events/by-actor.
Managing an agent identity is human-only, and loosening one takes two people. An AI-agent principal (or an API key) holding
agents:manageis refused403on register, update, rotate-credential, retire and kill-switch release — the alternative is a principal that can rewrite the record it is governed by, mint a successor nothing governs, or take over a peer by re-crededentialing it. Reads (agents:read) are unaffected.
Human-only is a property of the permission. Six permissions are flagged
human_onlyin the catalog (GET /api/v1/authz/catalogreturns the flag on every row):authz:policies:manage,authz:roles:manage,authz:assignments:manage,authz:resources:manage,agents:manageandmcp:servers:manage. A non-human principal — an AI agent or an API key — is refused403for any of them whatever it holds, on every path: a route, an MCP tool, andPOST /api/v1/authz/check(which answersallowed: falsefor 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 ofagents:kill/autonomy:disablemay 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 Acceptedwithapproval_idand no credential; a different person holdingagents:manageapproves it atPOST /api/v1/approvals/{approval_id}/decide, and you then repeat the original call, which returns200and (for a rotation) the new secret. The approval binds to the exact request — one agent, one proposed band — is single-use, and cannot be decided by whoever asked for it. If this deployment has the approvals ledger switched off there is nobody to ask, so both acts answer503rather than proceeding unattended."Tighten but never weaken" is now measured against the agent's current configuration, not against the risk level supplied in the same request. Lowering
risk_levelcounts as a weakening on its own, because it is what would make a later widening legal.
DELETE /api/v1/agents/{id}— retire an agent (agents:manage).204, and immediate on both halves of the agent's authority: the credential is destroyed, so no new token can be minted, and any access token the agent already holds is refused (401) on its next request — the same fail-closed instant as the kill switch, but permanent. The agent leaves the list and every subsequent call for it returns404. Repeating theDELETEon an already-retired agent is404.
Retirement is a soft delete, and that is deliberate. The agent's record is kept, because its audit entries, approval decisions and detections all reference it — destroying that trail would erase the evidence of what a non-human principal actually did, which is the opposite of governing it (EU AI Act Art 12 record-keeping, ISO 42001 8.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,
PATCHit tostatus: "suspended"instead — that is reversible; this is not.
These agents are managed from the AI Agents console (/agents in the app): register, tighten the
risk profile, rotate the credential, engage the kill switch, retire the agent, and delegate authority.
Attestation issuers (federated workload identity)
Availability: ships dormant. While
AGENT_FEDERATION_ENABLEDis off (the default), the whole attestation surface — the registry and the exchange — returns 404.
The registry of external issuers this organization trusts to attest its agent workloads, and
the bindings that map exact attestation claims to pre-registered agents. All endpoints
require tenant:manage; every write is recorded and raises an immediate detection — this
registry decides who can mint agent tokens, the same weight as an SSO connection. Also in the
console: AI Agents → Attestation issuers.
-
GET /api/v1/agents/attestation/profiles— the supported issuer types and, per type, the claims a binding may pin, plus whether an explicitjwks_uriis required:Profile Signature algs Max token validity Explicit jwks_urigithub-actionsRS256 15 min no — public discovery kubernetesRS256, ES256 1 h yes — cluster discovery is not reachable from outside awsRS256 1 h no gcpRS256 1 h no spiffeRS256, ES256 15 min yes — bundle endpoint, not OIDC discovery These are enforced, not advisory: a token signed with an algorithm its profile doesn't declare, or claiming a longer life than its issuer ever mints, is refused; registering a
kubernetes/spiffeissuer without ajwks_uriis a named400at setup time rather than an opaque timeout at the first exchange. -
GET/POST /api/v1/agents/attestation/issuers— list / register. Body:issuer_url(https, no query/fragment — the attestation token'sissmust equal it exactly),profile, optionaljwks_uri. Max 10 issuers per organization. -
PATCH /…/issuers/{id}—enabled: falsestops all exchange for the issuer without deleting its configuration.DELETE /…/issuers/{id}— trust ends whole; bindings go with it. -
POST /…/issuers/{id}/bindings— Body:agent_id(a live agent in this organization),claims(exact string matches drawn from the profile's claim schema). The subject claim is mandatory, wildcards are refused, and a duplicate claim set is409— a binding that matched more than one agent, or whole classes of foreign workloads, is a misconfiguration this API will not accept. Max 25 bindings per issuer.DELETE /…/issuers/{id}/bindings/{binding_id}removes one.
The federated exchange. POST /api/v1/agents/token/federated (unauthenticated — the
attestation is the auth) — Body: tenant (your organization slug — an attestation token
carries no Zynth tenant, so the request names it), subject_token (the attestation JWT your
runtime already has: GitHub Actions OIDC, a Kubernetes projected service-account token, cloud
instance identity, SPIFFE). Returns the same short-lived actor=agent token the classic
path mints — kill switch, risk band and delegations apply unchanged — with no standing Zynth
secret at rest anywhere.
What must hold, or the exchange refuses: the token's iss equals a registered, enabled issuer
exactly; the signature verifies against the issuer's published keys (RS256/ES256 only);
aud equals this deployment's issuer URL — mint your attestation for Zynth, e.g. GitHub's
audience: input; exp − iat 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:
POST /api/v1/agents/token
{
"credential": "…",
"task": {
"task": "Summarise last night's detections", // what it is for, in your own words
"permissions": ["command:read"], // must be a SUBSET of what the agent holds
"budget": 25 // authorized requests before the token is spent
}
}
The response adds a task object echoing what was actually granted:
{
"access_token": "…", "token_type": "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.
permissionsmust be a subset of the agent's own. Ask for anything the agent does not currently hold and the whole mint is refused with403—task scope exceeds the agent's own authority: <codes>— rather than quietly handing you the intersection. A scope that expands to nothing the catalogue knows (a typo) is refused the same way. Fix the request or the delegation; do not retry.- It is a ceiling, not a grant. The declared set is intersected with the agent's authority at the time of each request, so revoking a delegation narrows the task token in the same instant it narrows the agent. A task token can never do more than the agent could.
- Outside the scope is
403. Even for a permission the agent genuinely holds — that is the point. The refusal is recorded and alerts your security operators at the first occurrence, since an agent that chose its own scope minutes ago has no legitimate reason to reach past it. budgetcounts authorized requests, not permission checks: one API call spends at most one unit, and a request that is refused for any reason spends nothing. When it runs out, every further action is403— mint a new token (with a new task) to continue.- The declared task is recorded and shown to humans. It appears on the agent's audit trail and,
when an action needs human approval, in the approver's queue as the request's
justification, so the person deciding sees what it is for beside what is being asked. It is your text, displayed as your text: it informs the decision and grants nothing. - Nothing else changes. The kill switch, the token epoch, the risk ceiling, the oversight mode and the mint's rate limits all apply exactly as they do to an ordinary agent token — a task token is weaker in every dimension and stronger in none.
The practical shape: mint one task token per job your agent runs, with the narrowest permission list and the smallest budget that job needs. A task token for reading detections cannot write a policy acknowledgment even if the agent holds that permission — which is what makes "the agent can only do what we gave it for this run" a mechanical fact rather than a hope.
Rate limits on the mint, all fail-closed (429 + Retry-After, with the usual RateLimit-*
trio): a per-source-IP floor, a failed-attempt brake per credential — only failed attempts
move it, so an agent authenticating normally never approaches it — and a per-organization
issuance allowance charged only once a credential verifies. The defaults are far above normal
use: an agent holding a 15-minute token needs about four mints an hour, and hundreds of agents
can share one egress address. If you see a 429 here, something is re-authenticating on every
request instead of holding its token for expires_in — cache the token.
The MCP surface (POST /api/v1/mcp) carries its own fail-closed budget on every frame,
initialize and tools/list included: per agent, per organization, and per source host. The
per-agent ceiling is roughly an order of magnitude above a busy model-driven tool loop, and an
agent that exhausts its own ceiling does not consume its siblings' organization allowance —
one misbehaving agent cannot throttle your fleet. Over the limit you get 429 with a computed
Retry-After; honour it rather than retrying immediately.
The blast-radius budget and its circuit breaker. Separately from the request budgets above, each agent has a write budget — how many changes it may make in a rolling hour — and a refusal allowance. The write budget comes from your plan (200 changes per agent per hour on the free tier, 2 000 on pro, 20 000 on enterprise) and can be raised for your organization; reads never count against it, and neither does a change that is waiting on a human approval. The refusal allowance is fixed: an agent that is refused around 50 times in an hour is probing for capability or has lost a delegation, and either way a person should look.
Exhausting either does not slow the agent down — it stops it. The agent's kill switch is
engaged automatically, so its very next request is refused at authentication on every surface,
including any token it is already holding. The agent's record shows the reason (blast-radius breaker: mutation budget exhausted or … denial rate exceeded) with no person named as the one
who engaged it, and your Command Center raises a critical alert on the first occurrence.
Releasing it is the same one click as any other kill switch, by anyone holding
agents:kill — but read the reason first: an agent released without fixing what tripped it will
trip again inside the hour. 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.404for an unknown agent,422for an owner who is not a member.GET /api/v1/aigov/assessments(filteragent_id,status) ·GET .../{id}·POST /api/v1/aigov/assessments(draft; Bodyagent_idplus the assessment) ·PUT .../{id}(replace a draft; author only) ·POST .../{id}/submit(author only;422without 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}]. Aprohibitedclassification with an accepting decision, and anunacceptableresidual 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 currentassessment_idandclassification,review_due_on,overdue,stale(the band changed since approval),in_progress;coveredcounts agents with a profile and a current, in-date assessment.GET /api/v1/aigov/export/assessments(optionalagent_id,limitup to 5 000) — every version with its risks;truncatedwhen there were more.GET /api/v1/aigov/disclosures(filteragent_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 —409if 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 (409on an edit). Every disclosure carriescontent_digest; the report carriestruncated.GET /api/v1/aigov/evaluations(filteragent_id,model_name,kind,outcome) ·POST /api/v1/aigov/evaluations(aigov:evaluations:submit; agent-capable, never about the submitting agent itself,403):agent_idand/ormodel_*,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, andsupersedeswithsupersede_reasonfor a correction. The response carriesgate_outcome(passed/failed/no_gate) andgate_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_ENABLEDis off (the default), every route below returns404before the body is even validated.
GET /api/v1/approvals(agents:approvals:read) — the queue. Filters:status(pending·approved·denied·expired·consumed),agent_id,resource_type; newest first,limit1–200 (default 100) and thebefore/before_idcursor for older asks. Every row carriesresource— the concrete thing being decided, not only its type. For a manifest ask that is the resolved plan:manifest_hash,plan_hash,catalogue_version,appliable, andsteps[](kind,target,would,detail, plusgrants[]— the exact permission codes a role would carry or an invitation would confer). It isnullon asks recorded before this field existed, and on paths that supply none; treat absence as "no recorded detail", never as "nothing consequential".GET /api/v1/approvals/{id}— one request, plusapprovals_recorded(multi-approver asks declarerequired_approvals). Needsagents:approvals:read— except that an agent may always poll its own request, which is how it learns the verdict without holding a governance permission.POST /api/v1/approvals/{id}/decide(agents:approvals:decide) — Body:approve(bool), optionalreason(≤2000),break_glass(defaultfalse). Refusals are the design:403agents cannot decide approvals (the principal under judgement never judges),403you cannot decide your own request,403break-glass is owner-only, and403when you lack the required approver permission — which the message names.409if the request already has a verdict or has expired. An approval is spent once and binds to the exact action asked about.
Agent-authored content — /api/v1/approvals/content (same flag, same 404). The
sibling-service lane for the publish loop, keyed on an exact content_hash rather than an
approval id: POST /request (content:draft:write — an agent may only request its own
review), POST /feedback (the decisions and, on a denial, the reason the agent learns
from), POST /consume (content:read — spend the approval for those exact bytes; 409
with a structured {state, message} so a caller distinguishes "not decided yet" from
"denied" without a second round trip), POST /reversal (content:review — record that a
published piece had to be pulled), and GET /track-record/{agent_id} (content:read — the
earned-autonomy evidence: lower_bound against the bar, reversals_30d, and every unmet
criterion named). See Agent-authored content.
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), optionaldescription,max_calls_per_minute(default 30),max_calls_per_day(default 500).GET·PATCH·DELETE /api/v1/mcp/servers/{server_id}— read, reconfigure (enabled: falsestops calls without deleting), unregister.PUT /api/v1/mcp/servers/{server_id}/tools·DELETE /api/v1/mcp/servers/{server_id}/tools/{tool_name}— the per-tool allow-list and its risk scoring; atools/listreply is filtered down to it. Body:tool_name,risk_score(1–20), optionaldescription,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 returnsread_onlyper 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 a403naming the reason.404unknown server ·502when the upstream itself fails, so an upstream outage never reads as a denial ·503withRetry-Afterwhen 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 answered503) ·429the gateway's own request budget (per agent, per organization, per source host; fail-closed; charged on every frame,initializeand unknown servers included) with the standardRateLimit-*+Retry-Afterheaders.
Payment security
Switched off on the managed service (
PAYMENTS_STEPUP_ENABLED; ask us to enable it), so every/api/v1/payments/*route answers404. 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_ENABLEDis off (the default), every route below returns404— the whole surface, on every HTTP method, before authentication is considered. The refusal is byte-identical to a path that was never registered, so a dormant deployment is indistinguishable from one built without the feature.
payments:verify gates the two service-side calls (consume, risk context). Opening and
satisfying your own elevation needs no permission beyond an authenticated session.
-
POST /api/v1/payments/step-up— open an elevation bound to one exact operation. Body:action([a-z0-9_.:-]+), optionalcontextobject. Returns the elevation plusmethods— the factors this user has actually enrolled.400 no_factorwhen they have none (a password-only user cannot step up). Floats incontextare refused: amounts belong in minor units, and the error names the rule. -
POST /api/v1/payments/sca/challenge— the payment variant. Body:amount_minor(positive integer, minor units),currency(supported ISO-4217 code),payee,payee_name. Returns the elevation,methods, anddisplay— the payer-facing sentence rendered by the server from the bytes it bound ("Confirm PHP 149.99 to Acme Store"). Show it verbatim. An unsupported currency is a400listing the supported set rather than a guessed decimal position. -
POST /api/v1/payments/step-up/{id}/webauthn-options— mint a passkey challenge for a pending elevation (held server-side, bound to that one ask). -
POST /api/v1/payments/step-up/{id}/verify— satisfy it. Body:{"method":"totp","code":…}or{"method":"webauthn","credential":…,"credential_raw_id":…}.401on a bad code or assertion, which burns an attempt; past the cap the ask is dead, not paused. For SCA,403 insufficient_factorswhen the session plus this factor do not reach two factor categories — the message names the shortfall and points at the passkey route. -
POST /api/v1/payments/elevations/consume— spend it, once (payments:verify). Body:elevation_id,user_id,sid,action,contextexactly as bound. Returns the attestation; for SCA that includes the transaction as bound, the sentence shown, and which factor categories were proven. Refusals — all meaning do not proceed:409 fingerprint_mismatch(any bound detail changed, including the payee's display name),409 not_spendable(already used, expired, or never approved),409 session_gone(the session that earned it has ended, or sessions were revoked since). -
GET /api/v1/payments/risk/context?user_id=…&sid=…— risk inputs (payments:verify). Returns a coarsescoreandbandwith every factor that produced them — account age, session age, factor strength, open detections against the user and the source IP — plusunavailable(factors not computed for this request, disclosed rather than omitted) anddegraded(an input could not be read and was scored worst-case, so the score is a floor). Never an exemption verdict: Zynth does not rule a payment exempt from strong authentication.
Command Center
The security-operations read plane: the raw event stream, the tamper-evident audit chain,
detections, findings, incidents, and compliance evidence. All under /api/v1/command, all
gated by command:read, with the three mutations needing command:operate. Scoping is
automatic — a platform-operator tenant reads across organizations, everyone else reads only
their own. Also the Command Center console in the app. 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+severitynarrow together). A non-platform caller naming another organization'stenant_idgets403. Aseverityoutside the vocabulary is a422, never an empty page.- Paging. Every list under
/commandpages backwards with a keyset cursor: the response carriesnext_beforeandnext_before_id(null on the last page); pass them back asbeforeandbefore_idfor the next, older page. The audit log pages bybefore_seq. No offset, so a page never skips or repeats rows as new ones arrive. actor_idis the per-principal drill-down — "what has this agent been doing". It matchesactor.idexactly, whatever kind of principal that is: a user or agent UUID, but also a named service (alertmanager) or a credential prefix, because those are real actors in this stream. 1–64 characters (the column's width); outside that is a422.actor_typeis one ofhuman·agent·service·system. Anything else is a422naming the field, rather than a silently empty page. The value is derived from the authenticated principal, never assumed: a session user ishuman, an AI agent isagent, an API key isservice(a key is an integration, not a person), and platform-originated events aresystem. 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 toserviceon every surface — not to a person.- Both narrow within your own scope: they never widen it, so an
actor_idthat also acts in another organization still returns only your events for it.
- Paging. Every list under
GET /api/v1/command/events/by-actor— event volume per principal, over the whole window. Parameters:actor_type(the same four-value vocabulary; defaultagent) anddays(1–90, default 7). Returns one entry per principal —total,by_outcome(every outcome the window contains),elevated(events at or abovewarn),first_seen/last_seen— plustotal/shown/hidden, because the roster is bounded and tells you what it cut. Ordered by volume, ties broken onactor_id.- Not derivable from
GET /command/events. That endpoint is capped atlimit, so counting its rows answers "…among the most recent N" while looking exactly like a total. This one is aGROUP BYover the window. - Attribution is by the envelope's actor, never by the action name.
content.reversal_recordedis titled "Agent content was unpublished" and its actor is the human reviewer who unpublished it, with the agent in the metadata — it counts against the reviewer, which is correct. - Scoped like every other read here: your organization's principals, and a platform operator's every tenant.
- Not derivable from
GET /api/v1/command/events/stream— Server-Sent Events (text/event-stream). Opens at the live tip and tails from there; it is not a backfill, so page history from the list endpoint above. No query parameters. Quiet periods send the SSE comment: keepaliveso proxies don't reap the connection, and frames carry noid:, so there is no Last-Event-ID resume — reconnect and re-page if you need the gap. It authenticates from theAuthorizationheader like every other endpoint, which means a browser's bareEventSourcecannot connect: use a fetch-based SSE client.
Audit
GET /api/v1/command/audit— the append-only audit log. Filters:action,actor_id,since,until,before_seq(page backwards; the response'snext_before_seq),limit(default 100, max 1000). Each entry carries itscompliance_tagsand itsentry_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}.verifiedisnullandstaleistrueuntil the first walk has run;staleistruewhen the last walk is more than two hours old;broken_seqnames the first entry that did not match. A broken chain also raises a critical detection the moment it is recorded. Deliberately narrow:command:readplus platform-operator membership (403otherwise).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 withstale: false.command:operateplus 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_idfor events,before_seqfor 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), optionalsince. The same history up to 5 000 rows each, withtruncatedset 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— filtersseverity,rule_id,status(open·acknowledged·resolved),actor_id,limit(default 100, max 1000),before/before_id; each row is a deduplicated group withcount,first_seen/last_seenand status.actor_idis "what was raised about this principal": pass the bare id and the server matches every group-key spelling that names an actor (actor:<id>andagent:<id>), so a rule that changed grouping does not appear to have lost its history.GET /api/v1/command/detections/summary?days=(1–90, default 7) — roll-up by severity and by rule, each with an open count.POST /api/v1/command/detections/{id}/acknowledge(command:operate) — Body:status(acknowledged|resolved, defaultacknowledged), optionalnote.404unknown.GET /api/v1/command/findings— analyst-style findings over grouped detections. Filtersseverity,source(defaultanalyst),limit(default 50, max 500). Read-only. Each analyst finding'ssummaryis 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, optionaldescription,detection_ids(≤100),assignee.201;422naming any detection id that is malformed or not visible to you.GET /api/v1/command/incidents— filtersstatus,severity,agent_id(the incidents an agent is linked to),serious(true/false),limit(default 100, max 1000) and thebefore/before_idcursor. Each incident carriesagent_idsand 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 ofstatus(open·investigating·contained·resolved·closed),assignee,containment,remediation,root_cause.404unknown.- AI incidents (
command:operate, people only):PUT /api/v1/command/incidents/{id}/agents(agent_ids, live agents of the incident's organization,422otherwise) ·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,422without one;409if already serious) ·POST .../{id}/regulator-report(reported_atwith a timezone, not beforeaware_atnor in the future,422otherwise;report_ref;409unless serious and unreported; the incident keepsreported_late).GET /api/v1/command/detections?family=agentsnarrows 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 anunresolvedblock that deliberately ignores the window (it reports all-time backlog and the age of the oldest open incident — the number to alert on).
Compliance {#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=…—frameworkis required (422listing the known set). Optionaldays(1–365, default 90) andexport—export=trueraises 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, thegapslist,satisfied,attestedandapplicable, and per control itsstatus,detail,closableand audit samples with their hashes (controls_with_evidenceandtotal_evidencecount the audit rows that count). An export adds amanifest:digest(SHA-256 of the response's canonical JSON without the manifest, exactly as Python'sjson.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=True)writes it; the same digest is recorded on thecommand.evidence_generatedaudit entry) andaudit_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 withsatisfied,attested,not_applicableand itsreadiness_pct). Each control has astatus: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), orgap, withdetailandclosable(in_platformorattest). 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_pctis (satisfied + attested) over the controls that apply (applicable), withsatisfied,attested,not_applicable,gaps, open incidents (for an AI framework, only those linked to an agent or serious) and an overallposture.GET /api/v1/command/compliance/history?framework=…(days1–365, default 90) — readiness as it stood each day: the worker's dailysnapshots(day,readiness_pct, the counts by status, each control's status), oldest first.GET /api/v1/command/compliance/attestations(optionalframework) ·POST …/attestations(compliance:admin, people only;control_id,statement20–4 000 characters,owner_idan active member,evidence_refs,review_due_onwithin the next 366 days;422for an unknown control or one judged from the registers;409if attested at the same moment; it supersedes the attestation in force) ·POST …/attestations/{id}/revoke(reason). Each attestation carriesin_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 isISO42001_AIIA(clause 8.4) and the operation evidence once filed under 8.4 isISO42001_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).
| Field | Values |
|---|---|
kind | rate_limit_exceeded, csrf_rejected, input_rejected, auth_failure_burst, suspicious_request, prompt_injection_suspected, custom |
subject | free text, ≤ 1000 chars |
detail | free text, ≤ 5000 chars |
metadata | an object |
severity | low · 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,overduepast 100 days, its measuredrto_seconds/rpo_secondsand 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, optionalnotes;scripts/restore-drill.shprints 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_ENABLEDis off (the default) the engine records what it would have done and executes nothing — the endpoints below all serve, and the ledger only ever containsshadowrows.
Four permissions, split so an incident responder can stop the engine without gaining the power
to reconfigure it: autonomy:read, autonomy:manage, autonomy:approve, autonomy:disable.
The superset rule. Approving — or undoing — an action requires autonomy:approve plus
the permission to perform that action yourself (tenant:manage for session revocation). An
approver can never unlock authority they do not hold. An action with no declared approver
permission can be neither approved nor reverted by anyone: fail-closed by default.
-
GET /api/v1/autonomy/decisions— the decision ledger (autonomy:read). Query:mode(filter; an unknown mode is422),limit(1–200, default 50). Returns{decisions, viewer_scope}.modeis one ofdry_run,shadow(the engine decided not to act, and the row says why),proposed,denied,expired,executing,executed,reversed. Each decision carries the plan's one-sentenceeffect, itsrule_id/action_id/blast_class/target_id, the verdict (adjudication,decided_by,decision_reason), and three server-computed flags:actionable(awaiting a verdict and still inside its window),revertible, andunattended(executed with no human in the loop).viewer_scopeistenantorplatform— a platform operator reads every organization's decisions here, while caps and graduation below are always their own. -
POST /api/v1/autonomy/decisions/{id}/adjudicate— approve or deny, then execute if approved. Body:approve(bool), optionalreason(≤1000 chars — optional on approval, and worth writing: it is the oversight record an auditor reads). Returns{decision, executed, outcome, reason}.409if the decision already has a verdict or is not awaiting one;403if you lack the underlying permission. The verdict is recorded before execution is attempted, so a decision is never lost to a downstream failure — and every guard is re-checked at execution time, including a fingerprint of the exact effect that was approved. If the world moved between the yes and the act, the decision expires unexecuted rather than acting on facts that have aged out (executed: false, with the reason). -
POST /api/v1/autonomy/decisions/{id}/revert— undo an executed action. Body:reason(required, 1–1000 chars — a responder who cannot say why in one line probably wants the kill switch instead). Returns{decision, reverted, detail}.409, with nothing changed, when the effect was superseded — e.g. a person revoked the same sessions again after the engine did; undoing then would silently discard their decision. Reverting session revocation restores the previous state, so a wrongly-signed-out user's existing devices resume working; sessions ended by anything else stay ended. A reversal feeds the circuit breaker: one in the trailing 30 days holds that class below unattended operation until the window is clean. -
GET /api/v1/autonomy/metrics— engine health (autonomy:read):by_modecounts, execution latency (mean + p95 over 30 days, withunattended_30d), per-class cap utilisation (rate_used/rate_cap,spread_used/spread_cap), andpending— count, how many are still actionable, andoldest_age_seconds. That last number is the one to alert on: a rising oldest-age means proposals are not being adjudicated, so no record grows, nothing graduates, and Recommend has quietly become shadow mode with extra steps. -
GET /api/v1/autonomy/graduation— per-class gate status (autonomy:read):eligible, the confidencebarand yourlower_boundagainst it,approvals/total,span_daysvsmin_days,reversals_recent, thecatalogueversion the evidence is scoped to, every unmet criterion named, andapprovals_needed— ≈how many more clean approvals would clear the bar (null= not reachable soon). Always your own organization: a track record is a property of your own adjudications and has no cross-organization view.disable_principalis absent by design — its ceiling is Recommend, so it has no graduation path. -
GET /api/v1/autonomy/kill-switch(autonomy:read) ·POST /api/v1/autonomy/kill-switch(autonomy:disable) — the emergency stop. Body:enable(bool),hours(1,8or24), optionalreason. The stop is stored as an expiry instant and auto-re-arms; the read returns{engaged, scope, until, indefinite, reason, offered_hours}wherescopeisglobal(the deployment gate) ortenant. Omittinghoursmeans indefinite, which is400without areason— the unbounded option is deliberately the awkward one, because the real-world failure is the switch nobody turns back on. Every change raises an always-on critical detection: disabling is both a legitimate emergency action and an intruder's first move, and the platform makes it visible rather than guessing which.
Autonomy levels are organization settings — autonomy.level_contain_source,
autonomy.level_contain_non_human, autonomy.level_raise_assurance,
autonomy.level_revoke_access, autonomy.level_disable_principal — all defaulting to
manual. Each key accepts only the levels its class ceiling permits: contain_non_human
and revoke_access stop at graduated, disable_principal at recommend, and anything past
it is a 400 naming the allowed set.
contain_non_human — stopping an AI agent. The one response class in which a false
positive harms no person: the agent's kill switch is engaged and every token already issued
to it stops working, while nobody loses access to their own account. Reverting it releases the
switch — the agent works again immediately — but never resurrects the tokens minted before it,
and the revert is refused to any non-human caller (403), because handing a non-human
principal its authority back is the same act as releasing a kill switch by hand, which is
people-only. Its graduation bar is 0.90, not revoke-access's 0.95. The one cap not expressible
as a setting is inducibility — a detection an attacker can deliberately trip holds its
response at recommend whatever its class is configured to, because that is a per-detection
judgement rather than a per-class one.
GET /api/v1/autonomy/drillalso exists and is platform-operator only — it reports a soak drill running in a disposable tenant, and is not part of the tenant-facing surface.
GET /api/v1/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 withsince/until(422when the window ends before it starts) and paged (limit1–500, default 100, and thebefore/before_idcursor). Each entry carriesjournal_id,action,resource_type,resource_id,reversible(with anirreversible_reasonwhen it is not), androlled_back_at/rolled_back_byonce undone. - Roll back —
POST …/rollbackwith an optional window{since, until}; omit both to undo every pending mutation. Needsagents:killand 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:
{
"user_id": "…",
"tenant_id": "…",
"role": "owner",
"is_owner": true,
"tenant_isolation": "pooled",
"population": "workforce",
"email_verified": true
}
401 if the token is missing/invalid or the membership is no longer active.
Self-service — /api/v1/me/*
Your own account, with no permission beyond a valid token: what a user may inspect and undo
about themselves. These back the Account area of the app. See also
GET /me/permissions and connected apps.
Sessions — /api/v1/me/sessions
GET— every live session, each flaggedcurrentso a user can tell which one they are reading this on.DELETE /{sid}— sign one device out.204·404for a session that isn't yours or is already dead — the two are indistinguishable on purpose. Revoking your current session is allowed and behaves like logout. To end them all at once, use/auth/sign-out-all.
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_idto 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 ownGET /me/sessionsshows the same client fields for your sessions.truncatedistrueif 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).404for 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/sessionsor/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·404for a delegation that isn't yours, already revoked, or not to an agent. Revoking authority you delegated is self-service by definition; granting it is not — that stays on the governed admin surface.
Profile — /api/v1/me/profile
GET—{email, name, given_name, family_name, picture, editable}.editableisfalsewhen your organization provisions you through SCIM — render the form read-only rather than letting an edit fail.PATCH— Body:name,given_name,family_name.emailandpictureare not editable here (they are sourced from your sign-in identity).409when the profile is identity-provider-managed — edit it in the directory ·422if a name is too long or carries control characters.
GET /.well-known/jwks.json
Public. Returns the JWKS document of RS256 public signing keys — up to three, in order:
the retiring previous key (verify-only), the active signing key, and a staged
next key. Only active is present in steady state. Select by kid rather than
position or count. See Tokens & sessions.
GET /.well-known/security.txt
Public, text/plain. The RFC 9116 security-contact document — where to report a
vulnerability. Fields published: Contact, Expires, Preferred-Languages, Canonical, and
Policy when a public site is configured.
Config-derived, so a self-hosted install advertises its own security team rather than
Zynth's (SECURITY_CONTACT). Policy comes from PUBLIC_SITE_URL and is omitted when that
is unset — the disclosure policy is a marketing page, and a self-hosted install has no such
page, so the field is left out rather than pointing at a URL that 404s.
On the managed service this document is reachable at both
auth.zynthmedia.com/.well-known/security.txt
and zynthmedia.com/.well-known/security.txt
— the same document, so a researcher scanning either origin finds it. Canonical names the
application origin as the authoritative location.
GET /api/v1/pricing
Public, no authentication — this is what the public pricing page renders. Returns the published plans of the managed service:
{
"currency": "USD",
"note": "…",
"plans": [
{
"plan_code": "pro", "name": "Pro", "price": "$49", "period": "per month",
"blurb": "…", "cta_label": "Start free", "cta_href": "…",
"highlights": ["…"], "popular": true, "published": true,
"features": ["sso.oidc", "scim", "…"],
"caps": { "mau": 10000, "api_calls_monthly": null }
}
]
}
The presentation fields (price, blurb, highlights, CTA) are editable content, but
features and caps are derived from the entitlements catalog — the same values
GET /api/v1/entitlements resolves — so published pricing cannot claim a capability a plan
doesn't actually grant. A null cap means unlimited. On a self-hosted install this endpoint
reflects that install's own (usually empty) pricing content, not Zynth's.
Token pair shape
{ "access_token": "…", "refresh_token": "…", "token_type": "bearer" }
In cookie mode, refresh_token is null — the refresh token travels
only as the httpOnly cookie.
Cookie transport (browsers)
Browser clients can keep the long-lived refresh token out of JavaScript-readable storage:
send use_cookie: true on signup / login / mfa/challenge / change-password, and the
refresh token is set as an httpOnly, Secure, SameSite=Strict cookie scoped to
/api/v1/auth (the body's refresh_token is null). Then call refresh with an empty
body — the cookie authenticates and is rotated on every refresh; logout (and any failed
cookie refresh) clears it. API/SDK clients that omit use_cookie keep the body transport
unchanged.
OpenID Provider (OAuth 2.1 + OIDC Core)
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-replaydetection — it is treated as theft, not a soft error.
The hosted login & consent endpoints
/authorize parks the request and sends the browser to Zynth's hosted screens; these are what
those screens call. They are not part of an RP's integration — an OIDC library never touches
them — but they are documented because a self-hosted install may replace the hosted UI. All
three authorize legs require the end user's own session (no special permission).
GET /api/v1/oauth/authorize/request/{request_id}— what the consent screen renders: the client, the scopes asked for, and whether this user has already consented.POST /api/v1/oauth/authorize/complete— Body:request_id. The user said yes: records the consent and returns the redirect carryingcode+state.POST /api/v1/oauth/authorize/reject— Body:request_id. The user said no: returns the redirect carryingerror=access_denied, so the RP is told rather than left hanging.POST /api/v1/oauth/logout/complete— Body:request_id. Confirms a parked RP-initiated logout: ends the session, fans out the back-channellogout_tokens, and returns thepost_logout_redirect_uri.
POST /api/v1/oauth/token
Form-encoded (application/x-www-form-urlencoded). Confidential clients authenticate via
HTTP Basic (client_secret_basic) or client_secret_post; public clients send client_id
and rely on PKCE.
client_secret_basicencoding (RFC 6749 §2.3.1). Inside the Basic credential the client id and secret are eachapplication/x-www-form-urlencodedbefore being joined with:and base64-encoded. Strict libraries — oauth4webapi, and openid-client on top of it — encode the_in everyzc_…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 seeinvalid_client/ "unknown or disabled client" from a library that authenticates elsewhere, you are on a version that predates this decode — switch that client toclient_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. Returnsaccess_token,id_token(whenopenidis granted), andrefresh_token(when the client has that grant).refresh_token—refresh_token. Rotates (new refresh token each time, old one invalidated); reusing a rotated token revokes the whole grant session.scope=may narrow (never widen) the grant.client_credentials— machine-to-machine, confidential clients only. Returns an access token only (no id_token/refresh/user);subis theclient_id.urn:ietf:params:oauth:grant-type:device_code—device_code,client_id(RFC 8628). Answersauthorization_pending,slow_down,access_deniedorexpired_tokenuntil the person has decided; then the same tokens as the code grant (id_tokenwithopenid, without anonce;refresh_tokenwhen 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:
{ "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:
| Claim | Released |
|---|---|
sub | always |
tid, tenant_slug | always (see tenancy claims — tid is the key, the slug is display-only) |
email, email_verified | with the email scope |
name, given_name, family_name, picture, updated_at | with the profile scope (omitted when not stored) |
roles (or roles_truncated) | with the roles scope, when the client is also allowed it (role claims) |
On the
profilescope. OIDC Core §5.4 definesprofileas a set of possible claims, and §5.3.2 requires that claims which are not available be omitted rather than guessed or emitted empty. Profile attributes are sourced authoritatively — SCIM provisioning and enterprise SSO overwrite what the directory asserts on every sync/login, social login fills blanks, and non-IdP-managed users may edit their names in the console;claims_supportedin discovery always reflects exactly what this deployment emits, so build against that rather than against §5.4's full list.
GET /api/v1/oauth/logout
RP-Initiated Logout 1.0 — the discovery end_session_endpoint. Params: id_token_hint
(required — a Zynth-signed id_token; attributes the request to a client so no open
redirect can form), post_logout_redirect_uri (must be pre-registered on the client, exact
match), state. Ends the session and, on confirmation, fans out back-channel
logout_tokens to every other RP holding a grant for that user. 400 on a missing/invalid
hint or an unregistered redirect. Clients register a backchannel_logout_uri to receive the
signed logout_token (best-effort by contract).
The id_token
A distinct RS256 JWT (never the access token), verifiable offline against
JWKS. Claims: iss (your issuer), sub, aud (= client_id), azp, exp,
iat, nonce (echoing the authorize request), auth_time, at_hash, sid (session id,
used by back-channel logout), and amr — how the user actually authenticated (pwd,
mfa, webauthn, social, sso, mlink) for your own step-up decisions. email /
email_verified are included when the email scope is granted.
Tenancy claims — always present, not scope-gated, because a multi-tenant application cannot function without knowing which organization authenticated:
| Claim | Meaning | Stability |
|---|---|---|
tid | The organization's immutable UUID | Permanent. The only safe key. |
tenant_slug | Human-readable slug, e.g. acme-inc | Mutable — display only. |
⚠️ Key off
tid, nevertenant_slug. A slug can be changed by an administrator; anything that stores, authorizes, or routes on it will silently break when that happens. Usetenant_slugfor display, and only for display.
Both claims are also returned from /userinfo, so either artifact works as your
profile source.
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_secretandsecret_created_atare).POST /api/v1/tenants/oauth-clients— register a client. Body:name,client_type(confidential|public, immutable after creation),redirect_uris,post_logout_redirect_uris,backchannel_logout_uri,grant_types(default["authorization_code"]),scopes,enabled. Confidential clients get a show-onceclient_secretin the201response — store it now, it is never retrievable again. Redirect URIs are validated (absolute, no wildcards, no fragments,httpsexcept loopbackhttp). 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_tokenrequiresauthorization_codeor the device grant;authorization_coderequires ≥1 redirect URI; public clients can't useclient_credentials.scopesmay includeroles(releases role claims) and, for a confidential client withclient_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_typeis immutable, grants re-validate against it).POST /api/v1/tenants/oauth-clients/{id}/secret— rotate a confidential client's secret; the new plaintext is returned once (409for public clients).DELETE /api/v1/tenants/oauth-clients/{id}— unregister;/authorizeand/tokenrefuse the client immediately.
OAuth resources — audience-bound tokens (tenant admin)
The RFC 8707 resource registry: the set of APIs this organization may have tokens minted
for. Registering an identifier is what authorizes the OP to mint a token whose aud is that
identifier rather than the platform audience — so the token verifies only at that API and
is rejected by every other resource and by the Zynth API itself, including /userinfo
(a per-request UserInfo revocation check does not work with resource-bound tokens; see
the trade-off). That is the confused-deputy
defence, and it is what makes 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) andhttps— plainhttponly for loopback hosts (localhost,127.0.0.1,[::1]), because tokens minted for a plain-http network host would travel in clear. Max 512 chars, 50 resources per organization.PATCH /api/v1/tenants/oauth-resources/{id}— rename, orenabled: falseto stop minting for it without deleting the record.DELETE /api/v1/tenants/oauth-resources/{id}— unregister.
Also manageable in the console: Settings → Organization → OAuth Resources — register, disable (minting refused, record kept) and remove, with the same audit + detection posture.
Once registered, clients name it with the resource parameter at /authorize and
/token. Verifying such a token at your own service is the
MCP authorization guide's
procedure — it is not MCP-specific; set audience= to your identifier instead of the platform
audience. See also Verifying tokens.
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, andscopes— the ceiling this organization is willing to grant it. Leavingscopesempty 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-channellogout_tokenis sent, and/tokenrefuses further refreshes for that user from that moment.204·404if not the caller's consent.
Webhooks
Outbound HTTP notifications for events in your organization. All endpoints live under
/api/v1/tenants/webhooks, require tenant:manage, and are refused with 403 while
the tenant sits in bootstrap quarantine. Walkthrough, including verification
code: Webhooks.
GET /api/v1/tenants/webhooks/events— the publishable event catalogue, each with a summary. Only identity-facing events are publishable; Command Center, operator and control-plane events are structurally never delivered off-platform.GET /api/v1/tenants/webhooks— registered endpoints (metadata only — never the secret): URL, subscribedevent_types,enabled,disabled_reason,consecutive_failures,secret_rotated_at.POST /api/v1/tenants/webhooks— register one. Body:url,description,event_types.201returns thesecretonce — this is the only response that ever carries it.400for an unknown event type (no wildcards; the error points at/events) or a URL the egress policy refuses (https only, no credentials, no IP literals, no loopback/blocked hosts) ·409past 25 endpoints per organization.PATCH /api/v1/tenants/webhooks/{id}— changedescription,event_types,enabled. The URL is not editable — a new destination is a new endpoint. Re-enabling also clears the failure streak.404unknown.POST /api/v1/tenants/webhooks/{id}/rotate-secret— returns the new secret once. The previous secret keeps verifying until the next rotation, and during the overlap each delivery is signed with both — so you can roll without dropping a delivery.404.DELETE /api/v1/tenants/webhooks/{id}—204·404.POST /api/v1/tenants/webhooks/{id}/test— a real, signedzynth.testdelivery sent synchronously. Returns{delivered, status_code, error, blocked_by_policy}—blocked_by_policydistinguishes "your server said no" from "we refused to call that URL". The probe is never written to the delivery log.404.GET /api/v1/tenants/webhooks/{id}/deliveries?limit=(default 50, capped at 200) — the delivery log, newest first, with attempt counts, last status code andnext_attempt_at.404on an unknown endpoint id, like its siblings — an empty list always means "no deliveries yet", never "wrong id".POST /api/v1/tenants/webhooks/{id}/deliveries/{delivery_id}/replay— re-queue one delivery, keeping the sameZynth-Event-Idso a correct consumer de-duplicates it.404.
Delivery contract. At-least-once and unordered — de-duplicate on Zynth-Event-Id.
Each request carries Zynth-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 over
"<t>.<raw body>" with your secret; verify against the raw bytes, compare in constant
time, and reject a timestamp outside ~5 minutes. During a rotation overlap the header carries
two v1= values — accept if either matches. Failures retry with exponential backoff, and
an endpoint that fails persistently is auto-disabled with a disabled_reason; a single
success resets the streak.
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: falsefor a member who has since left or changed address), its webhook endpoint (endpoint_id, andendpoint_enabled) and its last delivery result (last_result:delivered,queued,failedorcapped, with a classifiedlast_error).POST /api/v1/alert-channels— Body:name, optionalkind(emailorwebhook; defaultemail) andmin_severity(info,warnorcritical; defaultcritical), and eitherrecipient_user_ids(email) orendpoint_id(webhook), never both.201·403not on the plan ·409past 20 channels per organization ·422when 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}— changename,min_severity,enabled, andrecipient_user_ids(email) orendpoint_id(webhook). Redirecting a channel (a different recipient set or endpoint), disabling it or raising its floor mails every owner.404unknown.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 signedalert.raisedto the endpoint (webhook). Returns{recipients, sent, error};erroris 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 (429either 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:
{
"plan_code": "pro",
"features": ["scim", "sso.oidc", "tokens.dpop", "..."],
"caps": { "mau": 10000, "agents": 50, "api_calls_monthly": 5000000 },
"source": "plan",
"degraded": false,
"licence": null
}
features— granted capability codes. A premium endpoint your plan lacks returns403naming the feature. Core IAM (auth, MFA, tokens, sessions, audit, the OpenID Provider) is never gated and never appears here as a requirement.caps— ceilings;nullmeans unlimited. Authentication is never denied by a cap: sign-in, MFA, sessions and token issuance for existing users always work. Most caps raise a warning and a commercial conversation. Two gate creation and do refuse at the ceiling:api_calls_monthly(API-key requests get429withRateLimit-Policy: monthly-quota) andmembers(new invitations refused; pending invitations count against the seat total). See Plans and entitlements.source—plan(managed SaaS) orlicence(self-hosted install).degraded—trueonly on a self-hosted install whose licence is expired beyond grace or unreadable: premium features fall to the free floor while core identity keeps working.licence— self-host only:{status, licensee, expires_at, maintenance_expires_at}wherestatus∈valid·expiring·grace·degraded·invalid.nullon managed SaaS.
Billing (tenant self-service)
Subscription and metered usage for your own organization. Requires tenant:manage.
Card data never reaches 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'smaintenance_expires_atagainst the release'spublished_at.)429— rate-limited (per credential and per source IP); retry later.503— the server has no object storage configured. This is an install-side misconfiguration, not an entitlement problem — tell your operator; it is not a404precisely so it can't be mistaken for "no such release".
What "maintenance" buys. You can download every release published while your maintenance term was active, forever — a lapsed term does not remove access to what you were already entitled to, it only stops access to releases published after it lapsed. See Plans, entitlements & usage.
Enterprise SSO & SCIM
Switched off on the managed service (
SSO_FEDERATION_ENABLED; ask us to enable it). On the managed service every route below answers404, 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 atGET/PUT/DELETE /api/v1/tenants/sso/oidc, withPOST /api/v1/tenants/sso/oidc/testto prove a connection (discovery, credentials, reachability) before anyone signs in through it. OIDC back-channel logout at…/auth/sso/oidc/{tenant}/backchannel-logoutrevokes sessions. → Enterprise SSO (OIDC) - Completing an SSO sign-in —
POST /api/v1/auth/sso/completeredeems the one-time code from either protocol's callback. Returns exactly what password login returns: a200token pair, or the MFA challenge (mfa_required: true,mfa_token) when the account has TOTP enrolled — enterprise SSO does not bypass an enrolled second factor. - SAML 2.0 —
POST …/auth/sso/saml/start(not tenant-scoped: the tenant travels in the body) plus…/auth/sso/saml/{tenant}/{acs,metadata,slo}; tenant-admin config atGET/PUT/DELETE /api/v1/tenants/sso/saml, withGET /api/v1/tenants/sso/saml/sp(this tenant's SP entity id, ACS and SLO URLs — what you paste into the IdP) andPOST /api/v1/tenants/sso/saml/import-metadata(paste the IdP's metadata XML instead of transcribing a certificate by hand). → Enterprise SSO (SAML) - SSO-required enforcement —
GET/PUT /api/v1/tenants/sso/enforcement(tenant:manage). Once required,POST /api/v1/auth/loginanswers403for non-owner members. - SCIM 2.0 —
/scim/v2/{Users,Groups,ServiceProviderConfig,ResourceTypes,Schemas}(bearer token per tenant). Admin surface at/api/v1/tenants/scim(tenant:manage):GET/PATCHfor the configuration,POST /api/v1/tenants/scim/tokento mint or rotate the provisioning token — the plaintext is returned once, alongside thebase_urlto configure, and unlike webhook secrets rotating immediately invalidates the previous token, so update the IdP in the same sitting — andDELETE /api/v1/tenants/scim/tokento revoke it outright (204;404if none exists), which stops provisioning without deleting the configuration. Note that a SCIM-managed membership can no longer be edited through the members API — it answers409. → Directory provisioning (SCIM)
Password policy
NIST 800-63B, length-first: 12–128 characters, and not a well-known common password. No forced character-class rules. Passphrases are encouraged.