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" }
CIBA — authentication with no browser (AD-83)
POST /bc-authorize(client auth, confidential only):login_hint(an email, required),scope,binding_message(<=100 chars, shown to the person),requested_expiry(60s-15m). Returns{auth_req_id, expires_in, interval}.- Poll
POST /tokenwithgrant_type=urn:openid:params:grant-type:cibaandauth_req_id. Expectauthorization_pending,slow_down,access_denied,expired_token. The id is single use; a second exchange isinvalid_grant.
expired_token means refused. An unanswered request is never an approval. And
bc-authorize answers identically whether or not the address has an account, so it cannot
be used to enumerate users — an auth_req_id for an unknown person simply never approves.
Poll delivery only: ping and push hand the notification back over a channel you control, which is more surface for no gain, and unlike them poll cannot be silently dropped.
Editing what already exists (AD-73, AD-80)
PATCH /orgs/clients/{client_id} changes client_name, redirect_uris,
post_logout_redirect_uris and allowed_origins keeping the client_id. Never delete
and recreate a client to change a URL: a new id discards every consent, refresh-token family
and pairwise subject bound to the old one. client_type, subject_type and first_party
are immutable — changing those makes a different client, not an edited one. Redirect-URI
changes are emailed to the organization contact.
Also PATCH /orgs/resources/{id} (name, scopes, mcp — the identifier is immutable, it is
the audience in live tokens) and PATCH /orgs/members/{user_id} (role).
RFC 7592: a client manages itself (AD-81)
POST /register now returns registration_access_token and registration_client_uri.
With that token: GET, PUT and DELETE /register/{client_id}. Only clients that
registered dynamically hold one, so this never reaches a client an operator or an
organization created by hand.
An organization's own audit trail (AD-82)
GET /orgs/events?limit=&before= — events about your own clients, newest first, for
your SIEM. Page with before, the at of the last row you saw. Events naming no client
belong to the user, not to you, and are excluded; subjects are pairwise, so nothing here
links a person across organizations.
Webhooks you can prove (AD-75, AD-86)
POST /orgs/clients/{id}/test-webhookqueues a signedpingto your lifecycle URL, so an endpoint can be verified before a real event needs it.GET /orgs/clients/{id}/deliveriesshows what we sent and what failed.
Withdrawing a grant (AD-82)
POST /orgs/consents/revoke with {"token": "<a refresh token you hold>"}. /revoke
(RFC 7009) kills tokens and leaves the consent standing, so an uninstalled app still skips
the consent screen when it returns. The user is named by a token you already hold, never by
sub: pairwise subjects cannot be reversed, and that is the point of them.
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. Every authorization response carries iss (RFC 9207);
compare it if your app talks to more than one issuer.
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
POST /introspect (RFC 7662) understands both token types: a JWT access token, and an
opaque refresh token, which answers {"active": true, "token_type": "refresh_token", ...}
for the client it was issued to and {"active": false} for anyone else. Before 0.7.0 a
refresh token always came back inactive, which was a confidently wrong answer.
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.
SAML-as-service-provider is not built. SCIM is — see below — and it uses exactly this membership model.
SCIM 2.0 (AD-87)
An organization's HR or IT system (Okta, Entra, Workday) provisions its people into the
organization. RFC 7643/7644: /scim/v2 with Users, Groups, eq filters, paging, PUT,
PATCH, DELETE, ETags, attributes/excludedAttributes and the three discovery documents.
A stock vendor mapping provisions without being trimmed first. Entra and Okta send
attributes this server does not keep - the enterprise extension (department,
employeeNumber, manager, ...) and core ones like title, phoneNumbers, addresses,
preferredLanguage. All of them are accepted and discarded: a 400 on PATCH would
quarantine the whole tenant and force an administrator to edit their attribute list. A path
in no schema is still 400 invalidPath, so a typo is still caught. A password is
discarded too and the discard is named in the organization's own audit export
(credential_discarded), because an administrator who thinks they set someone's sign-in
should be able to find out that this server threw it away.
excludedAttributes=members costs nothing, which is the request Entra actually sends for
groups. A projected response carries no meta.version: an ETag names the whole resource.
Group members are paged only when the caller passes startIndex or count - truncating by
default is indistinguishable, at the client, from those people having been removed.
It manages membership, never accounts. Creating a User sends the ordinary invitation; the
person accepts it from their own account and sets up their own sign-in. Nothing in a
customer's directory ever writes a password, passkey, name or email onto an account — which
is what makes exposing this safe at all. Deactivating or deleting a User removes the
membership and revokes only what that organization's clients hold (refresh families,
task grants, plus the CAEP session-revoked signal). The person's IdP session, personal
sign-ins and other organizations are untouched.
Two kinds of group share the collection. member and admin are the roles, they always
exist, and PATCH /Groups/{role} moves people between them - SCIM introduced no new
authorization model. Everything else is one of the organization's own directory groups
(AD-88), created with POST /Groups and managed with PUT, PATCH and DELETE.
A directory group is a label. It grants nothing. Its name rides on the org claim as
groups, and role is still the only field that decides anything. A group cannot be named
after a role, a role cannot be renamed or deleted, and deleting a directory group withdraws
no access from anybody - no membership, token or task grant is touched. This is what Mandate
keys an approval rule on: the employer says who is a "treasury approver", Zanii carries the
claim, and nothing here has to decide what that permits.
It is off until switched on twice. With scim_enabled unset, or with no live token, every
route — including ServiceProviderConfig and Schemas — answers a plain 404 identical to any
unknown URL. Discovery never advertises it. So an organization that never asked for SCIM
cannot be provisioned into by anyone who guesses the URL.
Tokens: GET/POST /orgs/scim/tokens, DELETE /orgs/scim/tokens/{id} (organization API key,
or the console). Hashed, org-scoped, expiring, rate-limited per token, five live at most. An
operator can list and revoke them under /manage/v1/organizations/{org_id}/scim-tokens.
Every change appears in GET /orgs/events.
Matching a SCIM record to a signed-in person (AD-89)
A SCIM user carries urn:zanii:scim:schemas:extension:2.0:User with subject: the
organization's own pairwise subject, byte-identical to the sub its clients receive in an
id_token and from /userinfo. That is the join - compare them for equality.
It is nested under the URN, not a top-level attribute. user["subject"] is the obvious
first guess and it raises:
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User",
"urn:zanii:scim:schemas:extension:2.0:User"],
"id": "3f2a...", "userName": "alice@example.com", "externalId": "emp-1",
"urn:zanii:scim:schemas:extension:2.0:User": { "subject": "Vv9k..." }
}
The match cannot fail because of how a client was configured. Every client that belongs to
an organization is pairwise by construction: /orgs/clients pins subject_type="pairwise",
dynamic registration pins it too, and the global admin API never attaches a client to an
organization at all. So there is no org client whose sub is the global account id.
Do not match on email. userName is the immutable invitation address, while the
account's email can change later through the email-change flow, so the two are allowed to
diverge for exactly the person who moved address.
subject is absent until the person accepts their invitation, because until then there is
no sign-in to point at. That absence is the only signal that distinguishes invited from joined.
The global account id is never exposed: it would work across organizations, which is what
pairwise subjects exist to prevent, so two organizations provisioning the same person see two
unlinkable subjects.
How fast removal actually is
Say this plainly, because a buyer will ask and the honest answer is not "instantly".
Deactivating or deleting a SCIM user immediately removes the membership, revokes that
organization's refresh-token families and task grants, and emits CAEP session-revoked to
its clients.
A resource server that introspects gets the truth at once (AD-89): /introspect reads
membership live, so the org field is gone the moment the membership is, and a role change
shows the same way. That is the recommended check, and it is why rule 18 of SKILL.md says to
gate on org rather than active - active still reports the token as valid, because it is.
What cannot be fixed from the issuer is a resource server that validates the JWT locally
and neither introspects nor acts on the CAEP signal. For that one, an access token already
issued stays good until it expires - up to access_token_ttl_seconds, 600 by default - and
shortening that globally would change behaviour for every integrator.
So: membership, refresh and task grants are instant, the signal is sent, introspection tells the truth immediately, and only a locally-validated access token carries the ten-minute ceiling. Never promise "removed means out of everything now" without saying which of those the resource server does.
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, org, 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.
org is live membership (AD-89), present for a token with organization scope from an
organization's client while the person is still a member, and absent the moment they are
not. Gate on its presence, never on active - active: true means the token is valid and
unexpired, which a removed person's token still is until it expires. Its shape:
"org": {
"id": "018f...", "name": "Acme", "role": "member",
"domain": "acme.com", // only when the key and the domain agree
"groups": ["Finance approvers"] // only with SCIM on and at least one group
}
id, name and role are always there; domain and groups are conditional, so do not
assert an exact key set. role is member or admin - the only values that decide anything.
groups are the employer's own labels and grant nothing (AD-88).
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.