REST API
The OID4VCI protocol endpoints (/.well-known/openid-credential-issuer, /oid4vci/credential, /oid4vci/deferredCredential, /oid4vci/nonce, /oid4vci/notification, /oid4vci/credentials/offers/{offerId}) come from the IDK and are documented in the IDK issuer guide. They are the wallet-facing surface and you do not call them from your own backend.
The EDK adds a second surface under /oid4vci/sessions/... that backend code, admin tooling, and async upstream systems use to drive an issuance pipeline session: create it, push attributes into it, evaluate its completeness, approve it, mark a source failed, and accept callbacks from async sources. This page documents that surface. The pipeline behaviour these endpoints expose is covered in Attribute Pipeline; read that first if you are new to the EDK pipeline model.
All endpoints below mount under the issuer service's /oid4vci adapter base. The full path is shown for each endpoint. Authentication is the OIDC bearer token (except the callback endpoint, which uses an opaque capability token in the path). The tenant is resolved by the EDK Layer 1 tenant pipeline from the validated JWT, the Host header, or the configured default; it is never read from a client-supplied wire header.
Quick Reference
The EDK exposes the pipeline endpoints in two parallel shapes:
- The versioned compact
groupsshape under/api/v1/oid4vci/...is the recommended surface for new integrations. One group carries sharedsourceId/phase/timestamp/retentiononce for every attribute and lookup key inside it, and integrators may reference an OCA bundle by id so the server derives the per-attribute path, value kind, data classification, legal basis, and retention. - The legacy unversioned shape under
/oid4vci/sessions/...keeps the per-attribute IDKAttributeRecord/LookupKeybody verbatim and is preserved for IDK callers.
| Method | Path | Description |
|---|---|---|
| POST | /oid4vci/sessions | Initialise an issuance pipeline session |
| GET | /oid4vci/sessions/{correlationId}/attributes | Read the current attribute state of a session |
| POST | /api/v1/oid4vci/sessions/{correlationId}/attributes | Contribute attributes (compact groups shape) |
| POST | /oid4vci/sessions/{correlationId}/attributes | Contribute attributes (legacy unversioned shape, preserved for IDK callers) |
| GET | /oid4vci/sessions/{correlationId}/completeness | Evaluate which credentials are ready to assemble |
| POST | /oid4vci/sessions/{correlationId}/completeness | Evaluate completeness with an explicit phase to re-run |
| POST | /oid4vci/sessions/{correlationId}/approve | Approve issuance for a session in AWAITING_APPROVAL |
| POST | /oid4vci/sessions/{correlationId}/fail | Mark a source contribution as failed |
| POST | /api/v1/oid4vci/sessions/{correlationId}/callbacks/{callbackToken} | Async-source callback ingress, compact body (capability-token authenticated) |
| POST | /oid4vci/sessions/{correlationId}/callbacks/{callbackToken} | Async-source callback ingress, legacy body (capability-token authenticated) |
Backend-facing credential-offer endpoints (the EDK rest-tenant module overrides the IDK offer endpoints to be tenant-path-aware, and adds a compact V1 variant that accepts preSeededGroups):
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/oid4vci/offers | Create a credential offer with the compact preSeededGroups shape (EDK enterprise) |
| POST | /oid4vci/backend/credential/offers | Create a tenant-bound credential offer (legacy unversioned, preserved for IDK callers) |
| GET | /oid4vci/backend/credential/offers/{correlationId} | Read the status of an offer-creation request |
When to Call Which
Most flows do not need every endpoint. The decision tree:
- A backend system (HR portal, enrolment app) is triggering the issuance:
POST /oid4vci/sessionsto allocate the session, then either let the wallet drive the flow orPOST /oid4vci/sessions/{correlationId}/attributesif you want to push attributes that the IDK protocol handlers will pick up. - An upstream system is slow and is configured as
ASYNC_CALLBACK: it calls back toPOST /oid4vci/sessions/{correlationId}/callbacks/{callbackToken}with the answer when ready. - An admin UI needs to show the current state of an in-flight issuance:
GET /oid4vci/sessions/{correlationId}/attributesfor the bag andGET /oid4vci/sessions/{correlationId}/completenessfor the per-credential readiness. - A human review step is needed for a credential whose binding has
approvalRequired = true:POST /oid4vci/sessions/{correlationId}/approvefrom the reviewer's UI. - A source is stuck and you want to abandon the issuance:
POST /oid4vci/sessions/{correlationId}/failwith the source id.
The wallet-facing OID4VCI protocol endpoints continue to work without any of these calls; the pipeline runs on its own as the protocol moves. You only call the pipeline endpoints when you need to inject input from outside the protocol path or to observe and intervene in a session.
Initialise a Session
POST /oid4vci/sessions
Authorization: Bearer <token>
Content-Type: application/json
{
"pipelineConfiguration": {
"pipelineId": "employee-credential-v1",
"sourceBindings": [ ... ],
"claimsBindings": [ ... ],
"expectedInitialLookupKeys": ["invitation_token"]
},
"correlationId": "ord-9af3e2",
"initialAttributes": [],
"initialLookupKeys": [
{
"name": "invitation_token",
"value": "inv-7c1a...",
"type": "BUSINESS_KEY",
"producedBy": { "value": "hr-backend" },
"phase": { "value": "session_init" },
"timestamp": "2026-05-18T01:30:00Z"
}
],
"encryptionMode": { "type": "platform-encrypted" },
"ttlSeconds": 3600
}
Returns 200 OK with the session and correlation ids:
{
"sessionId": "sess_01J...",
"correlationId": "ord-9af3e2"
}
correlationId is the join key with the IDK issuer-core session. When you omit it, the command generates one. initialAttributes and initialLookupKeys are the SESSION_INIT seed; the engine does not run any phase here, the call only allocates the session.
encryptionMode is one of { "type": "plaintext" }, { "type": "platform-encrypted" }, or { "type": "client-bound", "fallbackToPlatform": false }. See Persistence for the trade-offs.
Errors: 400 for malformed input, 409 if a session already exists for the correlation id.
Push Attributes
POST /api/v1/oid4vci/sessions/ord-9af3e2/attributes
Authorization: Bearer <token>
Content-Type: application/json
The compact body carries a list of groups. Every group factors out the shared sourceId, phase, timestamp, sourceDetail, and retention once, then contributes attributes through any of three notations and any lookup keys produced by the same source in the same phase. The HTTP adapter decodes groups into the existing List<AttributeRecord> / List<LookupKey> before delegating to the IDK service command, so the pipeline semantics (Attribute Pipeline) are unchanged.
Notations inside a group
Per-set values map — the recommended notation. Pass an OCA bundle id and a term -> value map; the server resolves the SemanticAttributeDefinition for each term and derives path, valueKind, dataClassification, legalBasis, retentionPolicy, sensitive, and sdPolicy from the bundle. The integrator never spells out a JSON Pointer path or repeats classification metadata.
{
"groups": [
{
"sourceId": "hr-backend",
"phase": "oid4vci_credential_request",
"semanticAttributeSets": [
{
"bundleId": "eu.europa.ec.eudi.pid.1",
"values": {
"given_name": "Alice",
"family_name": "Smith",
"birth_date": "1990-04-01"
}
}
]
}
]
}
Per-set attributes[] with overrides — same semantic-set container, but every entry can override retention, priority, assurance, verified, sourceDetail, or sdPolicy at the per-attribute level. term is required and path is rejected (the surrounding set supplies the schema):
{
"groups": [
{
"sourceId": "hr-backend",
"phase": "oid4vci_credential_request",
"semanticAttributeSets": [
{
"bundleId": "eu.europa.ec.eudi.pid.1",
"attributes": [
{ "term": "given_name", "value": "Alice" },
{ "term": "family_name", "value": "Smith" },
{ "term": "nationality", "value": "NL",
"retention": { "kind": "ephemeral" } }
]
}
]
}
]
}
Group-level attributes[] escape hatch — for ad-hoc attributes that are NOT scoped to any semantic set. Every entry is keyed by a raw JSON Pointer path; the decoder skips semantic resolution and uses group-level metadata to fill the envelope. path is required and term is rejected here:
{
"groups": [
{
"sourceId": "hr-backend",
"phase": "oid4vci_credential_request",
"attributes": [
{ "path": "custom.shoe_size", "value": 42 }
]
}
]
}
semanticAttributeSets, group-level attributes, and lookupKeys are independent; any combination (including only lookupKeys) is a valid group.
Realistic multi-set example
A group can carry multiple semantic sets, a group-level escape-hatch attribute, and lookup keys side-by-side. This matches the OpenAPI multiSetWithEscapeHatch example:
{
"groups": [
{
"sourceId": "hr-backend",
"phase": "oid4vci_credential_request",
"retention": { "kind": "session" },
"semanticAttributeSets": [
{
"bundleId": "eu.europa.ec.eudi.pid.1",
"values": {
"given_name": "Alice",
"family_name": "Smith"
}
},
{
"bundleId": "org.acme.hr.1",
"values": {
"department_code": "ENG-42",
"employee_status": "ACTIVE"
}
}
],
"attributes": [
{ "path": "custom.shoe_size", "value": 42 }
],
"lookupKeys": [
{ "name": "employee_id", "value": "E1042" },
{ "name": "email", "value": "alice@acme.io" }
]
}
]
}
Group-level fields
| Field | Required | Notes |
|---|---|---|
sourceId | yes | Same string applied to every attribute and lookup key in the group (becomes AttributeRecord.sourceId / LookupKey.producedBy). |
phase | yes | Single PipelinePhase per group. A request with attributes for two phases needs two groups. |
timestamp | no | ISO-8601 instant; defaults to server clock when omitted. |
sourceDetail | no | Source-specific reference (IDV node id, endpoint URL). |
retention | no | Polymorphic by kind: ephemeral, session, or retained. Per-item override > set-level retention > group-level retention > semantic-derived > default. |
semanticAttributeSets | no | Array of per-set containers (bundleId, optional version, optional set-level retention, values, attributes). |
attributes | no | Group-level escape hatch (path-keyed items). |
lookupKeys | no | { name, value, type?, metadata?, promotedToAttributePath? }. Provenance fields are filled from the group. |
retention shape
{ "kind": "ephemeral" }
{ "kind": "session", "encryptionRequired": true }
{ "kind": "retained", "retentionDays": 30,
"legalBasis": "gdpr_art6_1a_consent",
"regulatoryFramework": "eidas2",
"territoryId": "EU",
"encryptionRequired": true,
"consentObtained": true }
legalBasis, regulatoryFramework, and territoryRestriction accept any string; the IDK value classes carry the well-known constants but jurisdiction-specific values round-trip cleanly.
Response
Returns 200 OK:
{
"sessionId": "sess_01J...",
"status": "PHASE_COMPLETED",
"completedPhases": [
{ "value": "session_init" },
{ "value": "oid4vci_authorization" },
{ "value": "oid4vci_token" },
{ "value": "oid4vci_credential_request" }
]
}
The endpoint runs the named phase end-to-end: decodes the groups into per-attribute records, feeds them in as phase input, runs every source bound to that phase in dependency order, folds the result back into the session bag, and persists. Calling this for the same phase more than once is supported; the engine re-runs the phase with the new input.
If a required source for the phase is missing a lookup key, or if a bundleId cannot be resolved or a term is unknown in the resolved bundle, the call fails with a 400 listing every unresolved item across all sets and the session is unchanged.
Migrating from the legacy shape
The compact body decodes to exactly the same AttributeRecord / LookupKey lists the legacy endpoint produces, so the migration is a wire-format swap. The most common case (one source, one phase, several attributes) collapses into a single group:
- Legacy unversioned body
- Compact V1 body
{
"phase": { "value": "oid4vci_credential_request" },
"attributes": [
{
"path": "given_name",
"value": { "data": "Alice" },
"sourceId": { "value": "hr-backend" },
"phase": { "value": "oid4vci_credential_request" },
"timestamp": "2026-05-18T01:31:00Z"
},
{
"path": "family_name",
"value": { "data": "Smith" },
"sourceId": { "value": "hr-backend" },
"phase": { "value": "oid4vci_credential_request" },
"timestamp": "2026-05-18T01:31:00Z"
}
],
"lookupKeys": [
{
"name": "employee_id",
"value": "E1042",
"producedBy": { "value": "hr-backend" },
"phase": { "value": "oid4vci_credential_request" },
"timestamp": "2026-05-18T01:31:00Z"
}
]
}
{
"groups": [
{
"sourceId": "hr-backend",
"phase": "oid4vci_credential_request",
"timestamp": "2026-05-18T01:31:00Z",
"semanticAttributeSets": [
{
"bundleId": "eu.europa.ec.eudi.pid.1",
"values": {
"given_name": "Alice",
"family_name": "Smith"
}
}
],
"lookupKeys": [
{ "name": "employee_id", "value": "E1042" }
]
}
]
}
Two integrations worth noting:
- A request that pushes attributes from two different sources (HR plus a compliance system) needs two groups: one per
sourceId. The decoder fans out automatically. - An integration that does not yet have a semantic-set bundle id can keep using raw paths under group-level
attributes[](the escape hatch); the only difference from the legacy body is that thesourceId,phase, andtimestampare factored up into the group envelope.
Legacy unversioned endpoint
Legacy unversioned endpoint, preserved for IDK callers. New integrations should use the
/api/v1/...shape above.
POST /oid4vci/sessions/ord-9af3e2/attributes
Authorization: Bearer <token>
Content-Type: application/json
{
"phase": { "value": "oid4vci_credential_request" },
"attributes": [
{
"path": "department_code",
"value": { "data": "ENG-42" },
"sourceId": { "value": "hr-backend" },
"phase": { "value": "oid4vci_credential_request" },
"timestamp": "2026-05-18T01:31:00Z"
}
],
"lookupKeys": [
{
"name": "employee_id",
"value": "E1042",
"producedBy": { "value": "hr-backend" },
"phase": { "value": "oid4vci_credential_request" },
"timestamp": "2026-05-18T01:31:00Z"
}
]
}
Response shape and engine behaviour are identical to the V1 endpoint above.
Read Session Attributes
GET /oid4vci/sessions/ord-9af3e2/attributes
Authorization: Bearer <token>
Returns 200 OK with the current attribute bag, the lookup keys, the status, the completed phases, and the deferral entries:
{
"sessionId": "sess_01J...",
"status": "AWAITING_DEFERRED",
"completedPhases": [ ... ],
"currentPhase": { "value": "oid4vci_credential_request" },
"attributes": [
{
"path": "given_name",
"value": { "data": "Ada" },
"sourceId": { "value": "database" },
"phase": { "value": "oid4vci_credential_request" },
"timestamp": "2026-05-18T01:31:02Z"
}
],
"lookupKeys": [ ... ],
"deferralEntries": {
"credential-EmployeeCredential": {
"sourceId": { "value": "http" },
"reason": "Upstream HR API timed out",
"createdAt": "2026-05-18T01:31:10Z"
}
}
}
The response decrypts the sensitive payload before returning, so any caller with bearer-token access to this endpoint sees the cleartext attributes. Restrict access through the EDK authorization policy.
Evaluate Completeness
GET /oid4vci/sessions/ord-9af3e2/completeness
Authorization: Bearer <token>
Returns per-credential readiness, listing which required attributes are missing for each bound credential:
{
"sessionId": "sess_01J...",
"perCredential": [
{
"credentialBindingId": "EmployeeCredential",
"ready": false,
"missingAttributes": ["start_date"],
"deferralEligible": true
}
]
}
The POST variant accepts a body that names a phase to re-run before evaluating; useful when external input has arrived and you want to force re-evaluation rather than waiting for the next wallet poll.
Approve
POST /oid4vci/sessions/ord-9af3e2/approve
Authorization: Bearer <token>
Content-Type: application/json
{
"approverPrincipalId": "manager@example.com",
"credentialBindingId": "EmployeeCredential",
"decision": "APPROVED",
"reason": "Reviewed and confirmed against case ABC-123"
}
Returns 200 OK with the resulting session status. For a credential binding with approvalRequired = true, the session does not progress to READY until this is called with decision = APPROVED. A decision = REJECTED moves the session to FAILED. The approver identity and reason are recorded in the session's ApprovalState and persisted with the sensitive payload (encrypted at rest).
Fail a Source
POST /oid4vci/sessions/ord-9af3e2/fail
Authorization: Bearer <token>
Content-Type: application/json
{
"sourceId": { "value": "http" },
"reason": "Upstream HR API has been down for 30 minutes"
}
Records that the named source has failed for the session and stops the engine from waiting on it. If the source was required, the session moves to FAILED. Use this from operator tooling when a stuck flow needs to be abandoned.
Create Credential Offer (Compact)
POST /api/v1/oid4vci/offers
Authorization: Bearer <token>
Content-Type: application/json
The EDK enterprise offer endpoint accepts the IDK CreateCredentialOfferArgs fields (issuer id, credential configuration ids, grant flags, offer TTL, scheme, URI lifecycle) plus a preSeededGroups field that takes the same group shape as POST /api/v1/oid4vci/sessions/{correlationId}/attributes. Use it to create an offer and seed attributes in one round-trip:
{
"issuerId": "pid-issuer",
"credentialConfigurationIds": ["PidCredential"],
"preAuthorizedCodeGrant": true,
"authorizationCodeGrant": false,
"txCodeRequired": false,
"offerTtlSeconds": 600,
"preSeededGroups": [
{
"sourceId": "hr-backend",
"phase": "oid4vci_credential_request",
"semanticAttributeSets": [
{
"bundleId": "eu.europa.ec.eudi.pid.1",
"values": {
"given_name": "Alice",
"family_name": "Smith",
"birth_date": "1990-04-01"
}
}
]
}
]
}
Returns 201 Created with the offer URL, correlation id, and any minted transaction code.
Behind the scenes the decoder runs the same expansion as ContributeAttributes, then flattens the result into the IDK Map<String, JsonElement> preSeededAttributes shape and hands it to the existing IDK CreateCredentialOfferCommand. The original preSeededGroups JSON is retained on the EDK session record as read-only audit material; the IDK boundary itself sees only the flattened map.
preAuthorizedCodeGrant, authorizationCodeGrant, and txCodeRequired are flat booleans on the compact body. uriLifecycle accepts the strings "SINGLE_USE" (default) or "REUSABLE_FRESH_PER_FETCH" for static-offer scenarios. initialLookupKeys and rateLimit are not on this endpoint: initial lookup keys flow through preSeededGroups[i].lookupKeys; rate-limit configuration sits on the underlying IDK shape only.
Errors: 400 for validation problems including unresolved semantic terms (one envelope lists every miss across all sets).
Async-Source Callback
POST /api/v1/oid4vci/sessions/ord-9af3e2/callbacks/eyJhbGciOiJFUzI1NiIsImtpZCI6Im...
Content-Type: application/json
The callback endpoint accepts the same compact groups body as POST /api/v1/oid4vci/sessions/{correlationId}/attributes. The endpoint pins the phase to DEFERRED server-side; any per-group phase value is ignored, matching the legacy callback behaviour.
{
"groups": [
{
"sourceId": "http",
"phase": "oid4vci_deferred",
"semanticAttributeSets": [
{
"bundleId": "org.acme.hr.1",
"values": {
"start_date": "2026-01-15"
}
}
]
}
]
}
The legacy unversioned callback at POST /oid4vci/sessions/{correlationId}/callbacks/{callbackToken} is preserved for IDK callers and accepts the legacy per-attribute body:
{
"attributes": [
{
"path": "start_date",
"value": { "data": "2026-01-15" },
"sourceId": { "value": "http" },
"phase": { "value": "oid4vci_deferred" },
"timestamp": "2026-05-18T01:32:45Z"
}
],
"lookup_keys": []
}
Returns 200 OK with the updated session status.
This endpoint does not use a bearer token. Instead, the path's callbackToken is an opaque capability artefact the EDK minted when the async source was dispatched. The token-binding cross-check requires the token's embedded correlationId claim to match the path's correlation id. Token validation is handled by the EDK CallbackTokenService (a JWS-token-based implementation ships out of the box; deployments can swap it).
When a CallbackCoordinator is on the classpath, a successful callback also notifies any /credential request that is currently holding open within the source's syncWaitWindow. If the wallet's poll has not yet timed out, the wallet gets the credential synchronously instead of falling through to a deferred response. This is the path that turns slow upstream systems into responsive issuance: the wallet sees a synchronous credential rather than a deferral whenever the callback arrives within a few seconds.
Errors:
401if the callback token is invalid, expired, or itscorrelationIddoes not match the path.400for malformed JSON.
Tenant-Aware Paths
The IDK protocol endpoints assume tenant resolution from the host header. The EDK lib-openid-oid4vci-issuer-rest-tenant module also accepts a leading tenant slug:
/{tenantSlug}/oid4vci/credential
/{tenantSlug}/.well-known/openid-credential-issuer
The slug lookup is additive: an as-is host match still wins. Host-only deployments are unaffected by including this module. The tenant slug is resolved by the EDK tenant-resolution layer through RoutableSlugLookup, so the same slug works across all the issuer's tenant-aware adapters.
The tenant_public_endpoint registration (PUT /api/v1/tenants/{tenantId}/public-endpoints/OID4VCI_ISSUER, the tenant admin API documented under tenant administration) is what tells the metadata endpoint which host and path each tenant publishes its issuer on. When a binding exists, the /.well-known/openid-credential-issuer response and the URLs in created offers use the registered host instead of the inbound Host header. This is what lets a single EDK issuer image serve multiple tenants under distinct hostnames.
Error Envelope
Failures use the standard EDK IdkError envelope:
{
"code": "NOT_FOUND_ERROR",
"message": "Pipeline session not found: ord-9af3e2"
}
Mapping:
| Error code | HTTP status |
|---|---|
ILLEGAL_ARGUMENT_ERROR | 400 |
UNAUTHORIZED_ERROR | 401 |
POLICY_DENIED | 403 |
NOT_FOUND_ERROR | 404 |
ALREADY_EXISTS_ERROR | 409 |
COMMAND_DISABLED_ERROR | 503 (a required pipeline command is not wired in this deployment) |
UNKNOWN_ERROR or other | 500 |
The OID4VCI protocol endpoints continue to use the spec-defined error responses (invalid_credential_request, invalid_proof, and so on) from the IDK; that surface is unchanged.
Authorization
Every pipeline endpoint passes through the EDK PolicyCommandExtension, so authorization policies are evaluated per command. Typical policies:
issuance.pipeline.init-sessionand the create-offer commands: tenant administrators or backend service principals only.issuance.pipeline.contribute-attributes,issuance.pipeline.approve-session,issuance.pipeline.fail-source: roles that match your business model (HR back-office, compliance reviewer, operator).issuance.pipeline.get-session-attributesand the completeness command: a wider audit/support role, since the response contains decrypted attribute values.- The callback endpoint command
oid4vci.protocol.contribute-via-callbackdoes not go through policy; it is authenticated by the capability token alone, which encodes the scope (this specific session, this specific source).
All command IDs follow the issuance.pipeline.* or oid4vci.protocol.* namespace and are stable contracts for policy.