User Guide
A step-by-step walkthrough of how the pieces built so far fit together.
For the full request/response reference see openapi.yaml;
to try any of this live, use the Bruno collection.
The pieces, in one picture
Section titled “The pieces, in one picture”
flowchart LR
subgraph Caller identity
U[Human user<br/>X-Dev-User-Email]
K[API key<br/>Authorization: Bearer]
end
U -->|DevUserGuard| 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)]
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 the Phase 0 dev-auth stub (DevUserGuard) |
X-Dev-User-Email: someone@example.com |
Org/member/API-key/template management — anything an admin does in a console |
| API key | an org, via ApiKeyGuard |
Authorization: Bearer vcp_... |
Machine-to-machine calls — issuing and verifying credentials |
X-Dev-User-Email is a deliberate Phase 0 stand-in for real login (Architecture
§2) — sending any email upserts a user record with no password check. It will
be replaced by OIDC; the guard/decorator shapes it establishes are meant to
stay final.
Step by step
Section titled “Step by step”1. Create an organization
Section titled “1. Create an organization”POST /api/orgsX-Dev-User-Email: admin@acme.test{ "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.
2. Invite teammates
Section titled “2. Invite teammates”POST /api/orgs/{orgId}/membersX-Dev-User-Email: admin@acme.test{ "email": "dev@acme.test", "role": "developer" }Admin-only. Upserts the invited user by email (they don’t need to have
“signed up” first — same upsert-on-first-contact pattern as the dev-auth
stub) and adds the membership. Adding the same email twice returns 409.
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”POST /api/orgs/{orgId}/api-keysX-Dev-User-Email: admin@acme.test{ "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}X-Dev-User-Email: admin@acme.testAdmin-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}/templatesX-Dev-User-Email: admin@acme.test{ "name": "Proof of Employment", "claimsSchema": { "type": "object", "properties": { "jobTitle": { "type": "string" } } }, "formats": ["vc+jwt", "vc20", "dc+sd-jwt"], "validityPeriodDays": 365, "statusMechanism": "bitstring-status-list"}Admin-only to create; any member can list/read (GET /api/orgs/{orgId}/templates,
GET /api/orgs/{orgId}/templates/{templateId}). 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.
Four have renderers/parsers as of Phase 2: 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), and dc+sd-jwt (SD-JWT VC with selective disclosure).
Declaring an unimplemented format (mso_mdoc) is allowed as intent but
fails with a clear 400 at issuance, not at template-creation time.
statusMechanism is optional: omit it for credentials that can only
expire, or set bitstring-status-list to make them revocable (see the
revocation step below). token-status-list is declarable intent, same
rule as unimplemented formats.
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 declares a
statusMechanism, issuance also allocates the credential’s slot on the
org’s status list and embeds a credentialStatus entry pointing at it.
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 credentialStatus entry, 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 a
statusMechanism (400 otherwise — everything else can only expire).
Idempotent: revoking twice returns the original revokedAt without a
second audit event.
Under the hood this flips one bit on the org’s Bitstring Status List
(spec-minimum 131,072 entries, so a list reveals only bit positions —
never which credential or subject an index belongs to). The list itself is
served publicly at GET /status-lists/{orgId}/{listId} on the host root
(not under /api — the URL is baked into issued credentials forever), as
a signed VC 2.0 JWT that any verifier of this platform’s credentials can
verify with the same code path.
11. Query the audit trail
Section titled “11. Query the audit trail”GET /api/orgs/{orgId}/audit-events?action=credential.issued&limit=50X-Dev-User-Email: admin@acme.testReadable 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.
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, 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 still goes through DevUserGuard, not
ApiKeyGuard.
Try it yourself
Section titled “Try it yourself”The Bruno collection runs this entire flow end to end,
including the guardrails (duplicate conflicts, RBAC blocks, last-admin
protection) — see its README.md for setup.