Zanii ID — HTTP API reference
Issuer https://ids.zanii.agency. Protocol endpoints are unversioned (spec-stable);
only the management API carries /manage/v1. Errors follow RFC 6749 §5.2:
{ "error": "invalid_grant", "error_description": "code expired or already used" }
MCP and tool servers (AD-66)
If you are wiring an MCP server or any other API, this is the section to read first.
- Metadata:
/.well-known/oauth-authorization-server(RFC 8414) as well as/.well-known/openid-configuration. Both name the same endpoints; the first is the one plain OAuth clients look for. - Register the resource (organization API key):
POST /orgs/resourceswith{identifier, name, scopes, mcp}.identifieris an absolute URI with no query or fragment.GET /orgs/resourceslists,DELETE /orgs/resources/{id}removes. - Its metadata:
/.well-known/oauth-protected-resource/{id}(RFC 9728), served on the resource's behalf so a small server with nowhere to host it can proxy or redirect here. - Ask for a token for it: add
resource=<identifier>to/authorize, to/par, or to aclient_credentialsrequest. The access token'saudbecomes the resource andclient_idnames the client, so a token taken from one tool server is refused by the next. An unregisteredresourceisinvalid_target- it is never ignored.
Task-bounded agent grants (AD-67)
Add all four to /authorize (or push them through /par) to ask for a one-job
authorization instead of an open-ended one:
purpose (plain words the user will read), max_uses (1-1000), grant_ttl (60 seconds to
90 days), and optionally ceiling (a JSON object, at most 8 fields). All four together or
none; a partial request is invalid_request.
The consent screen shows the job, the uses and the deadline. Every token issued under the
grant carries tsk: {id, purpose, uses_left, exp, ceiling?, resource?} and spends one use.
When it runs out, expires, or the user ends it from their account page, the next token
request is invalid_grant naming which of the three happened.
ceiling is the user's declared limit. Zanii records and proves it; only your resource
server can enforce it, because only your resource server sees the spending. Say so in your
own interface too.
Shared signals - CAEP and RISC (AD-68)
GET /.well-known/ssf-configuration- what is supported.POST /ssf/stream(client auth) withdelivery=push&endpoint_url=...ordelivery=poll; emptydeliveryunsubscribes.POST /ssf/poll(RFC 8936) returns{sets: {jti: SET}, moreAvailable}; send{"ack": [jti, ...]}to clear them.GET /ssf/statusreports the backlog.
Events: session-revoked, credential-change, token-claims-change (CAEP) and
account-disabled, account-purged (RISC). Each is a signed JWT (RFC 8417) verifiable
against the same JWKS as your access tokens, with sub_id in your own subject namespace.
Push delivery uses Content-Type: application/secevent+jwt and retries with backoff.
Ask-my-human approvals (AD-69)
POST /approvals - Authorization carries the user's access token; the client
authenticates in the body (client_id + client_secret, or client_assertion),
because one header cannot hold both. Body: action (plain words, <=300 chars), optional
detail (<=12 fields), optional expires_in (60s-24h, default 15 minutes). Returns
{approval_id, status, expires_in, interval, approval_uri}.
GET /approvals/{id} (client auth) polls at the given interval; polling faster is
slow_down. Status is pending, approved, denied or expired. An unanswered request
expires refused - never treat expired as approval. Both outcomes are receipted.
Discovery (public)
GET /.well-known/openid-configuration— issuer, endpoints,scopes_supported,code_challenge_methods_supported: ["S256"],claims_supported(includesdid).GET /.well-known/jwks.json— active plus retired-but-unexpired keys,Cache-Control: max-age=300. Retired keys stay published so tokens issued before a rotation keep validating.
Rate limit: 240/min/IP on both.
GET /authorize
Query: response_type=code, client_id, redirect_uri, scope, state, nonce,
code_challenge, code_challenge_method=S256, and optionally login_hint (an email
address; it prefills the sign-in field and is ignored if it is not one — it never reveals
whether that address has an account).
Validates the client, the exact redirect URI, the scope subset and PKCE, then:
- Live session + first-party client → 302 straight back with
?code=&state=. - Live session + third-party client → renders the consent screen;
POST /ui/consentresumes the flow. The user approves per scope: expect a token whosescopeis a subset of what you asked for, and read it rather than assuming.openidis always present; anything the user withheld is simply absent, and asking again next time re-prompts.offline_accesswithheld means norefresh_tokenin the response. - No session → renders the hosted login page.
Errors that can be attributed to a registered redirect URI come back as
?error=...&error_description=...&state=.... Anything else (unknown client_id,
unregistered redirect_uri) renders on the IdP's own domain — an unvalidated redirect
would be an open redirect.
POST /token
Client auth: HTTP Basic preferred, form body accepted. Public clients send client_id
and must not send a secret.
grant_type=authorization_code— requirescode,redirect_uri(replayed exactly) andcode_verifier. The code is consumed atomically; a replay after successful use revokes the downstream refresh family.grant_type=refresh_token— rotates. The old token is marked used; presenting it again is treated as theft and revokes the entire family.
Returns {access_token, id_token, refresh_token, token_type: "Bearer", expires_in: 600, scope}
with Cache-Control: no-store. Rate limit: 30/min/client and 60/min/IP.
GET /userinfo
Authorization: Bearer <access_token>. Returns sub (the token's own, pairwise for org
clients), did for first-party clients only, plus email /
email_verified with the email scope and name / picture with profile. Honours the
jti denylist, so a revoked access token fails here before its 10-minute TTL expires.
With profile, given_name and family_name are included when the user filled them in;
like every OIDC claim with no value, they are omitted rather than sent as null.
POST /revoke (RFC 7009)
Client-authenticated, accepts either token type. A refresh token kills its whole family;
an access token is added to the jti denylist (audience-checked, so one client cannot
revoke another's token). Always 200, even for an unknown token — no enumeration.
GET /logout
RP-initiated: id_token_hint plus an allow-listed post_logout_redirect_uri. Destroys
the IdP session and clears the cookie. An unlisted URI is ignored, not followed.
Management API — /manage/v1 (admin token)
X-Admin-Token: <token>.
POST /clients{client_name, client_type: confidential|public, first_party, redirect_uris[], post_logout_redirect_uris[], allowed_origins[]}→{client_id, client_secret, webhook_secret, ...}. Both secrets are shown once.GET /clients·GET /clients/{client_id}POST /clients/{client_id}/rotate-secret— confidential clients only.POST /clients/{client_id}/rotate-webhook-secret— old signatures stop validating immediately.
first_party: true means "we own this product": no consent screen. Anything you did not
build should be false.
Organization members (AD-71)
An organization can contain people, not only clients.
POST /orgs/members/invite{email, role}- emails an invitation; the person accepts it themselves at/ui/orgs/accept, and an invitation addressed to one mailbox cannot enrol another.GET /orgs/members,DELETE /orgs/members/{user_id}.PATCH /orgs/me{require_member_mfa: true}makes members prove a second factor before any of this organization's clients can complete an authorization for them. Customers who merely sign in to its apps are unaffected.- Ask for
scope=organizationto receiveorg: {id, name, role, domain?}in the id_token and from/userinfo, for this organization only. A person's other memberships are never enumerated.
SCIM and SAML-as-service-provider are not built; this is the model they need.
Declared model provenance (AD-70)
PUT /orgs/clients/{client_id}/agent-model {model, version?, provider?} records what your
organization says is running behind an agent. It appears as the agent claim on machine
tokens, inside act when that agent delegates, and on the public registry page - always
labelled declared_by: operator. Zanii cannot check what model is answering and never
implies it has.
Organizations - /orgs (self-service, org API key)
For companies outside the Zanii ecosystem. POST /orgs/signup {name, contact_email, accept_terms: true} is
open and rate-limited (5/min/IP) and returns {organization_id, name, api_key} (400 without accept_terms; the version accepted is recorded) - the
zid_org_ key is shown once. Everything else takes Authorization: Bearer zid_org_...:
GET /orgs/me·PATCH /orgs/me{name?, contact_email?, agent_deny_list?}·POST /orgs/rotate-key(old key dies immediately)POST /orgs/agents/screen{did, owner?}→{ok, hits, history, history_error, receipt}: know-your-agent over your deny list plus the ledger's reputation summary for the DID (historyis null withhistory_errorset when the directory is unreachable). Advice, not a verdict; the decision is receipted askya.screeningunder your org DID.POST /orgs/clients- same body as the admin API. Every org client is pairwise and third-party;subject_typeandfirst_partyare not accepted from an org.GET /orgs/clients·GET /orgs/clients/{id}·DELETE /orgs/clients/{id}POST /orgs/clients/{id}/rotate-secret·.../rotate-webhook-secret
An org can only ever see its own clients; another org's client is a 404, never a 403.
The global admin can GET /manage/v1/organizations and POST /manage/v1/organizations/{id}/disable|enable; a disabled org's clients are unknown to
/authorize, /token and /revoke at once.
Lifecycle webhooks (Zanii ID -> your app)
Set lifecycle_webhook_url (https) on the client - at creation, via
POST /orgs/clients/{id}/lifecycle-url {"lifecycle_webhook_url": ...}, or in the console.
Zanii ID then POSTs JSON {"event", "sub", "occurred_at", "data"} with headers
X-Zanii-Event and X-Zanii-Signature: sha256=<HMAC-SHA256(webhook_secret, raw body)>.
Same secret and scheme as the ledger relay, so one verifier covers both.
| event | when | data |
|---|---|---|
user.deleted |
the user deleted their account - erase your copy | {} |
user.password_changed |
account page or reset; every refresh family is already dead | {"via": "account" | "reset"} |
user.email_changed |
confirmed at the new address | {"email", "email_verified": true} |
consent.revoked |
the user withdrew your access; your refresh families are dead | {} |
Verify with zanii_id.verify_lifecycle_event / verifyLifecycleEvent from @zanii-id/sdk
(0.1.3+). Deliveries retry with backoff for ~1 hour (8 attempts), then stop. Return 2xx fast; do the
work asynchronously. sub is your subject for the user (pairwise for org clients).
Machine grants and delegation
SDKs (0.3.0+): client_credentials / clientCredentials, exchange_token / exchangeToken,
device_authorize + poll_device_token / deviceAuthorize + pollDeviceToken; DPoP via
dpop_key / dpopKey, private_key_jwt via client_assertion_key / clientAssertionKey,
PAR via get_authorization_url_par / getAuthorizationUrl({ par: true }).
grant_type=client_credentials(confidential clients;scopemust not include OIDC user scopes; optionalaudience= another client_id) → access token withsub = client_id, no refresh token./userinfoanswers 401: there is no user.grant_type=urn:ietf:params:oauth:grant-type:token-exchangewithsubject_token(a user access token issued to the requesting client),subject_token_type= urn:ietf:params:oauth:token-type:access_token, optionalaudience,scope(subset),actor_token(+ type) → access token for the audience withact: {sub: actor}(nested on re-exchange), the audience's pairwisesub, the originalsid, no refresh token. Errors:invalid_grant(not your token, dead session, or the audience's organization refuses this actor - seeagent_deny_list),invalid_scope,invalid_target. With anactor_tokenthe actor is screened against the audience org's deny list first and the screening is receipted (kya.screening) whether it passed or not.POST /device_authorization(client auth orclient_id,scopeincl.openid) →{device_code, user_code, verification_uri, verification_uri_complete, verification_uri_qr, expires_in: 600, interval: 5}. Poll/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_code; expectauthorization_pending,slow_down,access_denied,expired_token.verification_uri_qris an SVG of the complete URI (GET /device/qr.svg?user_code=...) for a screen that can show a picture but has no QR encoder — display it beside the code, never instead of it. It answers 404 once the code is used or expired.
Sender-constrained tokens, pushed requests, key-based client auth
- DPoP: send a
DPoPproof header (typ dpop+jwt,jwk,htm,htu,iat,jti) at/token; the response hastoken_type: DPoPandcnf.jkt. UseAuthorization: DPoP <token>plus a proof withathat/userinfo. Refreshing a bound token needs the same key. Proofjtis are single-use. - PAR:
POST /par(client auth) with the authorize parameters →{request_uri, expires_in: 90}; thenGET /authorize?client_id=…&request_uri=…. Single use. - private_key_jwt: register
jwks(public keys only) on the client; authenticate withclient_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearerand a JWT (iss = sub = client_id,aud= issuer or/token,exp, single-usejti). Such a client has no secret.
Back-channel logout
Register backchannel_logout_uri (client field, /orgs/clients/{id}/lifecycle-url, RFC
7591). On every session end Zanii ID POSTs logout_token=<jwt> (form-encoded) with
events, sid and your pairwise sub. Verify it like an id_token (RS256, JWKS, iss,
aud, iat) and end the local session for that sid. Answer 200.
SDKs (0.3.0): verify_logout_token / verifyLogoutToken; the FastAPI, Express and Next
helpers mount the endpoint when given an on_backchannel_logout / onBackchannelLogout hook.
Front-channel logout
Register frontchannel_logout_uri (same places). When a session ends, the signed-out page
loads it in a hidden iframe as ?iss=<issuer>&sid=<sid>; clear the local state for that
sid and answer 200 with a page that is safe to frame from the issuer. Discovery advertises
frontchannel_logout_supported and frontchannel_logout_session_supported. The RP-initiated
post-logout redirect still happens, from that page, about two seconds later.
Verifiable credentials (OID4VCI)
/.well-known/openid-credential-issuer describes ZaniiIdentityCredential
(jwt_vc_json). The user creates an offer on the account page; the wallet redeems the
pre-authorized_code at /token (no client auth) to get an access token and c_nonce,
then POST /credential with a jwt proof (typ openid4vci-proof+jwt, jwk header or
did:key kid, aud = issuer, nonce). The credential's subject is the holder key; its
claims are the user's custodial zanii_subject_did and email_verified. Verify with the
issuer's JWKS.
Agent registry (public)
GET /agents/{did} (HTML) and GET /agents/{did}/profile.json: the ledger's verified history
and trust summary for a DID next to what Zanii ID knows (registered client, organization,
domain-verified, workload-bound; the operator's own agent links its constitution). Rate-limited
per IP (30/min), cached 60 s. ledger.reachable: false means the ledger was down, not that
the agent is unknown. No deny-list verdicts are public.
Token introspection (RFC 7662)
POST /introspect token=... with client authentication (Basic, client_id +
client_secret, or private_key_jwt). Only the token's audience gets active: true; anyone
else, a revoked or malformed token, or a dead session gets {"active": false}. A live answer
carries sub, aud, scope, exp, iat, sid, client_id, token_type, and when present
act, cnf, cst, did, kya, plus consent: {commitment, purpose, scope, granted_at} for
user tokens. cst is also on the tokens themselves: the hash the ledger's consent receipt
commits to. SDKs: introspect / introspect.
Agent-to-agent login
grant_type=token-exchange with subject_token = the requesting agent's own
client-credentials token and audience = another organization's client_id. Both
organizations screen the other party against their deny lists (receipted); invalid_grant
says which side refused. The token has sub = requester, aud = audience, kya: {ok, screened, mutual: true}, no act, no sid. Scope is a subset of the subject token's.
Accountability documents (public)
/.well-known/zanii-cosignatures.json?since=&limit=: countersignatures by the IdP's separate cosigner key over its recorded receipts; verify withzanii.cosign.verify_cosignature./.well-known/zanii-witness.json: ledger checkpoints the IdP co-signed as an independent witness; verify each withzanii.witness.verify_cosignature(entry.cosignature, entry.origin)./.well-known/zanii-sovereignty.json: hosting region and jurisdiction as the cloud reported them, declared sub-processors, the last residency claim, and the transfer verdict./agents/{did}/erc8004.jsonand/agents/{did}/cv.json: ERC-8004 registration file and a signed AgentCV for the IdP agent and registered clients with a DID.- Admin:
GET /manage/v1/reconcile?hours=24lists every intended receipt the ledger does not hold, with the reason; the sweeper retries them. - Users: the account page produces selective disclosures (one field of the age assertion or
of one consent) that verify with
zanii.redact.verify_disclosureagainst the envelope on the ledger.
Trust documents and ledger-native verification
Every constitution version: GET /.well-known/zanii-constitution/{hash}.json (sha256:... or
its first 16 hex characters). Verify an agent end to end with the SDK's command:
pip install 'zanii-id[verify]' && zanii-id verify <did> [--json] [--out pack.md].
/.well-known/zanii-issuer.json- the IdP's ledger DID, forzanii.credentials.resolve_issuer./.well-known/zanii-constitution.json- the signed operating rules; every self-receipt'smanifest_hashequals itsconstitution_hash. Check withzanii.constitution.verify_governance./.well-known/zanii-build.json- build attestation;runtime_hashon receipts equals its hash./.well-known/zanii-pq.json- ML-DSA-65 binding for the IdP DID (zanii.pq.verify_pq_binding).- Self-receipts use ecosystem shapes:
consent.granted|withdrawn(zanii.consent),data.retention.deleted|hold(zanii.retention), recorded unsalted. POST /orgs/kyb {domain, entity_name, jurisdiction?, registration?}→ attestation + the owner document to publish athttps://<domain>/.well-known/zanii-owner.json;POST /orgs/kyb/checkruns the two-way check;GET /orgs/meshowsdid,domain_verified.POST /credentialwithformat: "ldp_vc"(holderdid:key) → W3C VC 2.0 with aneddsa-jcs-2022proof from the IdP's ledger DID; verify withzanii.vc.verify_vc.GET /ui/activity/evidence/{did}.md?lang=en|ar|both(session) - admissibility pack + compliance report for the IdP agent or a linked agent.POST /orgs/clients/{id}/workload-binding {scheme: spiffe|entra, external_id, issuer, jwks_uri, token}- the token must carryzanii_did= the client's DID; bound clients' client_credentials tokens gain aworkloadclaim.
Observability
X-Request-ID is echoed (or generated) on every response; quote it in support requests.
/metrics (Prometheus) needs the admin token. With ZANII_ID_OTEL_ENDPOINT set the server
exports OTLP/HTTP traces (requests, outbound ledger calls, one span per ledger receipt with
zanii.target / zanii.hash) and every JSON log line carries trace_id.
Step-up: prompt, max_age, acr_values
prompt=nonenever shows UI: expectlogin_required,consent_requiredorinteraction_requiredat your redirect URI.prompt=loginandmax_age=<s>force a fresh login.prompt=consentre-shows the consent screen.acr_values=urn:zanii:mfaasks for a second-factor session. Enrolled users are challenged in place; passkey users sign in with the passkey; users with neither are bounced withunmet_authentication_requirements- send them to/ui/accountto enrol.- Every id_token has
acr(urn:zanii:mfa|urn:zanii:pwd) andamr(["pwd"],["pwd","otp"],["webauthn"]). Checkacr, notamr, for "is this a strong session".
Dynamic client registration (RFC 7591)
POST /register with Authorization: Bearer zid_org_... and {"redirect_uris": [...], "client_name", "token_endpoint_auth_method": "client_secret_basic" | "client_secret_post" | "none", "lifecycle_webhook_url"?}. Returns client_id, client_secret (confidential
only, client_secret_expires_at: 0), webhook_secret, subject_type: "pairwise",
require_pkce: true. Errors are invalid_redirect_uri / invalid_client_metadata; the
org client quota answers 429. Advertised as registration_endpoint in discovery. No
RFC 7592 - manage the client via /orgs/clients/{id} or the console.
Account page, MFA and amr
Users manage themselves at /ui/account (requires an IdP session, i.e. right after a
login). If a user has enrolled TOTP, /ui/login returns the MFA page instead of a code,
and password reset requires the code too. Every id_token carries amr: ["pwd","otp"]
when the session was established with the second factor, else ["pwd"]. An app that needs
step-up should check amr and, if it lacks otp, send the user to enrol at /ui/account
(see the Step-up section: acr_values=urn:zanii:mfa, prompt=login, max_age).
Age (scope=age). Everyone confirms 18+ at registration (a declaration, receipted).
Claims: age_declared: 18 when only the gate happened; age_over: 18, age_method
(document | estimation | credential | reusable-id | open-banking) and
age_provider when a configured assurance provider verified it from the account page.
Decide per app which one you accept; there is never a document or a birth date.
Security activity. The page lists the twenty most recent audited events about the
account — sign-ins and failures, consent decisions, credential and session changes — with
the time, address and browser. A sign-in from a browser string the account has not used
before also sends one email naming the browser and the address, and is audited as
login.new_device.
One-job authorisations and approvals. The page lists live task grants with their remaining uses and deadline and ends any of them, and shows approvals an agent is waiting on, with Approve and Refuse.
Language. The hosted pages are served in English or Arabic, chosen from the reader's
Accept-Language and overridden by the preference saved on this page. Arabic sets
dir="rtl" and translates the text.
Passwordless. Once a passkey is registered, POST /ui/account/password/remove deletes
the password entirely - allowed only when the current session was itself established with a
passkey.
Data export. POST /ui/account/export (session + page CSRF) returns the account's own
records as a JSON download: profile, identity DID, age-check result, sessions, connected
apps with consent commitments, passkey metadata, linked agents and the retained security
history. It never contains credential material — no password hash, no encrypted private
key, no cookie hash, no authenticator secret, no age document.
Identity backup. From a passkey registered with the WebAuthn PRF extension, the user
can download their custodial DID key encrypted in the browser under a key that passkey
derives, and open it later on the same page. The server never sees the PRF output; the
export is receipted (identity.key_backup_exported).
Quotas
Every organization has max_clients (default 10) and token_rate_limit_per_min (default
30, across all its clients). /token answers 429 slow_down with Retry-After: 60 when
the org cap is hit; POST /orgs/clients answers 429 at the client cap. Both are visible
in GET /orgs/me and adjustable by the global admin at
POST /manage/v1/organizations/{id}/quota.
Subject identifiers
subject_types_supported: ["public", "pairwise"].
- public - Zanii first-party clients.
subis the globalzanii_user_id, and thedidclaim is present. - pairwise - every organization-registered client.
subis derived per organization (OIDC Core 8.1): one company's clients agree on a user, no other company can derive it. Thedidclaim is absent - it is a global identifier and would undo the unlinkability./userinfoechoes the token's ownsub.
Pairwise subs are stable forever for a given org: the derivation salt is per org and is never rotated.
POST /webhooks/zanii/{client_id}
Ingests agent-activity events so they appear in the user's feed without waiting for a ledger pull. Your product relays them — the ledger does not post here directly.
Take the ledger's receipt.recorded envelope, add the subject_tag you stamped, and
POST the raw body with X-Zanii-Signature: sha256=<HMAC-SHA256(webhook_secret, raw_body)>
using the webhook_secret Zanii ID issued you, not the ledger's webhook secret.
| Response | Meaning |
|---|---|
202 accepted |
stored |
202 duplicate |
already ingested (content-addressed on the receipt hash) |
202 ignored |
not a receipt.recorded, or the tag is not this user's |
400 |
valid signature, malformed body, or missing data.hash / subject_tag |
403 |
bad signature |
404 |
unknown client |
Sign the exact bytes you send. Re-serialising the JSON changes the preimage and every delivery fails.
Hosted pages (/ui/*)
Internal to the IdP, not part of your product's API: /ui/login, /ui/register,
/ui/forgot, /ui/reset, /ui/consent, /ui/activity (+ claim, link, unlink,
rotate-did), and /verify-email. Link users to them; never scrape or reimplement them.
Health
GET /healthz → {"status":"ok","db":"ok","redis":"ok"}; any failing component turns it
503 degraded with the component named. No auth. Point the uptime monitor at it.