zaniiid

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/resources with {identifier, name, scopes, mcp}. identifier is an absolute URI with no query or fragment. GET /orgs/resources lists, 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 a client_credentials request. The access token's aud becomes the resource and client_id names the client, so a token taken from one tool server is refused by the next. An unregistered resource is invalid_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) with delivery=push&endpoint_url=... or delivery=poll; empty delivery unsubscribes.
  • POST /ssf/poll (RFC 8936) returns {sets: {jti: SET}, moreAvailable}; send {"ack": [jti, ...]} to clear them. GET /ssf/status reports 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 (includes did).
  • 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/consent resumes the flow. The user approves per scope: expect a token whose scope is a subset of what you asked for, and read it rather than assuming. openid is always present; anything the user withheld is simply absent, and asking again next time re-prompts. offline_access withheld means no refresh_token in 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 — requires code, redirect_uri (replayed exactly) and code_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=organization to receive org: {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 (history is null with history_error set when the directory is unreachable). Advice, not a verdict; the decision is receipted as kya.screening under your org DID.
  • POST /orgs/clients - same body as the admin API. Every org client is pairwise and third-party; subject_type and first_party are 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; scope must not include OIDC user scopes; optional audience = another client_id) → access token with sub = client_id, no refresh token. /userinfo answers 401: there is no user.
  • grant_type=urn:ietf:params:oauth:grant-type:token-exchange with subject_token (a user access token issued to the requesting client), subject_token_type= urn:ietf:params:oauth:token-type:access_token, optional audience, scope (subset), actor_token (+ type) → access token for the audience with act: {sub: actor} (nested on re-exchange), the audience's pairwise sub, the original sid, no refresh token. Errors: invalid_grant (not your token, dead session, or the audience's organization refuses this actor - see agent_deny_list), invalid_scope, invalid_target. With an actor_token the 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 or client_id, scope incl. openid) → {device_code, user_code, verification_uri, verification_uri_complete, verification_uri_qr, expires_in: 600, interval: 5}. Poll /token with grant_type=urn:ietf:params:oauth:grant-type:device_code; expect authorization_pending, slow_down, access_denied, expired_token. verification_uri_qr is 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 DPoP proof header (typ dpop+jwt, jwk, htm, htu, iat, jti) at /token; the response has token_type: DPoP and cnf.jkt. Use Authorization: DPoP <token> plus a proof with ath at /userinfo. Refreshing a bound token needs the same key. Proof jtis are single-use.
  • PAR: POST /par (client auth) with the authorize parameters → {request_uri, expires_in: 90}; then GET /authorize?client_id=…&request_uri=…. Single use.
  • private_key_jwt: register jwks (public keys only) on the client; authenticate with client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and a JWT (iss = sub = client_id, aud = issuer or /token, exp, single-use jti). 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 with zanii.cosign.verify_cosignature.
  • /.well-known/zanii-witness.json: ledger checkpoints the IdP co-signed as an independent witness; verify each with zanii.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.json and /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=24 lists 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_disclosure against 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, for zanii.credentials.resolve_issuer.
  • /.well-known/zanii-constitution.json - the signed operating rules; every self-receipt's manifest_hash equals its constitution_hash. Check with zanii.constitution.verify_governance.
  • /.well-known/zanii-build.json - build attestation; runtime_hash on 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 at https://<domain>/.well-known/zanii-owner.json; POST /orgs/kyb/check runs the two-way check; GET /orgs/me shows did, domain_verified.
  • POST /credential with format: "ldp_vc" (holder did:key) → W3C VC 2.0 with an eddsa-jcs-2022 proof from the IdP's ledger DID; verify with zanii.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 carry zanii_did = the client's DID; bound clients' client_credentials tokens gain a workload claim.

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=none never shows UI: expect login_required, consent_required or interaction_required at your redirect URI. prompt=login and max_age=<s> force a fresh login. prompt=consent re-shows the consent screen.
  • acr_values=urn:zanii:mfa asks for a second-factor session. Enrolled users are challenged in place; passkey users sign in with the passkey; users with neither are bounced with unmet_authentication_requirements - send them to /ui/account to enrol.
  • Every id_token has acr (urn:zanii:mfa | urn:zanii:pwd) and amr (["pwd"], ["pwd","otp"], ["webauthn"]). Check acr, not amr, 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. sub is the global zanii_user_id, and the did claim is present.
  • pairwise - every organization-registered client. sub is derived per organization (OIDC Core 8.1): one company's clients agree on a user, no other company can derive it. The did claim is absent - it is a global identifier and would undo the unlinkability. /userinfo echoes the token's own sub.

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.