VC Platform API (0.2.0)

Download OpenAPI specification:

License: Proprietary

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.

Organizations

Org creation and the caller's org list (PRD §5.1)

Create an organization

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.

Authorizations:
ConsoleBearerAuth
Request Body schema: application/json
required
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.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "slug": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "slug": "string",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

List the caller's organizations

Pre-tenant lookup via the list_user_memberships() database function — runs before any org context exists.

Authorizations:
ConsoleBearerAuth

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Rename an organization

Admin-only. The slug is immutable — it appears in issuer identifiers that already-issued credentials point at.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
Request Body schema: application/json
required
name
required
string [ 1 .. 200 ] characters

Responses

Request samples

Content type
application/json
{
  • "name": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "slug": "string",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Members

Org membership management, admin-gated except listing (PRD §5.1)

List pending invitations

Admin-only. Invitations that have not yet been accepted — an invited address becomes a member on its first login.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Revoke a pending invitation

Admin-only. Has no effect on an invitation that has already been accepted — that is a membership, removed via the members endpoint.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
invitationId
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "statusCode": 0,
  • "error": "string",
  • "message": "string"
}

Resend a pending invitation's email

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.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
invitationId
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "statusCode": 0,
  • "error": "string",
  • "message": "string"
}

List an organization's members

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.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Add a member

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.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
Request Body schema: application/json
required
email
required
string <email>
role
required
string (OrgRole)
Enum: "admin" "developer" "auditor" "billing"

Org roles per PRD §5.1.

displayName
string [ 1 .. 200 ] characters

Responses

Request samples

Content type
application/json
{
  • "email": "user@example.com",
  • "role": "admin",
  • "displayName": "string"
}

Response samples

Content type
application/json
{
  • "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
  • "email": "user@example.com",
  • "role": "admin",
  • "status": "active"
}

Change a member's role

Admin-only. Refuses to demote the last remaining admin.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
userId
required
string <uuid>
Request Body schema: application/json
required
role
required
string (OrgRole)
Enum: "admin" "developer" "auditor" "billing"

Org roles per PRD §5.1.

Responses

Request samples

Content type
application/json
{
  • "role": "admin"
}

Response samples

Content type
application/json
{
  • "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
  • "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
  • "role": "admin",
  • "createdAt": "2019-08-24T14:15:22Z"
}

Remove a member

Admin-only. Refuses to remove the last remaining admin.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
userId
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "statusCode": 0,
  • "error": "string",
  • "message": "string"
}

API Keys

Org-scoped API key issuance, listing, and revocation (PRD §5.2)

Issue an API key

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.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
Request Body schema: application/json
required
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.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "scopes": [
    ],
  • "expiresAt": "2019-08-24T14:15:22Z"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "keyPrefix": "string",
  • "scopes": [
    ],
  • "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"
}

List API keys

Any member may list. Never includes the secret or its hash.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Revoke an API key

Admin-only. Idempotent — revoking an already-revoked key returns 204 without recording a second audit event.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
keyId
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "statusCode": 0,
  • "error": "string",
  • "message": "string"
}

Templates

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.

Create a credential template

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.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
Request Body schema: application/json
required
name
required
string [ 1 .. 200 ] characters
description
string [ 1 .. 2000 ] characters

Shown alongside name in the SD-JWT VC Type Metadata document served at this template's vct URI (GET /vct/v1/{orgId}/{templateId}).

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.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "claimsSchema": { },
  • "formats": [
    ],
  • "validityPeriodDays": 1,
  • "revocable": false
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
  • "name": "string",
  • "description": "string",
  • "claimsSchema": { },
  • "formats": [
    ],
  • "validityPeriodDays": 0,
  • "revocable": true,
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

List an organization's credential templates

Any member may list.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Get a credential template

Any member may read. A nonexistent or foreign-org template id gets 404 — same no-leak pattern as everywhere else org-scoped.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
templateId
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
  • "name": "string",
  • "description": "string",
  • "claimsSchema": { },
  • "formats": [
    ],
  • "validityPeriodDays": 0,
  • "revocable": true,
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Update a credential template

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.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
templateId
required
string <uuid>
Request Body schema: application/json
required
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

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "claimsSchema": { },
  • "formats": [
    ],
  • "validityPeriodDays": 1,
  • "revocable": true
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
  • "name": "string",
  • "description": "string",
  • "claimsSchema": { },
  • "formats": [
    ],
  • "validityPeriodDays": 0,
  • "revocable": true,
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Delete a credential template

