Skip to content

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.

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.

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

POST /api/orgs/{orgId}/members
X-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.

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

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}
X-Dev-User-Email: admin@acme.test

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

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

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

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

GET /api/orgs/{orgId}/audit-events?action=credential.issued&limit=50
X-Dev-User-Email: admin@acme.test

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.

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

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.