Skip to main content

API contract — single source of truth

v0 — May 12, 2026. Every endpoint in the running service is listed here. Before adding, changing, or removing an endpoint, update this file first (DP1 — spec before code). The reviewer expects the diff in this file to land in the same PR as the implementation diff.

Error shape conventions and per-error codes live in docs/error_codes.md.

Conventions

  • All responses are application/json unless explicitly noted (metadata.xml, application/pdf).
  • All write endpoints return 201 Created with the new resource at the top level (e.g. { "device": {...} }).
  • All list endpoints return 200 OK with the collection plus the resolved environment (e.g. { "devices": [...], "environment": "live" }).
  • All errors return 4xx or 5xx with { "error": "<machine_code>", "message": "<human>" }.
  • Rate-limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) are present on every authenticated /v1/* response.
  • Tenant + environment headers (X-ZeroAuth-Tenant, X-ZeroAuth-Plan) are present on every authenticated /v1/* response.
  • All /v1/* endpoints accept the API key via Authorization: Bearer za_… or X-API-Key: za_…. Format: za_(live|test)_<48 hex chars>.

Authentication tiers

TierHeaderUsed by
Tenant API keyAuthorization: Bearer za_…/v1/*
Console JWT (24h)Authorization: Bearer eyJ… (issued by /api/console/login)/api/console/keys, /api/console/usage, /api/console/account, /api/console/overview, /api/console/audit
Admin static keyX-API-Key: <ADMIN_API_KEY>/api/admin/*, GET /api/leads
Unauthenticated/api/health, /, /docs/*, /dashboard/*, POST /api/leads/pilot, POST /api/leads/whitepaper, POST /api/console/signup, POST /api/console/login

Endpoints

Health

MethodPathDescription
GET/api/healthService + blockchain + ZKP + Poseidon subsystem status. Public.

Developer console (/api/console/*)

MethodPathAuthDescription
POST/api/console/signupnoneCreate a tenant + first live API key. Per-IP rate limit (10 / 15 min). Password policy: ≥12 chars, letter + digit, denylist.
POST/api/console/loginnoneExchange email + password for a 24h console JWT. Per-IP rate limit.
GET/api/console/keysconsole JWTList API keys for the authenticated tenant.
POST/api/console/keysconsole JWTCreate a new API key (max 10 active per tenant). Returns the raw key once.
DELETE/api/console/keys/:keyIdconsole JWTRevoke an API key. Irreversible.
GET/api/console/usageconsole JWTPer-tenant rate limit, monthly quota, history, recent calls.
GET/api/console/accountconsole JWTPlan, status, limits, account metadata.
GET/api/console/overviewconsole JWTCounts + 10 most-recent rows per stream (devices, users, verifications, attendance, audit). `?environment=live
GET/api/console/auditconsole JWTFilterable business audit events. `?environment=live

Central API — devices (/v1/devices)

MethodPathScopeDescription
POST/v1/devicesdevices:writeTrusted-service path. Register a device row in enrolled state directly (used by SDK-led bulk provisioning + demo seed scripts). Body: { name, deviceType?, externalId?, locationId?, batteryLevel?, metadata? }. deviceTypemobile_android,mobile_ios,kiosk,iot_bridge,desktop (defaults to kiosk).
POST/v1/devices/enrollnone (code is bearer)Device-side claim. Exchange a one-time enrollment code (minted by the dashboard) for an enrolled row. Body: { enrollment_code, fingerprint, attestation_kind? }. Rate-limited to 10 req/min per IP. Returns { device } on success, uniform 404 enrollment_failed on any failure mode (unknown code, expired code, invalid fingerprint, fingerprint collision). See ADR 0022.
GET/v1/devicesdevices:readList devices for the tenant's environment. ?status=active|inactive|retired, ?limit=… (≤100).
PATCH/v1/devices/:deviceIddevices:writeMutate name, locationId, batteryLevel, status, metadata, lastSeenAt.

Console-side device endpoints (require console JWT):

MethodPathDescription
GET/api/console/devicesList devices. ?status=…, ?enrollment_state=pending|enrolled|revoked, ?limit=….
POST/api/console/devicesMint a pending slot + enrollment code. Body: { name, deviceType, locationId?, metadata? }. Returns { device, enrollment: { code, expires_at, deeplink } }. The plaintext code is returned exactly once — server keeps only its SHA-256. Code TTL is 15 minutes.
POST/api/console/devices/:id/regenerate-codeRe-issue the enrollment code (voids the prior one). Same response shape as POST.
PATCH/api/console/devices/:idMutate name/location/status/etc.
DELETE/api/console/devices/:idSoft-revoke (sets enrollment_state='revoked', status='retired'). Row retained for audit.

Enrollment code format: ZA-XXXX-XXXX, 8 entropy chars from a 27-symbol Crockford-base32 alphabet (no 0, 1, I, L, O, U). The deeplink format is zeroauth://enroll?code=<code> and is stable across V1.

Central API — end-user registration ceremony (/v1/registrations)

The three-QR end-user signup flow. See ADR 0023 for design + state machine + threat-model deltas. The biometric never touches the server side; only the Poseidon commitment (step 2) and the Groth16 proof (step 3) do.

MethodPathAuthPurpose
POST/v1/registrationsusers:writeOpen a session. Body: { profile?: object }. Returns { session, pair: { code, expires_at, deeplink } }. Render pair.deeplink as QR1.
GET/v1/registrations/:idusers:readPoll state. Response redacts all code hashes + challenge nonce.
DELETE/v1/registrations/:idusers:writeAbandon (idempotent). Voids outstanding codes; row retained for audit.
POST/v1/registrations/pair-devicenone — pair_code is bearerStep 1. Body: { pair_code, fingerprint, attestation_kind? }. Phone scans QR1. Server claims a device row (reuses ADR 0022 fingerprint binding), attaches to session, mints enroll_code for step 2. Returns { session_id, device_id, next: { step: 'enroll', code, expires_at, deeplink } }.
POST/v1/registrations/submit-commitmentnone — enroll_code is bearerStep 2. Body: { enroll_code, did, commitment, attestation_kind? }. Phone scans QR2 after capturing biometric locally. Server stores (did, commitment), mints verify_code + challenge_nonce for step 3. Returns { session_id, next: { step: 'verify', code, expires_at, deeplink, challenge_nonce } }.
POST/v1/registrations/completenone — verify_code is bearerStep 3. Body: { verify_code, challenge_nonce, proof, public_signals }. Phone scans QR3, re-captures biometric, produces Groth16 proof. Server asserts challenge_nonce matches, asserts publicSignals[0] equals stored commitment, verifies proof off-chain, creates tenant_user. Returns { session_id, tenant_user, device }.

State machine: awaiting_device → awaiting_commitment → awaiting_verification → completed (or abandoned). Whole-session TTL is 30 min; each code's TTL is 15 min. Phone-side endpoints are rate-limited at 20 req/min per IP via pgRateLimit.

Failure-mode surface (uniform envelopes to defeat enumeration):

CodeWhen
400 invalid_requestRequired field missing or malformed at the JSON layer.
404 pair_failedStep 1: unknown / expired pair_code, invalid fingerprint, session expired.
404 enroll_failedStep 2: unknown / expired enroll_code, wrong session state.
404 verify_failedStep 3: unknown / expired verify_code, challenge mismatch, commitment mismatch, proof verification failed.
404 session_not_foundTenant poll: id does not exist in this tenant/environment.
429Phone-side rate-limit (20/min/IP) exceeded.

The deeplink schema is zeroauth://reg?step=<pair|enroll|verify>&session=<uuid>&code=<code>[&challenge=<hex>] and is stable across V1.

Central API — users (/v1/users)

MethodPathScopeDescription
POST/v1/usersusers:writeEnroll a tenant user. Body: { fullName, externalId?, email?, phone?, employeeCode?, primaryDeviceId?, metadata? }. No biometric template ever accepted.
GET/v1/usersusers:readList enrolled users. ?status=active|inactive, ?limit=….
PATCH/v1/users/:userIdusers:writeMutate user metadata.

Central API — verifications (/v1/verifications)

MethodPathScopeDescription
POST/v1/verificationsverifications:writeRecord a verification event. Body: { method, result, userId?, deviceId?, reason?, confidenceScore?, referenceId?, metadata?, occurredAt? }. methodzkp,fingerprint,face,depth,saml,oidc,manual. resultpass,fail,challenge.
GET/v1/verificationsverifications:readList events. ?method=…, ?result=…, ?limit=….

Central API — attendance (/v1/attendance)

MethodPathScopeDescription
POST/v1/attendanceattendance:writeRecord check-in/out. Body: { userId, type, deviceId?, verificationId?, result?, metadata?, occurredAt? }. typecheck_in,check_out. resultaccepted,rejected.
GET/v1/attendanceattendance:read?type=…, ?result=…, ?limit=….

Central API — audit (/v1/audit)

MethodPathScopeDescription
GET/v1/auditaudit:readRead-only business audit log. ?action=…, ?status=success|failure, ?limit=….

Identity + ZKP (/v1/auth/zkp/*, /v1/identity/*)

ADR 0017 introduced the face-first identity surface at /v1/identity/register + /v1/identity/verify. These are the production integration points; the /v1/auth/zkp/* endpoints are retained for backward compat with the W3 demo client and are deprecated for new integrations.

MethodPathScopeDescription
POST/v1/identity/registerzkp:registerFace-first register. Accepts the on-device-computed (did, commitment) tuple. No biometric template ever crosses the wire. Optional externalId + attestation fields. Returns 201 { userId, did, commitment, createdAt }. Conflicts: 409 did_already_registered.
POST/v1/identity/verifyzkp:verifyFace-first verify. Accepts { did, proof, publicSignals, nonce, timestamp }. Looks up user by DID, asserts publicSignals[0] matches the stored commitment, runs snarkjs.groth16.verify against the boot-pinned vkey. On success returns 200 with accessToken / refreshToken / sessionId / did. Uniform 401 verification_failed for did_unknown, commitment_mismatch, proof_invalid (enumeration defence).
GET/v1/identity/meidentity:readUser profile from a session JWT (passed via X-Session-Token).
POST/v1/identity/logoutidentity:readInvalidate a session.
POST/v1/identity/refreshidentity:readRefresh-token → new access token.
POST/v1/auth/zkp/registerzkp:registerDEPRECATED. Accepts a base64 biometricTemplate. Computes commitment server-side and registers. Retained for the W3 demo client + existing fixtures. New integrations MUST use /v1/identity/register per ADR 0017.
POST/v1/auth/zkp/verifyzkp:verifyDEPRECATED for new integrations. Verifies a Groth16 proof without a DID lookup. Use /v1/identity/verify which adds the commitment-vs-DID match check before running snarkjs.
GET/v1/auth/zkp/noncenonce:createFresh nonce, 5-minute lifetime.
GET/v1/auth/zkp/circuit-infozkp:verifyCircuit metadata for client SDKs.

SAML + OIDC (/v1/auth/saml/*, /v1/auth/oidc/*)

These endpoints are gated by ENABLE_DEMO_AUTH and currently simulate the assertion exchange — they are not production-quality SAML / OIDC. See A-03, A-04 in threat_model.md. Full implementations will land via @node-saml/node-saml and openid-client and the route signatures will not change.

MethodPathScopeDescription
GET/v1/auth/saml/loginsaml:loginReturns the IdP redirect URL.
POST/v1/auth/saml/callbacksaml:callbackSAML assertion → session JWT.
GET/v1/auth/saml/metadatasaml:loginSP metadata XML.
GET/v1/auth/oidc/authorizeoidc:authorizeOIDC /authorize redirect URL with PKCE.
POST/v1/auth/oidc/callbackoidc:callbackCode → session JWT.

Admin (/api/admin/*)

All require X-API-Key: <ADMIN_API_KEY>. Read-only.

MethodPathDescription
GET/api/admin/statsIn-process counters + blockchain identity count.
GET/api/admin/blockchainLive RPC info, contract addresses, deployer address.
GET/api/admin/privacy-auditZero-storage attestation.
GET/api/leadsAll marketing leads. ?type=pilot|whitepaper.

Marketing (/api/leads/*)

MethodPathAuthDescription
POST/api/leads/pilotnonePilot-access form: { name, company, email, size }.
POST/api/leads/whitepapernoneWhitepaper download form: { email }. Response includes downloadUrl.

Proof pairing (/v1/proof-pairing/*)

Cross-device verification flow: a desktop opens a session, a phone scans the QR + generates a Groth16 proof, the desktop submits the proof, the backend mints a desktop JWT. Full protocol in ADR-0009.

MethodPathScopeDescription
POST/v1/proof-pairing/sessionsproof_pairing:createDesktop opens a session. Server returns { id, nonce, expiresAt, qrPayload, streamUrl } and sets a session_bind cookie on the response.
POST/v1/proof-pairing/sessions/:id/submitproof_pairing:claimDesktop submits the proof + public signals it scanned from the phone. Body: { did, proof, publicSignals, clientMeta? }. Returns { session, tokens }.
GET/v1/proof-pairing/sessions/:id/streamproof_pairing:create + session_bind cookieServer-Sent Events. Events: session_created, session_bound, session_expired, session_error. Connection closes after a terminal event.
GET/v1/proof-pairing/sessions/:idproof_pairing:create + session_bind cookiePolling fallback for clients without EventSource.
GET/v1/proof-pairing/sessions/:id/publicnone (per-IP rate-limited 30/min)Unauthenticated freshness read. Returns only { id, state, expiresAt } so the Android app can short-circuit a ceremony after a stale QR was scanned. Uniform 404 pairing_session_not_found for every rejection class (A-25). Cache-Control: no-store.

POST /sessions response (also sets Set-Cookie: zeroauth_pair_bind=…; HttpOnly; Secure; SameSite=Strict; Path=/v1/proof-pairing/; Max-Age=300):

{
"session": {
"id": "9f8e2a4b-1c0d-4e9a-bd33-2a44f0e7e9d1",
"nonce": "<62-hex-char 31-byte nonce>",
"expiresAt": "2026-05-25T14:35:00.000Z",
"qrPayload": "za:pair:1:9f8e2a4b…:9f7c1d4a…:zeroauth.dev:5b3e",
"streamUrl": "/v1/proof-pairing/sessions/9f8e2a4b…/stream",
"state": "issued"
}
}

POST .../submit body:

{
"did": "did:zeroauth:demo:7a3c9f5b8e1d2a4c6f0b9e3d5a7c1f8b",
"proof": { "pi_a":[], "pi_b":[], "pi_c":[], "protocol":"groth16", "curve":"bn128" },
"publicSignals": ["<commitment>", "<didHashSession>", "<identityBinding>"],
"clientMeta": { "appVersion": "0.1.0", "platform": "android", "model": "Pixel 7a", "proofMs": 4820, "playIntegrityVerdict": "MEETS_STRONG_INTEGRITY", "rawScan": "za:proof:1:<base64url-of-gzip-cbor>" }
}

clientMeta.rawScan short-key schema. The Android app encodes the proof QR as gzip(cbor({ s, p, ps, d, m })) to fit under the 1500-byte QR-capacity ceiling (ADR-0009 §5). The desktop relays the raw scan string into clientMeta.rawScan unchanged; backend audit + analytics decoders need to know the field-shortening map:

Short keyLong key in this contractType
ssession.idstring (UUIDv4)
pproof{ a, b, c, protocol, curve } (same shape, also short-keyed: a = pi_a, b = pi_b, c = pi_c)
pspublicSignalsstring[3] ([commitment, didHashSession, identityBinding])
ddidstring
mclientMeta{ av, pl, md, ms, pi } where av = appVersion, pl = platform, md = model, ms = proofMs, pi = playIntegrityVerdict

Decoded forms are byte-equal to the long-keyed JSON above; the desktop's submit handler reconstructs the long-keyed body before posting if it needs to call /submit itself, so server-side handlers can rely on the documented long-keyed shape and treat clientMeta.rawScan as an opaque audit token.

POST .../submit success — 200 OK:

{
"session": { "id": "9f8e2a4b…", "state": "bound", "boundAt": "…", "userId": "…", "did": "…" },
"tokens": { "accessToken": "eyJ…", "refreshToken": "eyJ…", "tokenType": "Bearer", "expiresIn": 3600 }
}

SSE event shapes on /stream:

event: session_created
data: {"id":"9f8e2a4b…","state":"issued","expiresAt":"…"}

event: session_bound
data: {"id":"…","state":"bound","userId":"…","did":"…","tokens":{…}}

event: session_expired
data: {"id":"…","state":"expired"}

event: session_error
data: {"id":"…","error":"pairing_nonce_mismatch","message":"…"}

Error variants on /submit (all return { "error": "<code>", "message": "<human>" }): see docs/error_codes.md under "Proof pairing".

Tenant policy

Per-tenant security knobs live in the tenants.security_policy JSONB column. The current consumer is the Play Integrity verdict gate on /submit. All fields are optional; absent = permissive default (any verdict accepted, including absent).

{
"require_strong_integrity": false,
"require_device_integrity": false,
"require_basic_integrity": false,
"allow_play_integrity_absent": false
}

Rank order (highest wins when multiple flags set): require_strong_integrity (rank 4) > require_device_integrity (rank 3) > require_basic_integrity (rank 2). Demo tenants ship with {}. BFSI / regulated tenants flip require_strong_integrity: true and allow_play_integrity_absent: false.

Failure modes:

  • Verdict absent + policy requires one + allow_play_integrity_absent false → 400 play_integrity_required.
  • Verdict rank < required rank → 401 play_integrity_insufficient.
  • Both write an audit_events row with action pairing.integrity_rejected carrying the presented verdict + the policy snapshot (no PII; never did).

Legacy /api/auth/* surface

These exist for backwards compatibility with internal tooling that pre-dates the /v1/* rollout. The legacy SAML and OIDC callbacks are gated by ENABLE_DEMO_AUTH for the same reason as their /v1/* counterparts. Document but plan to deprecate.

MethodPathDescription
GET/api/auth/meCurrent user from session JWT.
POST/api/auth/refreshRefresh tokens.
POST/api/auth/logoutInvalidate a session.
POST/api/auth/zkp/registerRegister identity. Same shape as /v1/auth/zkp/register minus tenant scoping.
POST/api/auth/zkp/verifyVerify proof.
GET/api/auth/zkp/nonceFresh nonce.
GET/api/auth/zkp/circuit-infoCircuit metadata.
GET/api/auth/saml/loginSAML login, demo-gated.
POST/api/auth/saml/callbackSAML callback, demo-gated.
GET/api/auth/saml/metadataSP metadata XML.
GET/api/auth/oidc/authorizeOIDC authorize, demo-gated.
POST/api/auth/oidc/callbackOIDC callback, demo-gated.
GET/api/auth/oidc/.well-known/openid-configurationOIDC discovery document. Note: jwks_uri is intentionally absent (HS256-only today).

LAST_UPDATED: 2026-05-22 OWNER: Pulkit Pareek