Admin-only. Already-issued credentials survive — they are self-contained signed documents, and the audit trail keeps its record of them.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
templateId
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "statusCode": 0,
  • "error": "string",
  • "message": "string"
}

Credentials

Issuance and verification, API-key-authenticated (PRD §5.4, §5.5)

Issue a credential

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

Authorizations:
ApiKeyAuth
Request Body schema: application/json
required
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 vc+jwt (the Phase 0 format) when omitted, so pre-Phase 1 callers are unaffected. Implemented: vc+jwt, vc20, vc11+ld, dc+sd-jwt (SD-JWT VC — every subject claim selectively disclosable, registered claims in the clear).

Responses

Request samples

Content type
application/json
{
  • "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
  • "subjectClaims": { },
  • "format": "vc+jwt"
}

Response samples

Content type
application/json
{
  • "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"
}

Verify a credential

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.

Authorizations:
ApiKeyAuth
Request Body schema: application/json
required
credential
required
string non-empty

The wire credential to verify — compact JWT (vc+jwt / vc20), compact SD-JWT VC presentation, or a JSON-serialized vc11+ld credential document. The format is auto-detected: a leading { means JSON-LD, otherwise the JOSE typ header decides (falling back to the ~ separator), so there is no format field to pass. SD-JWT VC presentations may carry a key-binding JWT; it is verified against the credential's cnf key when present.

Responses

Request samples

Content type
application/json
{
  • "credential": "string"
}

Response samples

Content type
application/json
{
  • "verified": true,
  • "format": "vc+jwt",
  • "errors": [
    ],
  • "credential": {
    }
}

Revocation

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.

Revoke a credential

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.

Authorizations:
ApiKeyAuth
path Parameters
credentialId
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "revoked": true,
  • "revokedAt": "2019-08-24T14:15:22Z"
}

Audit

Read side of the append-only audit trail (PRD §5.8) — admin and auditor roles

Query the audit trail

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

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
query Parameters
action
string

Exact match on the dotted action, e.g. credential.issued.

resourceType
string

e.g. credential, api_key, org, credential_template.

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

Responses

Response samples

Content type
application/json
{
  • "events": [
    ],
  • "limit": 0,
  • "offset": 0,
  • "hasMore": true
}

Usage

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.

Get usage for an organization

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.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
query Parameters
from
string <date>

Inclusive lower bound (ISO date). Defaults to 29 days before to.

to
string <date>

Inclusive upper bound (ISO date). Defaults to today (UTC).

Responses

Response samples

Content type
application/json
{
  • "from": "2019-08-24",
  • "to": "2019-08-24",
  • "days": [
    ],
  • "totals": {
    }
}

Webhooks

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.

List webhook subscriptions

Admin-only. Signing secrets are never returned — see the create and rotate operations.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Create a webhook subscription

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.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
Request Body schema: application/json
required
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

Responses

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "topics": [
    ],
  • "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"
}

List delivery attempts

Admin-only. Newest first. Offset pagination with a hasMore flag rather than a total count — follow hasMore, not arithmetic on a total.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
query Parameters
subscriptionId
string <uuid>
status
string (WebhookDeliveryStatus)
Enum: "pending" "delivering" "succeeded" "failed"
limit
integer [ 1 .. 200 ]
Default: 50
offset
integer >= 0
Default: 0

Responses

Response samples

Content type
application/json
{
  • "deliveries": [
    ],
  • "limit": 0,
  • "offset": 0,
  • "hasMore": true
}

Update a webhook subscription

Admin-only, partial update. Setting enabled to false pauses deliveries without discarding the subscription or its secret.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
id
required
string <uuid>
Request Body schema: application/json
required
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.

Responses

Request samples

Content type
application/json
{
  • "topics": [
    ],
  • "description": "string",
  • "enabled": true
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "topics": [
    ],
  • "description": "string",
  • "enabled": true,
  • "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Delete a webhook subscription

Admin-only. Its delivery history goes with it.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "statusCode": 0,
  • "error": "string",
  • "message": "string"
}

Rotate the signing secret

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.

