User Guide
A step-by-step walkthrough of how the pieces fit together.
For the full request/response reference see openapi.yaml;
to try any of this live, use the Bruno collection.
Everything below can be done from the admin console in a browser — it is the same API, and the console is the fastest way to create an organization and get your first API key. The requests are shown as HTTP so the flow is explicit and scriptable.
The pieces, in one picture
Section titled “The pieces, in one picture”
flowchart LR
subgraph Caller identity
U[Human user<br/>OIDC access token]
K[API key<br/>Authorization: Bearer]
end
U -->|AuthGuard + RolesGuard| CONSOLE[Console actions:<br/>orgs, members, keys, templates]
K -->|ApiKeyGuard + scope| MACHINE[Machine actions:<br/>issue / verify credentials]
CONSOLE --> ORG[(Organization)]
MACHINE --> ORG
ORG --> MEM[Memberships<br/>user + role]
ORG --> KEYS[API Keys<br/>scoped: issue / verify / admin]
ORG --> TPL[Credential Templates]
TPL --> ISSUED[Issued Credentials<br/>signed via the isolated<br/>Key Management Service]
ORG --> AUD[(Audit log)]
ORG --> USE[(Usage counters)]
ISSUED --> HOOK[Webhook deliveries<br/>signed, retried]
An organization is the tenant boundary — everything else (members, API
keys, templates, issued credentials, and eventually billing) hangs off an
orgId, and Postgres row-level security enforces that boundary as a second,
independent layer under the app-level checks described below (Architecture
§5).
There are two ways to authenticate as a caller, and they are not interchangeable:
| Identifies | Header | Used for | |
|---|---|---|---|
| User | a human, via an OIDC access token (AuthGuard) |
Authorization: Bearer <access token> |
Org/member/API-key/template/webhook management — anything an admin does in the console |
| API key | an org, via ApiKeyGuard |
Authorization: Bearer vcp_... |
Machine-to-machine calls — issuing and verifying credentials |
Both arrive in the same header, and the platform tells them apart by shape: a
JWT is validated against the identity provider’s JWKS and resolved to a user
(provisioned on first login), while a vcp_-prefixed opaque secret is
resolved to an org and its scopes. Your role in the organization you are
addressing decides what a user token may do.
The simplest way to get a user token is to sign in to the console — it runs
the authorization-code + PKCE flow for you. (Local development also allows an
X-Dev-User-Email header when AUTH_DEV_MODE=true; the platform refuses to
start with that enabled in production, and it is not part of the API
contract.)
Step by step
Section titled “Step by step”1. Create an organization
Section titled “1. Create an organization”POST /api/orgsAuthorization: Bearer <access token>{ "name": "Acme Inc", "slug": "acme" }This does three things in one transaction: creates the org, adds the caller
as its first member with role admin, and writes an org.created audit
event. There’s nothing to invite yet — the creator is always the first
admin.
The name can be changed later (PATCH /api/orgs/{orgId}, admin-only); the
slug cannot, because it appears in the issuer identifiers that
already-issued credentials point at.
2. Invite teammates
Section titled “2. Invite teammates”POST /api/orgs/{orgId}/membersAuthorization: Bearer <access token>{ "email": "dev@acme.test", "role": "developer" }Admin-only, and the response tells you which of two things happened:
"status": "active"— the address already belonged to a user, so they are a member right now."status": "invited"(withuserId: null) — nobody has signed in with that address yet, so a pending invitation was recorded. It becomes a real membership, with the role you chose, the first time that address signs in.
Adding the same email twice returns 409. Pending invitations are listable
and revocable while they wait:
GET /api/orgs/{orgId}/invitationsDELETE /api/orgs/{orgId}/invitations/{invitationId}3. List and manage members
Section titled “3. List and manage members”GET /api/orgs/{orgId}/members -- any member can call thisPATCH /api/orgs/{orgId}/members/{userId} -- admin-only, change roleDELETE /api/orgs/{orgId}/members/{userId} -- admin-only, removeTwo guardrails worth knowing:
- Last-admin protection: you cannot demote or remove an org’s only
remaining admin (
400). An org can never be left without one. - No information leak on probing: a non-member calling any org-scoped
endpoint with someone else’s
orgIdgets403, identical to what a member calling a nonexistentorgIdgets. Membership lookup doubles as the tenant-existence check, so there’s no way to distinguish “wrong id” from “not your org.”
4. Issue an API key
Section titled “4. Issue an API key”In the console: API keys → New API key. Over HTTP:
POST /api/orgs/{orgId}/api-keysAuthorization: Bearer <access token>{ "name": "CI key", "scopes": ["issue", "verify"] }Admin-only. The response includes the plaintext secret exactly once —
only its SHA-256 hash is stored, so if you lose it, the only recovery is to
revoke it and issue a new one. Everyone else who lists keys
(GET /api/orgs/{orgId}/api-keys, any member can call this) only ever sees
metadata: id, name, keyPrefix (e.g. vcp_a1b2, safe to display), scopes,
timestamps — never the secret or its hash.
5. Use the API key
Section titled “5. Use the API key”Machine clients authenticate with:
Authorization: Bearer vcp_<secret>ApiKeyGuard resolves this through a database function that looks up the
key by its hash, checks it isn’t revoked or expired, and establishes which
org it belongs to — all before any row-level-security tenant context
exists, which is why that lookup runs through a narrow, deliberately-limited
database function rather than a normal table query (Architecture §5).
6. Revoke a key
Section titled “6. Revoke a key”DELETE /api/orgs/{orgId}/api-keys/{keyId}Authorization: Bearer <access token>Admin-only, idempotent — revoking an already-revoked key still returns
204 and doesn’t write a second audit event. A revoked or expired key
fails ApiKeyGuard with 401 on its next use.
7. Define a credential template
Section titled “7. Define a credential template”POST /api/orgs/{orgId}/templatesAuthorization: Bearer <access token>{ "name": "Proof of Employment", "claimsSchema": { "type": "object", "properties": { "jobTitle": { "type": "string" } } }, "formats": ["vc+jwt", "vc20", "dc+sd-jwt"], "validityPeriodDays": 365, "revocable": true}Admin-only to create; any member can list/read (GET /api/orgs/{orgId}/templates,
GET /api/orgs/{orgId}/templates/{templateId}). Admins can also edit
(PATCH, partial) and delete (DELETE) a template — PRD §5.3’s “define
credential types without code deploys” is what that exists for. Editing
affects future issuance only: credentials already issued are signed
artifacts and do not change, and deleting a template does not revoke them. claimsSchema is stored but
not yet enforced at issuance time — a template describes the shape a
credential is meant to have, but nothing currently rejects a
non-conforming subjectClaims payload.
formats declares which wire formats this template may be rendered in.
Five have renderers/parsers: vc+jwt (W3C VC 1.1 as a plain JWT), vc20
(W3C VC 2.0 as a JWT), vc11+ld (VC 1.1 with a JSON-LD Data Integrity
proof), dc+sd-jwt (SD-JWT VC with selective disclosure), and mso_mdoc
(ISO 18013-5 mdoc, signed under this deployment’s sandbox IACA trust
chain — see the mdoc stream plan). Direct-REST issuance of mso_mdoc
needs a holder public key up front (mdoc has no holder-less path the way
a plain JWT VC does); issuing it through OpenID4VCI, where the wallet’s
proof-of-possession key fills that in, has no such requirement.
revocable defaults to false — credentials that can only expire. Set it
to true to make them revocable (see the revocation step below).
You do not choose a status mechanism. Which one carries the revocation pointer follows from the credential’s format, because the format’s own specification decides: SD-JWT VC uses the IETF Token Status List, the W3C formats use Bitstring Status List v1.0. Since one template can declare several formats, a single per-template mechanism could only ever have been right for some of them.
8. Issue a credential
Section titled “8. Issue a credential”POST /api/credentialsAuthorization: Bearer vcp_<secret> (needs the "issue" scope){ "templateId": "...", "subjectClaims": { "jobTitle": "Engineer" }, "format": "vc20" }Notice there’s no {orgId} in this URL, unlike every console endpoint
above — the API key itself is the tenant selector (the same way a Stripe
key implies your account), so there’s nothing to mix up between a key’s
org and a path parameter. format picks one of the template’s declared
formats and defaults to vc+jwt when omitted, so pre-Phase-1 callers are
unaffected.
What happens under the hood: the org’s signing key is provisioned lazily
(one Ed25519 key per org, created on first issuance, reused after —
Architecture’s “swappable per-org/per-template” key model is real but not
built yet, deliberately). For the JOSE formats the platform constructs and
base64url-encodes the JWS header and payload itself, sends only the opaque
signing-input bytes to the isolated signing service, and gets back an
opaque signature — the signing service never sees credential content, and
the platform never sees the private key (Architecture §5). The vc11+ld
format works the same way at the boundary: the Data Integrity cryptosuite
hands its canonicalized digest to the same signing RPC, unaware the “key”
on the other end is a service. If the template is revocable, issuance
also allocates the credential’s slot on the org’s status list and embeds
the status pointer that credential’s format defines.
The response includes the rendered credential string plus its id,
issued-at, and validity window; a credential.issued audit event is
recorded in the same transaction as the local record of what was issued.
9. Verify a credential
Section titled “9. Verify a credential”POST /api/credentials/verifyAuthorization: Bearer vcp_<secret> (needs the "verify" scope){ "credential": "<the wire credential>" }The format is auto-detected (a leading { means vc11+ld; otherwise the
JOSE typ header decides), so there’s no format field. Signature
verification never calls the signing service — the credential’s issuer
identifier is a did:jwk, which embeds the public key directly, so
resolving it is just decoding, not a network round trip.
If the credential carries a status pointer, a revocation check
runs after signature verification, and it fails closed: a revoked
credential fails with revoked, and a status list that can’t be fetched
or verified fails with status_unavailable — “couldn’t check” is never
reported as “valid”. The platform’s own lists are read locally;
third-party lists are fetched and signature-verified before any bit is
trusted.
The response is always verified: true or false plus a machine-readable
errors array (signature_invalid, expired, not_yet_valid,
issuer_unresolvable, revoked, status_unavailable, malformed) — a
credential.verified audit event records the outcome either way,
satisfying PRD §5.8’s “pass/fail + reason,” not just successful checks.
10. Revoke a credential
Section titled “10. Revoke a credential”POST /api/credentials/{credentialId}/revokeAuthorization: Bearer vcp_<secret> (needs the "issue" scope)Issuer lifecycle uses the issue scope — the key that may create an org’s
credentials is the one that may end them; a verify-only key can do
neither. Only works for credentials whose template declared
revocable: true (400 otherwise — everything else can only expire).
Idempotent: revoking twice returns the original revokedAt without a
second audit event.
Which list gets flipped depends on the credential’s format, because the
format’s own specification decides the mechanism. dc+sd-jwt uses the IETF
Token Status List — a status claim pointing at a statuslist+jwt —
which is what a conformant SD-JWT VC verifier reads and what the EUDI ARF
requires. The W3C formats use Bitstring Status List v1.0 via
credentialStatus. One template can declare both and each credential gets
the right one; vc11+ld can carry neither yet, so a revocable template
refuses to issue in it rather than producing a credential whose revocation
would silently do nothing.
Under the hood this flips one entry on the org’s list
(spec-minimum 131,072 entries, so a list reveals only bit positions —
never which credential or subject an index belongs to). Which URL serves it
depends on the same format-driven mechanism as above — both live on the
host root, not under /api, since the URL is baked into issued credentials
forever:
GET /status-lists/{orgId}/{listId}— Bitstring Status List, a signed VC 2.0 JWT, for the W3C formats.GET /token-status-lists/{orgId}/{listId}— IETF Token Status List, astatuslist+jwt, fordc+sd-jwt.
Both are public and unauthenticated by design (any verifier of this platform’s credentials must be able to dereference them without credentials) and both are what a conformant verifier of the matching format reads.
11. Issue via a wallet (OpenID4VCI)
Section titled “11. Issue via a wallet (OpenID4VCI)”So far every credential has come back directly in the API response. To put
one in an end user’s wallet instead, create an offer and hand its URI to
the wallet as a QR code or deep link — the wallet then drives OpenID4VCI 1.0
Final against wallet-facing endpoints at the host root (not under /api,
since every org is its own credential issuer at
{publicUrl}/oid4vci/{orgId}):
POST /api/credentials/offersAuthorization: Bearer vcp_<secret> (needs the "issue" scope){ "templateId": "...", "subjectClaims": { "jobTitle": "Engineer" }, "format": "dc+sd-jwt" }The response’s credentialOffer is what you show the wallet; offerId
lets you poll GET /api/credentials/offers/{offerId} for pending →
accepted → confirmed (the last only if the wallet’s notification
endpoint call actually lands — optional per spec).
Two grant types back this, both served from the same
/oid4vci/{orgId}/token endpoint.
Pre-authorized code — the default. No browser, no PAR, no DPoP: the
org’s backend already knows who the credential is for, so the wallet just
redeems the one-time code from the offer. It still needs a Client
Attestation JWT, same as the authorization-code grant below — brought to
attestation parity as of ARF Annex 2 (WUA_22/24/25 don’t carve out an
exception by grant type), so a wallet that skips this gets invalid_client
at the token endpoint.
sequenceDiagram
participant B as Org backend
participant P as Platform
participant W as Wallet
B->>P: POST /api/credentials/offers<br/>{templateId, subjectClaims, format} (API key, issue scope)
P-->>B: {credentialOffer (URI), offerId, expiresIn}
B->>W: show credentialOffer as QR / deep link
W->>P: GET /.well-known/openid-credential-issuer/oid4vci/{orgId}
P-->>W: issuer metadata (credential_configurations_supported, ...)
W->>P: GET /.well-known/oauth-authorization-server/oid4vci/{orgId}
P-->>W: AS metadata (token_endpoint, grants supported)
rect rgb(235, 235, 235)
note over W,P: obtain a client attestation (once, cached ~1h)
W->>P: POST /attestations/challenge
P-->>W: {challenge}
W->>P: POST /attestations<br/>{jwk, challenge, pop}
P-->>W: {attestation_jwt, client_id}
end
W->>P: POST /oid4vci/{orgId}/token<br/>grant_type=pre-authorized_code,<br/>client_attestation(+pop)
P-->>W: access_token (single-use code now consumed)
W->>P: POST /oid4vci/{orgId}/nonce
P-->>W: c_nonce
W->>P: POST /oid4vci/{orgId}/credential<br/>Bearer access_token, proof=jwt(c_nonce, holder pubkey)
P->>P: verify proof-of-possession,<br/>issue credential bound to holder key (cnf)
P-->>W: credential + notification_id
opt wallet reports storage outcome (optional per spec)
W->>P: POST /oid4vci/{orgId}/notification<br/>{notification_id, event: credential_accepted}
end
B->>P: GET /api/credentials/offers/{offerId} (poll)
P-->>B: {status: pending → accepted → confirmed}
Authorization code (Phase 2) — a real browser login instead, gated by
PAR, PKCE, DPoP, and a Client Attestation JWT. A wallet gets one from the
reference attestation issuer at /attestations/* — served by its own
deployable (apps/wallet-backend, carved out for security isolation; see its
user guide for the full
protocol walkthrough), reached through the same public hostname as
everything else here — a private, wallet-provider-style protocol that every
OpenID4VC spec deliberately leaves out of scope — and caches it (~1 hour)
for reuse.
sequenceDiagram
participant W as Wallet
participant WB as Wallet Backend
participant P as Platform
participant U as End user (browser)
rect rgb(235, 235, 235)
note over W,WB: obtain a client attestation (once, cached) — a separate deployable from the platform
W->>WB: POST /attestations/challenge
WB-->>W: {challenge}
W->>WB: POST /attestations<br/>{jwk, challenge, pop}
WB-->>W: {attestation_jwt, client_id}
end
rect rgb(235, 235, 235)
note over W,P: pushed authorization request (RFC 9126)
W->>P: POST /oid4vci/{orgId}/par<br/>client_id, redirect_uri, PKCE code_challenge (S256),<br/>client_attestation(+pop), DPoP proof
alt missing/stale DPoP nonce
P-->>W: 400 use_dpop_nonce + DPoP-Nonce header
W->>P: retry PAR with fresh DPoP proof
end
P-->>W: {request_uri, expires_in}
end
rect rgb(235, 235, 235)
note over W,U: browser login
W->>U: open GET /oid4vci/{orgId}/authorize?client_id&request_uri
U->>P: GET /oid4vci/{orgId}/authorize
alt no live login-session cookie for this org
P-->>U: render minimal login form
U->>P: POST /oid4vci/{orgId}/authorize<br/>{request_id, username, password}
end
P->>P: mint authorization code,<br/>bind it to redirect_uri + PKCE challenge + DPoP jkt
P-->>U: 302 redirect to redirect_uri?code=...&state=...<br/>+ set login-session cookie
U->>W: deliver code (custom scheme / app link)
end
rect rgb(235, 235, 235)
note over W,P: token exchange
W->>P: POST /oid4vci/{orgId}/token<br/>grant_type=authorization_code, code, code_verifier,<br/>redirect_uri, client_attestation(+pop), DPoP proof
P->>P: verify PKCE, redirect_uri match,<br/>client attestation, DPoP (same key bound at PAR)
P-->>W: DPoP-bound access_token
end
rect rgb(235, 235, 235)
note over W,P: same as the pre-authorized flow from here
W->>P: POST /oid4vci/{orgId}/nonce
P-->>W: c_nonce
W->>P: POST /oid4vci/{orgId}/credential<br/>Authorization: DPoP access_token, proof=jwt(c_nonce)
P-->>W: credential + notification_id
opt
W->>P: POST /oid4vci/{orgId}/notification<br/>{notification_id, event}
end
end
The DPoP key proven at PAR must match the one presented at token exchange —
a different key is refused even with an otherwise-valid proof — and the
resulting access token is itself DPoP-bound, so the credential and
notification endpoints then require Authorization: DPoP <token>, not
Bearer. See
AUTHORIZED_FLOW_ARCHITECTURE.md
for the wire-level detail this section deliberately doesn’t repeat.
A narrower, specific use of this same mechanism — issuing a credential that lets its holder log into this console instead of/alongside Zitadel — is covered separately in CREDENTIAL_LOGIN_GUIDE.md, since it only accepts one designated template per format rather than any credential of the right shape.
Two more identity-adjacent surfaces this guide doesn’t otherwise cover:
- Key Attestation (
/key-attestations/*, host root, same apps/wallet-backend deployable as Client Attestation above) — the credential-holder-key analogue of the Client Attestation issued above, for a wallet that needs to attest the specific key a credential will be bound to, not just the wallet app itself. See apps/wallet-backend/docs/USER_GUIDE.md for both flows in full, with worked examples. - Agent Identity — letting an AI agent hold and present its own identity (issuance, MCP-authenticated sessions, presentation to an external relying party) is a separate capability from anything above; see AGENT_IDENTITY_GUIDE.md.
12. Present to a verifier via a wallet (OpenID4VP)
Section titled “12. Present to a verifier via a wallet (OpenID4VP)”The verifier-side equivalent: create a presentation request, show its
openid4vp:// URI to the holder, and poll for the outcome. Unlike
issuance, there is only one flow here — the authorized/pre-authorized split
above is specific to OpenID4VCI’s two issuance grant types.
POST /api/presentations/requestsAuthorization: Bearer vcp_<secret> (needs the "verify" scope){ "templateId": "...", "requestedClaims": ["jobTitle"] }A request can also name several credentials at once —
"credentials": [{ "id": "...", "templateId": "...", "format": "...", "requestedClaims": [...] }, ...]
instead of a single templateId — each resolved by its own DCQL query.
Two more options, both opt-in and independent of each other:
"signed": false— the request is JAR-signed (x509_hash, ACME certificate) by default; passfalsefor the old unsigned behavior, which needs no certificate."trustModel": "rpac"— signs the request under the EUDI ARF Relying-Party trust model (ETSI TS 119 411-8) instead of the defaultweb-pki(ACME) anchor, and attaches an RPRC (ETSI TS 119 475) via the request’sverifier_info. Sandbox-only today, and fails the request (503) if no RPAC is active for this deployment. Ignored ifsigned: false."jarm": true— instead of a plaindirect_post, the wallet delivers its response encrypted (response_mode=direct_post.jwt): an unsigned JWT, encryptedECDH-ES/A128GCMagainst a fresh per-request keypair. Encrypt-only, not sign-and-encrypt — OpenID4VP 1.0 dropped the generic JARM spec’s signing option (openid/OpenID4VP#463: “implementations MUST use an unsigned, encrypted JWT”). No certificate needed. Defaults tofalse.
sequenceDiagram
participant B as Org backend
participant P as Platform
participant W as Wallet
B->>P: POST /api/presentations/requests<br/>{templateId or credentials[], requestedClaims,<br/>signed, trustModel, jarm}<br/>(API key, verify scope)
P-->>B: {id, authorizationRequest (openid4vp:// URI), expiresIn}
B->>W: show authorizationRequest as QR / deep link
W->>W: resolve DCQL query against stored credentials,<br/>select the matching one(s)
W->>W: build vp_token — key-binding JWT signed over<br/>this request's nonce + client_id (aud)
W->>P: POST /oid4vp/{orgId}/response/{sessionId}<br/>(direct_post, or direct_post.jwt if jarm)<br/>{vp_token, state}
P->>P: verify issuer signature, disclosures,<br/>key-binding signature vs credential's cnf key,<br/>nonce/aud freshness for this exact session
P-->>W: 200 {} (plain ack, no redirect)
B->>P: GET /api/presentations/requests/{id} (poll)
P-->>B: {status: verified|failed, results:<br/>[{id, vct, templateId, result: {verified, disclosedClaims,<br/>issuerId, holderKeyId, errors}}]}
results carries one entry per requested credential, each with its
verification outcome nested under result (a single-credential request —
one templateId, or a one-entry credentials[] — also gets vct,
templateId, and result flattened onto the top level, for callers who
never moved to the multi-credential shape). The top-level status is
verified only if every entry’s result.verified is.
13. Query the audit trail
Section titled “13. Query the audit trail”GET /api/orgs/{orgId}/audit-events?action=credential.issued&limit=50Authorization: Bearer <access token>Readable by admin and auditor members — the auditor role is the PRD’s
compliance persona, and this endpoint is what it exists for. Filter by
action, resourceType, and/or a from/to date range; results come
newest-first with limit/offset paging and a hasMore flag. The rows
themselves are immutable at the database layer: the application role has
SELECT/INSERT grants on audit_events and nothing else, so
“append-only” is a grant, not a convention.
14. See your usage
Section titled “14. See your usage”GET /api/orgs/{orgId}/usage?from=2026-07-01&to=2026-07-31Authorization: Bearer <access token>Readable by admin and billing members. Returns per-day counts in UTC
days — credentials issued, verifications, revocations, presentations, and
API calls — plus totals for the window, defaulting to the last 30 days. The
console renders the same data with a CSV export.
Counts only: there is no invoicing or payment surface, by design. Two details worth knowing when reconciling numbers:
api.callcounts every successfully authenticated API-key request, whatever it did afterwards — it is a measure of traffic, not of successful business operations.- Credential counters are fed asynchronously from the same events the audit trail records, so they can lag the audit log by a few seconds. They are bucketed by when the event happened, not when it was counted, so a backlog draining after midnight still lands on the right day.
15. Subscribe to webhooks
Section titled “15. Subscribe to webhooks”POST /api/orgs/{orgId}/webhooksAuthorization: Bearer <access token>{ "url": "https://example.com/hooks/vcp", "topics": ["credential.issued", "credential.verified", "credential.revoked"]}Admin-only. The response contains the signing secret exactly once — as
with an API key, store it now. Unlike an API key it is not hashed (the
platform has to recompute an HMAC with it on every delivery), so if you lose
it, rotate it: POST /api/orgs/{orgId}/webhooks/{id}/rotate-secret returns a
new one and invalidates the old.
The target must be a public HTTPS URL. Hostnames are resolved and rejected if they point at loopback, private, CGNAT or link-local addresses, so a subscription cannot be used to probe the platform’s own network.
Each delivery is a POST whose body is:
{ "id": "b3f1…", "topic": "credential.issued", "orgId": "…", "occurredAt": "2026-07-26T10:00:00.000Z", "data": { "credentialId": "…", "templateId": "…", "format": "vc+jwt" }}with three headers: X-VCP-Topic, X-VCP-Delivery-Id (stable across
retries — deduplicate on it), and X-VCP-Signature.
Verify every delivery before acting on it. The signature has the form
t=<unix-seconds>,v1=<hex>, where v1 is
HMAC-SHA256(secret, "<t>.<raw-body>"):
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(header, rawBody, secret) { const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2))) const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest() const given = Buffer.from(parts.v1 ?? '', 'hex') return expected.length === given.length && timingSafeEqual(expected, given)}Compute it over the raw body, before any JSON parsing, compare in
constant time, and reject timestamps outside your tolerance — t is inside
the signed material, so it cannot be rewritten without invalidating the
signature.
Delivery is at-least-once. A non-2xx response or a network failure is retried
with backoff (1 minute, 5, 30, 2 hours, 6) and then marked failed; every
attempt, with its HTTP status and last error, is visible in the delivery log
(GET /api/orgs/{orgId}/webhooks/deliveries, also in the console). Pause a
subscription without losing it or its secret with
PATCH /api/orgs/{orgId}/webhooks/{id} and {"enabled": false}.
The role model
Section titled “The role model”Four roles exist on a membership (admin, developer, auditor,
billing), matching the personas in PRD §5.1. Be aware of the gap
between what’s modeled and what’s enforced today:
| Role | Intended for (PRD §4) | Enforced today? |
|---|---|---|
admin |
Org admin — manage members, keys, billing | Yes — every admin-only action in this guide checks specifically for this role |
auditor |
Read-only, audit trail access | Partially — the audit trail endpoint (step 11) accepts admin and auditor specifically, so the role now grants something the other non-admin roles don’t. Nothing yet restricts an auditor to read-only elsewhere. |
developer |
Build against issuance/verification APIs | No — a developer member is authorized identically to billing: anything that isn’t admin-gated or auditor-gated |
billing |
Billing/usage visibility | No — same as above |
In other words, the code now makes two authorization distinctions:
admin vs. everyone else (most console actions) and admin/auditor
vs. everyone else (the audit trail). developer and billing are
stored and returned everywhere so the shape is right, but no endpoint yet
branches on them — that differentiation is future work, not a bug.
API key scopes
Section titled “API key scopes”API keys carry one or more scopes, independent of the org-role model above:
| Scope | Intended for (PRD §5.2) |
|---|---|
issue |
Credential issuance calls |
verify |
Credential verification calls |
admin |
Key/org management via API key rather than a human session |
A key can hold any combination (["issue"], ["issue", "verify"], etc.).
ApiKeyScopeGuard checks a route’s @RequireApiKeyScopes(...) requirement
against the calling key’s scopes and returns 403 on a mismatch — live on
POST /api/credentials, POST /api/credentials/offers,
GET /api/credentials/offers/{offerId}, and
POST /api/credentials/{id}/revoke (issue — revocation is issuer
lifecycle, so the creating scope is also the ending scope), and on
POST /api/credentials/verify and POST /api/presentations/requests
(verify). admin scope is modeled (an org could issue a scoped key for
key/org management itself, machine-to-machine) but nothing currently
checks it — org/key management goes through a user token (AuthGuard +
RolesGuard), not ApiKeyGuard.
Try it yourself
Section titled “Try it yourself”Two ways in:
- The console, in a browser — sign in, create an organization, and work through templates, keys, usage and webhooks with the same API described here.
- The Bruno collection, which runs this entire flow end
to end including the guardrails (duplicate conflicts, RBAC blocks,
last-admin protection) — see its
README.mdfor setup.