Skip to content

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.

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.)

POST /api/orgs
Authorization: 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.

POST /api/orgs/{orgId}/members
Authorization: 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" (with userId: 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}/invitations
DELETE /api/orgs/{orgId}/invitations/{invitationId}
GET /api/orgs/{orgId}/members -- any member can call this
PATCH /api/orgs/{orgId}/members/{userId} -- admin-only, change role
DELETE /api/orgs/{orgId}/members/{userId} -- admin-only, remove

Two 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 orgId gets 403, identical to what a member calling a nonexistent orgId gets. Membership lookup doubles as the tenant-existence check, so there’s no way to distinguish “wrong id” from “not your org.”

In the console: API keys → New API key. Over HTTP:

POST /api/orgs/{orgId}/api-keys
Authorization: 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.

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).

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.

POST /api/orgs/{orgId}/templates
Authorization: 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.

POST /api/credentials
Authorization: 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.

POST /api/credentials/verify
Authorization: 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.

POST /api/credentials/{credentialId}/revoke
Authorization: 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, a statuslist+jwt, for dc+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.

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/offers
Authorization: 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 pendingacceptedconfirmed (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/requests
Authorization: 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; pass false for 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 default web-pki (ACME) anchor, and attaches an RPRC (ETSI TS 119 475) via the request’s verifier_info. Sandbox-only today, and fails the request (503) if no RPAC is active for this deployment. Ignored if signed: false.
  • "jarm": true — instead of a plain direct_post, the wallet delivers its response encrypted (response_mode=direct_post.jwt): an unsigned JWT, encrypted ECDH-ES/A128GCM against 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 to false.
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.

GET /api/orgs/{orgId}/audit-events?action=credential.issued&limit=50
Authorization: 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.

GET /api/orgs/{orgId}/usage?from=2026-07-01&to=2026-07-31
Authorization: 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.call counts 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.
POST /api/orgs/{orgId}/webhooks
Authorization: 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}.

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 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.

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.md for setup.