Authorizations:
ConsoleBearerAuth
path Parameters
orgId
required
string <uuid>
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "topics": [
    ],
  • "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

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 metadata
  • GET /.well-known/oauth-authorization-server/oid4vci/{orgId} — AS metadata
  • GET /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 form
  • POST /oid4vci/{orgId}/authorize — the login form's submit target; redirects the browser back to the wallet with an authorization code
  • POST /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 grants
  • POST /oid4vci/{orgId}/nonce — c_nonce endpoint (Final's dedicated nonce endpoint)
  • POST /oid4vci/{orgId}/credential — proof-of-possession → SD-JWT VC
  • POST /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.

Create a credential offer

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.

Authorizations:
ApiKeyAuth
Request Body schema: application/json
required
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 formats, or the request is rejected.

Responses

Request samples

Content type
application/json
{
  • "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
  • "subjectClaims": { },
  • "format": "dc+sd-jwt"
}

Response samples

Content type
application/json
{
  • "credentialOffer": "string",
  • "credentialOfferObject": { },
  • "expiresIn": 0,
  • "offerId": "42da58f8-9040-4cf5-98ce-bce9965cca0d"
}

Poll a credential offer

Requires an API key with the issue scope — same requirement as creating the offer.

Authorizations:
ApiKeyAuth
path Parameters
offerId
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "status": "pending",
  • "credentialId": "f568fec0-10b6-4b94-9daf-e62c50c9bf3e",
  • "holderKeyId": "string",
  • "error": "attestation_required"
}

OpenID4VP

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.

Create a presentation request

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.

Authorizations:
ApiKeyAuth
Request Body schema: application/json
required
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 credentials.

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 formats. Changes the DCQL query shape: for dc+sd-jwt each requestedClaims entry becomes the path [name]; for mso_mdoc it becomes [docType, name] (the namespace is always the template name). Only used with templateId — set format per entry inside credentials instead.

requestedClaims
Array of strings

Claim names the wallet is asked to disclose. Omitted = the wallet decides. Only used with templateId — set requestedClaims per entry inside credentials instead.

signed
boolean
Default: true

JAR-sign the request (RFC 9101) with this platform's ACME verifier certificate, x509_hash client-id scheme (HAIP 1.0 §5), delivered via request_uri rather than by value. Defaults to true. Pass false for the old unsigned redirect_uri client-id form, which needs no verifier certificate.

trustModel
string
Default: "web-pki"
Enum: "web-pki" "rpac"

Which certificate authenticates this request's JAR signature, when signed (default true). Both use the identical x509_hash client-id scheme — only the certificate issuer and trust anchor differ. web-pki (default) is today's ACME certificate, unchanged. rpac opts into the EUDI ARF Relying-Party trust model instead (ETSI TS 119 411-8) and attaches an RPRC (ETSI TS 119 475) via the request's verifier_info; requires an RPAC to already be active for this deployment, or the request fails with 503 — sandbox-only today. Ignored entirely when signed: false.

jarm
boolean
Default: false

Request the wallet deliver its authorization response encrypted (response_mode=direct_post.jwt) instead of plain direct_post. Encrypt-only — an unsigned JWT, encrypted ECDH-ES/A128GCM against a fresh per-request keypair (no certificate needed, unlike signed) — per OpenID4VP 1.0's own definition; it dropped the generic JARM spec's sign-and-encrypt option (openid/OpenID4VP#463: "implementations MUST use an unsigned, encrypted JWT"). Defaults to false.

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.

Responses

Request samples

Content type
application/json
{
  • "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
  • "format": "dc+sd-jwt",
  • "requestedClaims": [
    ],
  • "signed": true,
  • "trustModel": "web-pki",
  • "jarm": false,
  • "credentials": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "authorizationRequest": "string",
  • "expiresIn": 0
}

Poll a presentation request

Requires an API key with the verify scope — same requirement as creating the request.

Authorizations:
ApiKeyAuth
path Parameters
requestId
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "status": "pending",
  • "vct": "string",
  • "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
  • "result": {
    },
  • "results": [
    ]
}

Credential Login

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 response

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

Start a credential-login exchange

Public — there is no session yet by definition. Takes no body.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "authorizationRequest": "string",
  • "expiresIn": 0
}

Poll a credential-login exchange

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.

path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "status": "pending",
  • "accessToken": "string",
  • "expiresIn": 0,
  • "claims": {
    },
  • "errors": [
    ]
}

Start linking a credential to the caller's profile

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.

Authorizations:
ConsoleBearerAuth

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "authorizationRequest": "string",
  • "expiresIn": 0
}

Poll a credential-link exchange

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.

Authorizations:
ConsoleBearerAuth
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "status": "pending",
  • "accessToken": "string",
  • "expiresIn": 0,
  • "claims": {
    },
  • "errors": [
    ]
}

MCP

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.