Download OpenAPI specification:
Account/organization management, API key issuance, and multi-format credential issuance/verification for the VC Platform rebuild (see /requirements/PRD.md §5.1-§5.8). Covers everything implemented through Phase 2: org/membership CRUD, API keys, credential templates, four credential formats (VC-JWT, W3C VC 2.0 as JWT, W3C VC 1.1 with JSON-LD Data Integrity proofs, SD-JWT VC) over both the direct REST API and OpenID4VCI/VP 1.0 Final, Bitstring Status List revocation, the queryable audit trail, usage metering, and outbound webhooks (PRD §5.7/§5.9).
Console endpoints authenticate with an OIDC access token; the machine endpoints (Credentials, offers, presentations) authenticate with an org-scoped API key. Each operation states which it expects.
Creates the org and adds the caller as its first admin member, in one transaction. No RLS exemption needed: the org id is generated app-side and the tenant context is set to it before the insert.
| name required | string [ 1 .. 200 ] characters |
| slug required | string^[a-z0-9][a-z0-9-]{1,62}$ Lowercase alphanumeric with hyphens, 2-63 chars, starting alphanumeric. |
{- "name": "string",
- "slug": "string"
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "slug": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Pre-tenant lookup via the list_user_memberships() database function — runs before any org context exists.
[- {
- "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
- "orgName": "string",
- "orgSlug": "string",
- "role": "admin"
}
]Admin-only. The slug is immutable — it appears in issuer identifiers that already-issued credentials point at.
| orgId required | string <uuid> |
| name required | string [ 1 .. 200 ] characters |
{- "name": "string"
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "slug": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Admin-only. Invitations that have not yet been accepted — an invited address becomes a member on its first login.
| orgId required | string <uuid> |
[- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "email": "user@example.com",
- "role": "admin",
- "invitedBy": "9803bd85-1570-4d91-99f5-3a4d18ccfff0",
- "createdAt": "2019-08-24T14:15:22Z"
}
]Admin-only. Has no effect on an invitation that has already been accepted — that is a membership, removed via the members endpoint.
| orgId required | string <uuid> |
| invitationId required | string <uuid> |
{- "statusCode": 0,
- "error": "string",
- "message": "string"
}Admin-only. Re-sends the invite notification email without changing the invitation itself — use when the original delivery was missed or the recipient asks again.
| orgId required | string <uuid> |
| invitationId required | string <uuid> |
{- "statusCode": 0,
- "error": "string",
- "message": "string"
}Any member may list. A non-member gets the same 403 as a nonexistent org id — the membership check doubles as the tenant-existence check.
| orgId required | string <uuid> |
[- {
- "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
- "email": "user@example.com",
- "displayName": "string",
- "role": "admin",
- "since": "2019-08-24T14:15:22Z"
}
]Admin-only. If the address belongs to an existing user, they become a
member immediately (status: active). Otherwise a pending invitation
is recorded (status: invited, userId: null) and resolved into a
membership when that address first signs in.
| orgId required | string <uuid> |
| email required | string <email> |
| role required | string (OrgRole) Enum: "admin" "developer" "auditor" "billing" Org roles per PRD §5.1. |
| displayName | string [ 1 .. 200 ] characters |
{- "email": "user@example.com",
- "role": "admin",
- "displayName": "string"
}{- "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
- "email": "user@example.com",
- "role": "admin",
- "status": "active"
}Admin-only. Refuses to demote the last remaining admin.
| orgId required | string <uuid> |
| userId required | string <uuid> |
| role required | string (OrgRole) Enum: "admin" "developer" "auditor" "billing" Org roles per PRD §5.1. |
{- "role": "admin"
}{- "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
- "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
- "role": "admin",
- "createdAt": "2019-08-24T14:15:22Z"
}Admin-only. The plaintext secret is returned exactly once in this response — only its SHA-256 hash is persisted. There is no way to retrieve it again after this call.
| orgId required | string <uuid> |
| name required | string [ 1 .. 200 ] characters |
| scopes required | Array of strings (ApiKeyScope) non-empty Items Enum: "issue" "verify" "admin" |
| expiresAt | string <date-time> ISO 8601 timestamp. Omit for a key that doesn't expire. |
{- "name": "string",
- "scopes": [
- "issue"
], - "expiresAt": "2019-08-24T14:15:22Z"
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "keyPrefix": "string",
- "scopes": [
- "issue"
], - "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "lastUsedAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "secret": "string"
}Any member may list. Never includes the secret or its hash.
| orgId required | string <uuid> |
[- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "keyPrefix": "string",
- "scopes": [
- "issue"
], - "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "lastUsedAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z"
}
]Admin-only. Idempotent — revoking an already-revoked key returns 204 without recording a second audit event.
| orgId required | string <uuid> |
| keyId required | string <uuid> |
{- "statusCode": 0,
- "error": "string",
- "message": "string"
}Credential template & schema management, console-managed (PRD §5.3).
A template's name and (optional) description are also served
publicly, at the host root (not under /api), as the SD-JWT VC Type
Metadata document for its dc+sd-jwt credentials
(draft-ietf-oauth-sd-jwt-vc-19 §5.3.1): a plain GET on the vct value
baked into every such credential, no .well-known indirection.
GET /vct/v1/{orgId}/{templateId} — { vct, name, description? };
404 if the template doesn't exist or doesn't offer dc+sd-jwt.Admin-only. claimsSchema is stored but not yet enforced against
subjectClaims at issuance time. formats declares which wire
formats this template may be rendered in; all five CredentialFormat
values have a renderer. Revocation is narrower: bitstring-status-list
is implemented, token-status-list is declarable but rejected at
issuance, and mso_mdoc has no revocation mechanism at all yet —
declaring revocable: true for it is accepted at creation time but
rejected with 400 at issuance.
| orgId required | string <uuid> |
| name required | string [ 1 .. 200 ] characters |
| description | string [ 1 .. 2000 ] characters Shown alongside |
| claimsSchema required | object A JSON Schema the eventual subjectClaims are meant to satisfy — accepted and stored, not yet validated against at issuance. |
| formats required | Array of strings (CredentialFormat) non-empty Items Enum: "vc+jwt" "vc11+ld" "vc20" "dc+sd-jwt" "mso_mdoc" |
| validityPeriodDays | integer >= 1 If set, an issued credential's validUntil is validFrom + this many days. Omit for a credential that doesn't expire. |
| revocable | boolean Default: false Whether credentials from this template can be revoked. Intent only — which status mechanism carries it is determined by the credential's format, not chosen here, because the format's own specification decides. When true, issuance allocates a status entry and the renderer embeds the pointer its format defines. |
{- "name": "string",
- "description": "string",
- "claimsSchema": { },
- "formats": [
- "vc+jwt"
], - "validityPeriodDays": 1,
- "revocable": false
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
- "name": "string",
- "description": "string",
- "claimsSchema": { },
- "formats": [
- "vc+jwt"
], - "validityPeriodDays": 0,
- "revocable": true,
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Any member may list.
| orgId required | string <uuid> |
[- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
- "name": "string",
- "description": "string",
- "claimsSchema": { },
- "formats": [
- "vc+jwt"
], - "validityPeriodDays": 0,
- "revocable": true,
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
]Any member may read. A nonexistent or foreign-org template id gets 404 — same no-leak pattern as everywhere else org-scoped.
| orgId required | string <uuid> |
| templateId required | string <uuid> |
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
- "name": "string",
- "description": "string",
- "claimsSchema": { },
- "formats": [
- "vc+jwt"
], - "validityPeriodDays": 0,
- "revocable": true,
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Admin-only, partial update. Affects future issuance only — credentials already issued from this template are signed artifacts and do not change. PRD §5.3's "without code deploys" is what this exists for.
| orgId required | string <uuid> |
| templateId required | string <uuid> |
| name | string [ 1 .. 200 ] characters |
| description | string [ 1 .. 2000 ] characters |
| claimsSchema | object A JSON Schema the eventual subjectClaims are meant to satisfy — accepted and stored, not yet validated against at issuance. |
| formats | Array of strings (CredentialFormat) non-empty Items Enum: "vc+jwt" "vc11+ld" "vc20" "dc+sd-jwt" "mso_mdoc" |
| validityPeriodDays | integer >= 1 |
| revocable | boolean |
{- "name": "string",
- "description": "string",
- "claimsSchema": { },
- "formats": [
- "vc+jwt"
], - "validityPeriodDays": 1,
- "revocable": true
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
- "name": "string",
- "description": "string",
- "claimsSchema": { },
- "formats": [
- "vc+jwt"
], - "validityPeriodDays": 0,
- "revocable": true,
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Admin-only. Already-issued credentials survive — they are self-contained signed documents, and the audit trail keeps its record of them.
| orgId required | string <uuid> |
| templateId required | string <uuid> |
{- "statusCode": 0,
- "error": "string",
- "message": "string"
}API-key-authenticated (issue scope), not a console token — there is
deliberately no {orgId} in this path; the API key itself is the
tenant selector, the same way a Stripe key implies your account.
The org's Ed25519 signing key is provisioned lazily on first issuance (one key per org for Phase 0) and reused after. The platform constructs and encodes the JWS header/payload itself and sends only opaque signing-input bytes to the isolated signing service — it never sees credential content, and the platform never sees the private key (Architecture §5).
| templateId required | string <uuid> |
| subjectClaims required | object Not validated against the template's claimsSchema yet (Phase 0). |
| format | string Default: "vc+jwt" Enum: "vc+jwt" "vc11+ld" "vc20" "dc+sd-jwt" "mso_mdoc" Which of the template's declared formats to render. Defaults to
|
{- "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
- "subjectClaims": { },
- "format": "vc+jwt"
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "format": "vc+jwt",
- "credential": "string",
- "issuedAt": "2019-08-24T14:15:22Z",
- "validFrom": "2019-08-24T14:15:22Z",
- "validUntil": "2019-08-24T14:15:22Z"
}API-key-authenticated (verify scope). Signature verification
never calls the signing service — the issuer identifier is a
did:jwk, which embeds the public key directly, so resolving it is
pure decoding, not a network round trip. Always returns 201 with a
verified boolean and an errors array, whether the credential is
valid or not — a malformed or invalid credential is a successful
API call that reports failure, not an HTTP error. Audited either
way (PRD §5.8's "pass/fail + reason").
Credentials carrying a credentialStatus entry additionally get a
revocation check after signature verification, and it fails closed:
a flipped bit yields revoked, and a status list that cannot be
fetched or signature-verified yields status_unavailable — both
are verification failures, since "could not determine revocation
state" must not read as "valid". The platform's own lists are read
locally; third-party lists are fetched over HTTP and
signature-verified before a single bit is trusted.
| credential required | string non-empty The wire credential to verify — compact JWT ( |
{- "credential": "string"
}{- "verified": true,
- "format": "vc+jwt",
- "errors": [
- {
- "code": "signature_invalid",
- "message": "string"
}
], - "credential": {
- "id": "string",
- "orgId": "string",
- "templateId": "string",
- "issuerId": "string",
- "subjectClaims": { },
- "validFrom": "2019-08-24T14:15:22Z",
- "validUntil": "2019-08-24T14:15:22Z"
}
}Bitstring Status List v1.0 revocation (PRD §5.6, Architecture §4).
Templates that declare revocable: true get a
status entry allocated at issuance and a credentialStatus object
embedded in the rendered credential (vc+jwt and vc20 formats).
Revocation flips the credential's bit; verification then reports
revoked.
The status list itself is served publicly at the host root (not under
/api), because its URL is baked verbatim into issued credentials as
statusListCredential and any relying party must be able to fetch it:
GET /status-lists/{orgId}/{listId} — the signed status list
(W3C BitstringStatusList, served as a Verifiable Credential; used by
vc+jwt and vc20)GET /token-status-lists/{orgId}/{listId} — the IETF Token Status
List, served as a statuslist+jwt; used by dc+sd-jwt, whose own
specification requires this mechanism rather than the W3C one
credential, a W3C VC 2.0 secured as a JWT (verifiable exactly like
any vc20 credential this platform issues). Reveals only bit
positions — never which credential or subject an index belongs to.API-key-authenticated (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). Flips the credential's bit on its status list and
stamps revokedAt, in one transaction, audited as
credential.revoked. Idempotent: revoking an already-revoked
credential returns the original revocation without a second audit
event. Only credentials whose template declared revocable: true
can be revoked — anything else can only expire.
| credentialId required | string <uuid> |
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "revoked": true,
- "revokedAt": "2019-08-24T14:15:22Z"
}Readable by admin and auditor members — the auditor role is the
PRD §4 compliance persona, and this endpoint is what it exists for.
Newest-first, offset-paginated; hasMore is computed by fetching
one extra row, not by counting an append-only table. Rows are
immutable at the database layer (the app role has no UPDATE/DELETE
grants on audit_events).
| orgId required | string <uuid> |
| action | string Exact match on the dotted action, e.g. |
| resourceType | string e.g. |
| from | string <date-time> Inclusive lower bound on the event timestamp. |
| to | string <date-time> Exclusive upper bound on the event timestamp. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| offset | integer >= 0 Default: 0 |
{- "events": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
- "actorType": "user",
- "actorId": "d2f1b55c-8cee-4a0d-af57-aebaeff77518",
- "action": "string",
- "resourceType": "string",
- "resourceId": "string",
- "detail": { },
- "createdAt": "2019-08-24T14:15:22Z"
}
], - "limit": 0,
- "offset": 0,
- "hasMore": true
}Daily usage metering per organization (PRD §5.7) — credentials issued,
verifications, and API calls. Counts only; there is no invoicing or
payment surface. Credential counters are fed asynchronously from the
same events the audit trail records, so they can lag it by a few
seconds; api.call counts every successfully authenticated API-key
request.
Readable by admin and billing. Daily counters in UTC days,
defaulting to a trailing 30-day window. Days with no activity are
omitted, as are metrics with no activity on a given day.
| orgId required | string <uuid> |
| from | string <date> Inclusive lower bound (ISO date). Defaults to 29 days before |
| to | string <date> Inclusive upper bound (ISO date). Defaults to today (UTC). |
{- "from": "2019-08-24",
- "to": "2019-08-24",
- "days": [
- {
- "day": "2019-08-24",
- "counts": {
- "property1": 0,
- "property2": 0
}
}
], - "totals": {
- "property1": 0,
- "property2": 0
}
}Outbound HTTP callbacks when credentials are issued, verified or revoked (PRD §5.9).
Each delivery is a POST whose body is
{ id, topic, orgId, occurredAt, data } and which carries three
headers: X-VCP-Topic, X-VCP-Delivery-Id (stable across retries —
deduplicate on it), and X-VCP-Signature.
Verifying the signature. X-VCP-Signature has the form
t=<unix-seconds>,v1=<hex>, where v1 is
HMAC-SHA256(secret, "<t>.<raw-body>"). Compute it over the raw
request 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. The secret is returned exactly once, when the subscription
is created or its secret rotated.
Delivery is at-least-once. Failures retry with backoff (1m, 5m, 30m, 2h, 6h) and are then marked failed; every attempt is visible in the delivery log. Targets must be public HTTPS endpoints — addresses that resolve to private or link-local ranges are rejected.
Admin-only. Signing secrets are never returned — see the create and rotate operations.
| orgId required | string <uuid> |
[- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "topics": [
- "credential.issued"
], - "description": "string",
- "enabled": true,
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
]Admin-only. Returns the signing secret exactly once, in this response — store it now; it cannot be read back, only rotated.
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.
| orgId required | string <uuid> |
| url required | string <uri> Public HTTPS endpoint. Private and link-local targets are rejected. |
| topics required | Array of strings (WebhookTopic) non-empty Items Enum: "credential.issued" "credential.verified" "credential.revoked" |
| description | string <= 200 characters |
{- "topics": [
- "credential.issued"
], - "description": "string"
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "topics": [
- "credential.issued"
], - "description": "string",
- "enabled": true,
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "secret": "whsec_8Kj2mNp4qRs7tVw9xYz1AbCd"
}Admin-only. Newest first. Offset pagination with a hasMore flag
rather than a total count — follow hasMore, not arithmetic on a
total.
| orgId required | string <uuid> |
| subscriptionId | string <uuid> |
| status | string (WebhookDeliveryStatus) Enum: "pending" "delivering" "succeeded" "failed" |
| limit | integer [ 1 .. 200 ] Default: 50 |
| offset | integer >= 0 Default: 0 |
{- "deliveries": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "subscriptionId": "d079718b-ff63-45dd-947b-4950c023750f",
- "topic": "credential.issued",
- "status": "pending",
- "attempts": 0,
- "responseStatus": 0,
- "lastError": "string",
- "nextAttemptAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "deliveredAt": "2019-08-24T14:15:22Z"
}
], - "limit": 0,
- "offset": 0,
- "hasMore": true
}Admin-only, partial update. Setting enabled to false pauses
deliveries without discarding the subscription or its secret.
| orgId required | string <uuid> |
| id required | string <uuid> |
| url | string <uri> |
| topics | Array of strings (WebhookTopic) non-empty Items Enum: "credential.issued" "credential.verified" "credential.revoked" |
| description | string <= 200 characters |
| enabled | boolean False pauses deliveries; the subscription and its secret survive. |
{- "topics": [
- "credential.issued"
], - "description": "string",
- "enabled": true
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "topics": [
- "credential.issued"
], - "description": "string",
- "enabled": true,
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Admin-only. Returns the new secret exactly once. Rotation takes effect immediately: deliveries queued but not yet sent are signed with the new secret, so update your receiver before rotating if you cannot tolerate rejected deliveries.
| orgId required | string <uuid> |
| id required | string <uuid> |
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "topics": [
- "credential.issued"
], - "description": "string",
- "enabled": true,
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "secret": "whsec_8Kj2mNp4qRs7tVw9xYz1AbCd"
}OpenID4VCI 1.0 Final issuance (PRD §5.4, Architecture §4). Only offer
creation lives under /api — the wallet-facing protocol endpoints sit at
the host root, because every org is its own credential issuer at
{publicUrl}/oid4vci/{orgId}. Two grant types are supported, both
requiring Wallet Instance Attestation: the pre-authorized code flow
below (no PAR, no DPoP, but client attestation required since the
pre-authorized_code Wallet Attestation CR gave it parity with
authorization_code), and, since Phase 2, a full authorization_code
flow gated by PAR, PKCE, a real browser login, client attestation and
DPoP — see AUTHORIZED_FLOW_ARCHITECTURE.md for the wire-level detail
this section deliberately doesn't repeat:
GET /.well-known/openid-credential-issuer/oid4vci/{orgId} — issuer metadataGET /.well-known/oauth-authorization-server/oid4vci/{orgId} — AS metadataGET /oid4vci/{orgId}/jwks — org public key set (access-token verification)POST /oid4vci/{orgId}/par — RFC 9126 pushed authorization request;
starts the authorization_code grant (not used by the
pre-authorized-code grant)GET /oid4vci/{orgId}/authorize — the browser lands here after PAR;
completes silently if a login session cookie already covers this
org, otherwise renders a minimal login formPOST /oid4vci/{orgId}/authorize — the login form's submit target;
redirects the browser back to the wallet with an authorization codePOST /oid4vci/{orgId}/token — exchanges either grant
(pre-authorized code, or the authorization code from the flow above)
for an access token; both require a Client Attestation JWT
(OAuth-Client-Attestation/-PoP headers) — anonymous requests are
rejected on both grantsPOST /oid4vci/{orgId}/nonce — c_nonce endpoint (Final's dedicated nonce endpoint)POST /oid4vci/{orgId}/credential — proof-of-possession → SD-JWT VCPOST /oid4vci/{orgId}/notification — wallet reports what it did
with the issued credential (credential_accepted /
credential_failure / credential_deleted), same access token as
the credential endpoint. Optional per spec; when a wallet sends
credential_accepted, GET /credentials/offers/{offerId} reports
confirmed instead of accepted.Wire formats for those are defined by the OpenID4VCI / OAuth2 specs,
not re-documented here; conforming wallets discover everything from
the credential offer URI returned by POST /credentials/offers.
Client Attestation and Key Attestation issuance — what a wallet
needs before it can even reach the endpoints above — used to be
documented here as prose under this file's own Attestations/
Key Attestations tags. Both moved to apps/wallet-backend/docs/openapi.yaml
when that issuance surface was carved out into its own deployable for
security isolation (Client/Key Attestation are the only fully
unauthenticated, pre-auth routes anywhere on this system) — see the
Wallet Backend API reference, linked from
this site's sidebar, for the full contract.
Starts the OpenID4VCI pre-authorized code flow: the org's backend
(API key, issue scope) states what to issue; the response's
credentialOffer URI goes to the holder as a QR code / deep link,
and their wallet drives the rest against the wallet-facing endpoints
(see the OpenID4VCI tag). The offer expires after expiresIn
seconds, the pre-authorized code is single-use, and the credential
is bound to the wallet's proof-of-possession key (cnf) at the
credential endpoint — subject claims are fixed here, at offer time.
| templateId required | string <uuid> |
| subjectClaims required | object Fixed at offer time — the wallet receives exactly these claims. |
| format | string Default: "dc+sd-jwt" Enum: "vc+jwt" "vc11+ld" "vc20" "dc+sd-jwt" "mso_mdoc" Defaults to dc+sd-jwt. dc+sd-jwt and mso_mdoc are both
OpenID4VCI-offerable; vc+jwt, vc20, and vc11+ld are issued
only via the direct POST /credentials endpoint, not as an
OpenID4VCI offer. Must also be listed in the template's own
|
{- "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
- "subjectClaims": { },
- "format": "dc+sd-jwt"
}{- "credentialOffer": "string",
- "credentialOfferObject": { },
- "expiresIn": 0,
- "offerId": "42da58f8-9040-4cf5-98ce-bce9965cca0d"
}Requires an API key with the issue scope — same requirement as creating the offer.
| offerId required | string <uuid> |
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "status": "pending",
- "credentialId": "f568fec0-10b6-4b94-9daf-e62c50c9bf3e",
- "holderKeyId": "string",
- "error": "attestation_required"
}OpenID4VP 1.0 Final relying-party flow with DCQL (PRD §5.5,
Architecture §4). The org's backend creates a presentation request —
by default JAR-signed (RFC 9101) with this platform's ACME verifier
certificate, x509_hash client-id scheme (HAIP 1.0 §5), delivered via
request_uri; pass signed: false for the unsigned, by-value,
redirect_uri client-id form instead — shows the returned
openid4vp:// URI to the holder, and polls the request for the
outcome. Two more opt-in knobs on the same request, both independent
of signed: trustModel: 'rpac' signs under the EUDI ARF
Relying-Party trust model (ETSI TS 119 411-8) instead of the default
ACME/web-pki anchor and attaches an RPRC via verifier_info
(ETSI TS 119 475, sandbox-only); jarm: true has the wallet answer
encrypted, direct_post.jwt, instead of plain direct_post —
encrypt-only (an unsigned JWT, encrypted), per OpenID4VP 1.0's own
definition, not the generic JARM spec's sign-and-encrypt option it
replaced. See CreatePresentationRequest for both. The wallet
answers by direct_post (or direct_post.jwt if jarm) to the
wallet-facing endpoint at the host root:
POST /oid4vp/{orgId}/response/{requestId} — authorization response
(vp_token with DCQL-keyed SD-JWT VC presentations)Presentations must carry a key-binding JWT: its signature is checked
against the credential's cnf key, and its nonce/aud must match this
exact request — a presentation captured from one exchange cannot be
replayed into another.
Starts an OpenID4VP exchange (API key, verify scope): names the
credential type(s) being asked for (by template) and optionally
which claims the wallet should disclose. Poll the returned id for
the outcome; the request expires after expiresIn seconds. Asking
for several credentials at once (credentials) still produces one
URI, one wallet screen, one response.
| templateId | string <uuid> Template whose credential type is being asked for. For
dc+sd-jwt, vct = the template name; for mso_mdoc, docType =
the template name. Mutually exclusive with |
| format | string Default: "dc+sd-jwt" Enum: "vc+jwt" "vc11+ld" "vc20" "dc+sd-jwt" "mso_mdoc" Defaults to dc+sd-jwt. dc+sd-jwt and mso_mdoc are both
presentable; must also be listed in the template's own
|
| requestedClaims | Array of strings Claim names the wallet is asked to disclose. Omitted = the
wallet decides. Only used with |
| signed | boolean Default: true JAR-sign the request (RFC 9101) with this platform's ACME
verifier certificate, |
| trustModel | string Default: "web-pki" Enum: "web-pki" "rpac" Which certificate authenticates this request's JAR signature,
when |
| jarm | boolean Default: false Request the wallet deliver its authorization response encrypted
( |
Array of objects [ 1 .. 10 ] items Ask for several credentials in one request: one openid4vp://
URI, one wallet claim-selection screen, one direct_post
response. Every entry is mandatory — the exchange only
verifies once the wallet has answered all of them. Mutually
exclusive with the top-level |
{- "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
- "format": "dc+sd-jwt",
- "requestedClaims": [
- "string"
], - "signed": true,
- "trustModel": "web-pki",
- "jarm": false,
- "credentials": [
- {
- "id": "string",
- "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
- "format": "dc+sd-jwt",
- "requestedClaims": [
- "string"
]
}
]
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "authorizationRequest": "string",
- "expiresIn": 0
}Requires an API key with the verify scope — same requirement as creating the request.
| requestId required | string <uuid> |
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "status": "pending",
- "vct": "string",
- "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
- "result": {
- "verified": true,
- "disclosedClaims": { },
- "issuerId": "string",
- "holderKeyId": "string",
- "errors": [
- {
- "code": "signature_invalid",
- "message": "string"
}
]
}, - "results": [
- {
- "id": "string",
- "vct": "string",
- "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
- "result": {
- "verified": true,
- "disclosedClaims": { },
- "issuerId": "string",
- "holderKeyId": "string",
- "errors": [
- {
- "code": "signature_invalid",
- "message": "string"
}
]
}
}
]
}Logging into the console with a verifiable credential instead of Zitadel — a second front door onto the same account, not a replacement: an account must already exist (via Zitadel) and a credential must already be linked to it before login can succeed.
Both endpoint families follow the same create-request/poll shape as
OpenID4VP above, reusing the same openid4vp:// request/QR-code/
deep-link UX for both same-device (a browser extension) and
cross-device (a mobile wallet scanning the QR) presentation. The
wallet answers by direct_post to the same wallet-facing endpoint
shape as OpenID4VP, at the host root:
POST /oid4vp/login-response/{requestId} — authorization responseNo vct/docType is requested — either flow accepts whichever
credential (dc+sd-jwt or mso_mdoc) the wallet offers. What decides
acceptance is the credential's ID (its jti for dc+sd-jwt; a hash of
the holder's bound key for mso_mdoc, which has no jti-equivalent
claim) matching an entry already on a user profile — an unrecognized
credential is rejected outright, never auto-provisioned into a new
account. On a successful login, the disclosed name/email sync onto the
profile when they differ from what's stored.
Public, same as creating it — the request id itself is the bearer capability. Once status is verified, accessToken/expiresIn carry the console's new session token.
| id required | string <uuid> |
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "status": "pending",
- "accessToken": "string",
- "expiresIn": 0,
- "claims": {
- "email": "string",
- "name": "string"
}, - "errors": [
- {
- "code": "string",
- "message": "string"
}
]
}Requires the existing Zitadel-backed console session (ConsoleBearerAuth, the default for this page). Takes no body — the target is always the authenticated caller, never a supplied user id. Linking a new credential replaces any previously linked one.
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "authorizationRequest": "string",
- "expiresIn": 0
}Requires the same session that created the request — another caller's (or an unknown/expired) id reports 404, not 403, so as not to confirm a request id belongs to someone else.
| id required | string <uuid> |
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "status": "pending",
- "accessToken": "string",
- "expiresIn": 0,
- "claims": {
- "email": "string",
- "name": "string"
}, - "errors": [
- {
- "code": "string",
- "message": "string"
}
]
}An MCP (Model Context Protocol) server exposing agent-identity
operations as tools — AGENT_IDENTITY_MCP_PLAN.md's Slice A: an org's
own automation gets its own audit-attributable identity, not a new
agent-to-agent trust model. A single Streamable HTTP endpoint, not
modelled as formal paths for the same reason as OpenID4VCI/OpenID4VP
above — a client discovers everything (tool names, input schemas) via
the MCP protocol itself, not an OpenAPI schema:
POST /mcp — MCP Streamable HTTP transport (stateless: no session ID,
a fresh server per request). Authorization: Bearer <api key>, same
as every other API-key-authenticated route.Tools, each a thin wrapper over an already-built operation:
create_agent_identity — mints a platform-custodial key and issues
an AgentIdentityCredential under it. The template must be
revocable. Requires the admin scope.open_agent_session — the platform presents that credential on the
agent's behalf (KB-JWT signed by the signing service, not the
caller) and, once its own signature and nonce/aud/vct binding
verify, returns a short-lived session token. The session never
carries more scope than the API key that opened it. Requires admin.issue_credential / verify_credential — wrap POST /credentials
and POST /credentials/verify, but attribute the resulting audit
event to the agent (actorType: 'agent') instead of only the API
key, given a valid agentSessionToken whose session carries the
matching issue/verify scope.present_agent_identity — Slice B: presents the agent's credential
for an audience/nonce supplied by a real external relying party
(delivered to the caller out of band). Unlike open_agent_session,
the platform does not verify the result itself — the external party
does, using standard did:jwk/cnf.jwk/status-list checks, the
same as any other dc+sd-jwt credential this platform issues.
Requires admin.revoke_agent_identity — revokes an agent identity: blocks future
open_agent_session/present_agent_identity calls, and flips the
credential's real status-list entry (the same mechanism
POST /credentials/{id}/revoke uses) so an external relying party
checking independently sees the same answer. Requires admin.