Digital Attestations — Application API
API document version: 1.3.1
Hub URL: https://openid4vc-hub.com
Default prefix: /api/v1
Contact: contact@talao.io
1. Introduction
This guide has four reading levels: orientation, quick start, API
reference, complete examples and protocol/interoperability behavior. Start with Quick Start to
integrate one issuance or presentation without needing protocol expertise.
The Application calls /api/v1 from its server. The Wallet Hub handles the
Wallet-facing interaction.
Your Application
|
| REST API
v
Wallet Hub
|
| Secure Wallet interaction
v
User's Wallet
Keep the API Key on the Application backend. Never send it in a QR code, Wallet invocation URI, browser JavaScript, query parameter or Wallet request.
Digital identity in brief
| Term | Meaning |
|---|---|
| Wallet | An application chosen by the End-User to store digital attestations and approve sharing the requested information. |
| EUDI Wallet | A European Digital Identity Wallet. It lets a person keep control of identity data and other digital attestations they share with a service. |
| PID | Person Identification Data: a digital attestation used to prove a person's identity. |
| Attestation | Digitally issued information that can be checked by a service, such as identity data, proof of residence, or a professional qualification. |
| Issuer | The organization that issues an attestation to a Wallet. |
| Verifier | The organization or service that requests and checks an attestation presented by a Wallet. |
In this API, the Application selects an Issuer to issue an attestation or a
Verifier to check one. The End-User always approves the Wallet interaction.
The API contract retains credential in field names and resource identifiers;
this guide uses attestation when referring to information held by a user.
Examples of workflow
| Goal | Start with | Main operation |
|---|---|---|
| Verify a person's identity (PID) | Verify a PID with an EUDI Wallet | POST /api/v1/presentations |
| Issue an attestation to a known user | Issue an attestation to a known user | POST /api/v1/issuances |
| Verify a PID before issuing an attestation | Verify a PID before issuing an attestation | POST /api/v1/issuances, then finalization |
| Revoke, suspend or restore an attestation | Suspend, restore or revoke an attestation | POST /api/v1/credentials/{credential_id}/status |
For executable client examples, see Complete examples at the end of this guide.
Wallets
The End-User selects their Wallet. The application does not receive, choose,
or configure it through this API. It displays the qr_code_content returned
by the Wallet Hub; the Wallet then asks the End-User to approve the requested data.
Member State EUDI Wallets
Member States are expected to make an EUDI Wallet available from 1 January 2027, subject to their national roll-out schedule.
- 🇫🇷 France Identité
- 🇩🇪 Germany EUDI Wallet
- 🇮🇹 IT-Wallet
- 🇪🇺 European Commission — EU Digital Identity Wallet
EUDI-compliant private Wallets
- 🇫🇷 Talao Wallet
- 🇸🇪 iGrant.io
- 🇳🇱 Paradym EUDI Wallet
- 🇮🇹 Namirial Wallet
- 🇩🇪 Lissi ID-Wallet
- 🇨🇠Procivis One Wallet
This list is informative only. It is neither a certification nor a guarantee that every Wallet supports every configured Wallet Hub use case. The Application API Key always stays on the application server and is never entered in a Wallet.
Purpose and scope
The Application API is the server-to-server interface between a client Application and the Wallet Hub. It lets an Application:
- authenticate with a managed API Key;
- discover the published Rulebook catalogue;
- start one issuance containing one or more attestations;
- receive QR-code content to display;
- poll the issuance and per-attestation statuses;
- verify information before an issuance, inspect the normalized result, then provide the issuance datasets;
- start one presentation from a configured Verifier use case;
- receive a Wallet invocation URI and QR-code content to display;
- poll the presentation status and retrieve normalized verified claims.
Your Application communicates only with /api/v1. The Wallet Hub handles the
Wallet-facing protocols and returns only the information required by the
application. Issuance polling never returns datasets, issued attestations,
private signing material or Wallet protocol tokens. Presentation polling
returns normalized verified claims, never the raw Wallet presentation.
This document is the implemented /api/v1 client contract. It is not an
administration UI manual. The protocol material retained for interoperability
work is collected in Protocol and interoperability reference.
Routes and documentation
GET /api/v1/health
GET /api/v1/rulebooks
GET /api/v1/rulebooks/{rulebook_id}
POST /api/v1/issuances
GET /api/v1/issuances/{issuance_id}
POST /api/v1/issuances/{issuance_id}/authorization/finalize
POST /api/v1/issuances/{issuance_id}/credentials/{item_id}/decision
POST /api/v1/credentials/{credential_id}/status
POST /api/v1/presentations
GET /api/v1/presentations/{presentation_id}
These are the Application API routes currently supported.
An interactive Swagger UI is available here. Its downloadable
OpenAPI 3.1 contract is served here. These two
documentation resources are public and contain no API Key or deployment
secret. Actual /api/v1 operations remain protected by X-API-Key.
For manual testing, select Authorize in Swagger UI and enter the complete
managed hub_live_... key supplied by the Wallet Hub operator. The UI keeps the key
only in browser memory, does not persist it across a reload, and does not send
the OpenAPI document to Swagger's public validator. The examples are editable
test requests; executing a creation or decision operation creates or changes
real API state under the permissions of that API Client.
The OpenAPI document is the machine-readable structural contract. This file remains the detailed behavioral contract and records flow semantics, operational limits and unsupported features.
Authentication and ownership
Every Application API request uses:
X-API-Key: hub_live_<secret>
An API Key belongs to an API Client. Permissions are attached to the client:
- creation is allowed only for an Issuer associated with that API Client;
- a verify-before-issue issuance additionally requires the selected Verifier to be associated with the same API Client;
- polling is allowed to any active key of the API Client that created the issuance;
- another API Client receives
403 forbidden, even if it is authorized to use the same Issuer; - presentation creation is allowed only for a Verifier associated with that API Client;
- presentation polling is allowed to any active key of the API Client that created the presentation;
- another API Client receives
403 forbidden, even if it is authorized to use the same Verifier.
Every authenticated API Client can read the common published Rulebook catalogue. Rulebook discovery does not depend on the client's Issuer or Verifier associations. Draft and retired Rulebooks are not exposed.
When API_KEY_REQUIRED is enabled, authentication failures are:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"error": "missing_api_key"}
or:
{"error": "invalid_api_key"}
2. Quick Start
The following calls illustrate the API contract of 2 of the most used workflows. Replace every Issuer, Verifier and configuration identifier with an enabled identifier associated with your API Client. Obtain these identifiers and the API base URL from the Wallet Hub operator.
If needed request an X-API-Key to contact@talao.io
Verify a PID with an EUDI Wallet
A PID (Person Identification Data) is a digital attestation of a person's identity. An EUDI Wallet is a European Digital Identity Wallet that lets the End-User keep control over the identity data they share. The application asks the Wallet Hub to verify a PID; it does not ask the Wallet for a particular technical credential format.
Application -> Wallet Hub: create presentation
|
| PID verification request
v
Wallet
|
| User-approved PID presentation
v
Wallet Hub
Application -> Wallet Hub: poll normalized verified claims
curl --silent --show-error --request POST "$HUB_URL/api/v1/presentations" \
--header "X-API-Key: $HUB_API_KEY" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data '{
"verifier": "eudi-verifier",
"presentation_configuration_id": "eudi-pid-mdoc",
"expires_in": 300
}'
Store presentation_id. Display qr_code_content unchanged, or invoke
wallet_invocation_uri for a same-device flow. The Wallet Hub handles the Wallet
interaction. Poll status_uri until completed, expired or failed. On
completion, read the normalized verified information from credentials.
curl --silent --show-error \
--header "X-API-Key: $HUB_API_KEY" \
--header "Accept: application/json" \
"$HUB_URL/api/v1/presentations/<presentation_id>"
The verified PID result is under credentials.pid; do not log or expose it to
browser code.
eudi-verifier and eudi-pid-mdoc are configured Wallet Hub identifiers. Confirm
with the Wallet Hub operator that they are enabled and associated with your API Client
before using them.
See Verify a PID or another attestation and Create a presentation for the workflow and the complete REST contract.
Issue a Proof of Residence to a known user
Before creating the issuance, authenticate the user in your own application and apply the eligibility checks required by your business process.
Application -> Wallet Hub: create issuance
|
| Attestation delivery request
v
Wallet
|
| User accepts the attestation
v
Wallet Hub
Application -> Wallet Hub: poll issuance
Create the issuance from the Application backend:
export HUB_URL="https://<hub-api-origin>"
export HUB_API_KEY="hub_live_<secret>"
curl --silent --show-error --request POST "$HUB_URL/api/v1/issuances" \
--header "X-API-Key: $HUB_API_KEY" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data '{
"issuer": "core-por-issuer",
"credentials": [{
"credential_configuration_id": "proof_of_residence_sd_jwt_v2",
"claims": {
"resident_address": "100 Kifisias Avenue, Marousi 151 24, Athens, Greece",
"street_name": "Kifisias Avenue",
"building_number": "100",
"postal_code": "151 24",
"locality": "Marousi",
"region": "Athens",
"country": "GR"
}
}]
}'
Store issuance_id. Display qr_code_content unchanged. The Wallet Hub handles the
Wallet interaction and delivers the attestation. Poll until completed,
expired or failed:
curl --silent --show-error \
--header "X-API-Key: $HUB_API_KEY" \
"$HUB_URL/api/v1/issuances/<issuance_id>"
See Issue an attestation to a known user and Create an issuance for the workflow and the complete REST contract.
3. Core concepts
| Term | Meaning |
|---|---|
| Application | Your server-side product integrating /api/v1. It owns business decisions and keeps the API Key. |
| API Client / API Key | An administrator-configured client and its managed hub_live_... key. Associations limit which Issuers and Verifiers it can use. |
| Issuer | The Wallet Hub configuration that issues attestations. |
| Credential Configuration | A configured type of attestation that can be issued by an Issuer. It defines the accepted claims and the technical settings used by the Wallet Hub. |
| Credential Dataset | One Application-supplied set of business claims to be issued as an attestation under a Credential Configuration. |
| Verifier | A configured Wallet Hub service used to request and verify information from a Wallet. |
| Presentation Configuration | A predefined verification request that determines what information the user will be asked to share. |
| Rulebook | A published description of an attestation type, including its meaning, attributes, governance and applicable policies. It is read-only through this API. |
Identifier guide
| Identifier | Scope and use |
|---|---|
issuance_id |
Opaque Application API session identifier used to poll an issuance. |
presentation_id |
Opaque Application API session identifier used to poll a presentation. |
item_id |
Numeric identifier of one credential item inside an issuance; used for finalization and deferred decisions. |
credential_configuration_id |
Identifier of the configured attestation type to issue. The Wallet Hub operator provides the available identifiers. |
credential_id |
Opaque identifier of an issued attestation used for lifecycle operations such as suspension or revocation. |
4. Workflows
Issue an attestation to a known user
Use this workflow when the Application has already authenticated the user, applied its eligibility checks, and holds the data to issue. The Wallet Hub does not authenticate the user for this workflow.
Application -> Wallet Hub: create issuance
Wallet Hub -> Application: QR content
Application -> User: display QR
Wallet Hub <-> Wallet: deliver attestation
Application -> Wallet Hub: poll status
- The Application calls
POST /api/v1/issuanceswith an Issuer and one or more datasets to issue to that user. - It stores
issuance_idand gives the exactqr_code_contentfrom the creation response to its QR renderer. If it requested a Transaction Code, it communicates that code separately from the QR code. - The Wallet obtains the attestation from the Wallet Hub. The Application polls
GET /api/v1/issuances/{issuance_id}untilcompleted,expiredorfailed, bounded byexpires_at.
API operations:
POST /api/v1/issuancesGET /api/v1/issuances/{issuance_id}
The Application never sends its API Key to the Wallet. See the Python and JavaScript examples and the Create an issuance and Get an issuance contracts.
Issue an attestation with a transaction code
Use this workflow when a code must be communicated to the user separately from
the QR code, for example through an authenticated channel. The Application
supplies tx_code while creating the issuance, displays the QR content, and
communicates the code separately; the Wallet Hub handles the rest.
API operations:
POST /api/v1/issuancesGET /api/v1/issuances/{issuance_id}
See With Transaction Code for the exact request and constraints.
Issue different attestations in the same session
Use this workflow when one user should receive several different attestation
types in one issuance. Each entry uses its own credential_configuration_id
and dataset.
One issuance
|
+-- Proof of Residence
+-- Employee Attestation
+-- Email Attestation
API operations:
POST /api/v1/issuancesGET /api/v1/issuances/{issuance_id}
See Create an issuance for the complete contract.
Issue several attestations of the same type
A single issuance can contain several datasets for the same Credential Configuration when the issuance mode permits it.
One issuance
|
+-- Proof of Residence #1
+-- Proof of Residence #2
Each entry is a separate dataset. For application_decides, the Application
chooses the effective mode at creation; a deferred item is later marked ready
or failed through the decision operation. See Several datasets of the same
type and Deferred decision
for the exact contracts.
Verify a PID or another attestation
Use POST /api/v1/presentations when the Application needs verified claims
without issuing an attestation. The primary documented journey is PID
verification with eudi-verifier and eudi-pid-mdoc. The same endpoint also
verifies other configured attestations, such as a proof of residence or a
professional attestation.
The selected Presentation Configuration defines what the Wallet is asked to present. The Application supplies only the Verifier, the Presentation Configuration identifier, and optionally the session lifetime and application return URL. It does not send or alter the Wallet request rules.
- Create the presentation with
POST /api/v1/presentations. - Display the returned
qr_code_contentunchanged. - Poll the returned
status_uri. - When the status is
completed, read the normalized verified information.
The Wallet Hub returns only normalized verified information, never the raw Wallet presentation. See the Python and JavaScript examples, or the complete Create a presentation and Get a presentation contracts for status and error behavior.
For same-device EUDI use, a successful Wallet response may include a
single-use, session-bound redirect_uri to the Wallet Hub terminal result page.
Cross-device applications continue polling normally.
Verify a PID before issuing an attestation
Use this workflow when verified PID information must inform the application's decision to issue an attestation.
Application -> Wallet Hub -> Wallet
<- verified presentation
Application <- normalized verified claims
Application -> Wallet Hub: final attestation datasets or rejection
Wallet Hub -> Wallet: issuance continues
The Application waits for authorization.status=awaiting_application, then
finalizes every item with outcome=ready or rejects the issuance. Final
datasets are frozen, identical finalization is idempotent, and a rejection
becomes an access-denied result when the Wallet resumes. The Application
decides which attestation data to issue; the Wallet Hub never derives it from the PID.
See the Python and
JavaScript
examples. The finalization operation
defines the exact request and state rules.
API operations:
POST /api/v1/issuancesGET /api/v1/issuances/{issuance_id}POST /api/v1/issuances/{issuance_id}/authorization/finalize
Deferred business decision
Use this workflow when the Application cannot decide immediately whether an attestation should be delivered.
Attestation requested
|
v
Business decision pending
|
+--> ready
|
+--> failed
After the Wallet has made its initial Credential Request, the Application
marks the deferred item ready or failed. See Deferred decision
for the exact contract.
API operation: POST /api/v1/issuances/{issuance_id}/credentials/{item_id}/decision.
Suspend, restore or revoke an attestation
When lifecycle management is enabled for an attestation, issuance polling can
return an opaque credential_id in credentials[].status_credentials[].
Store this identifier if the Application needs to suspend, restore or revoke
the attestation later. The issuance request does not change.
issue
|
v
credential_id
|
+--> valid
|
+--> suspended (two-bit lists only) --> valid
|
+--> invalid
{
"issuance_id": "b55df066-c201-4f32-a7e8-fd8cf29db614",
"status": "completed",
"credentials": [
{
"item_id": 42,
"credential_configuration_id": "eu.europa.ec.eudi.pid_jwt_vc",
"status": "issued",
"status_credentials": [
{
"credential_id": "cs_R4nd0mUnpr3d1ct4bl3Ident1fier",
"status": "valid"
}
]
}
]
}
Store credential_id with your own business record, such as an employee,
training record or issuance request, only when you need later lifecycle
control. It is generated by the Wallet Hub. Its technical distinction from Wallet and
status-list identifiers is documented in the protocol reference.
status_credentials is absent when Status Lists are disabled for the
Credential Configuration. An issuance with several items or holder bindings
can return several status references.
When an administrator enables batch credential issuance for the Issuer, the
Wallet can receive several instances of the same configuration and dataset
from one Credential Endpoint request. The Application API creation payload
and grant selection remain unchanged: client applications do not select the
batch size or provide holder keys, proofs, Status List coordinates, or batch
metadata. Polling still reports one item per requested dataset, and a
status-enabled batch returns one status_credentials entry per issued copy.
Each opaque credential_id can be updated independently using the existing
status endpoint. Raw credentials and proofs remain absent from polling.
Immediate and deferred delivery share these rules; one notification may cover
the entire batch. Issuer batch support does not enable EUDI reuse policies or
refresh-token re-issuance.
Change attestation status
POST /api/v1/credentials/{credential_id}/status
X-API-Key: hub_live_<secret>
Content-Type: application/json
Revoke an attestation permanently:
curl --silent --show-error --request POST \
"$HUB_URL/api/v1/credentials/cs_opaque_credential_id/status" \
--header "X-API-Key: $HUB_API_KEY" \
--header "Content-Type: application/json" \
--data '{"status":"invalid"}'
Suspend an attestation temporarily, only when its Credential Configuration uses a two-bit Status List:
curl --silent --show-error --request POST \
"$HUB_URL/api/v1/credentials/cs_opaque_credential_id/status" \
--header "X-API-Key: $HUB_API_KEY" \
--header "Content-Type: application/json" \
--data '{"status":"suspended"}'
Restore an attestation to valid status when business policy permits it:
curl --silent --show-error --request POST \
"$HUB_URL/api/v1/credentials/cs_opaque_credential_id/status" \
--header "X-API-Key: $HUB_API_KEY" \
--header "Content-Type: application/json" \
--data '{"status":"valid"}'
Each successful request returns the identifier, requested status and
updated_at, for example:
{
"credential_id": "cs_opaque_credential_id",
"status": "invalid",
"updated_at": "2026-09-24T15:30:00Z"
}
Allowed values are valid, invalid, and, for a two-bit Status List,
suspended. The Wallet Hub resolves the opaque identifier privately; Applications
never send a Status List URI or index. A managed API Key must belong to the
API Client that owns the original issuance. The transitional master-key bypass
keeps the compatibility semantics described in Authentication and
ownership.
During a successful presentation, an attestation with a Status List reference
contains nested verification.credential_status, for example
{"checked":true,"value":"valid"}. The Wallet Hub automatically checks the
attestation status during verification. An attestation whose value is
invalid or suspended does not produce a successful verification result.
Status-list validation details are in the protocol reference.
Status endpoint errors use the current Application API error shape: 400 for
an absent or unsupported status, 403 for a managed API Client that does not
own the attestation, 404 for an unknown credential_id, and 409 when the
list is unavailable or does not permit suspension.
See Change attestation status for the authoritative REST contract.
5. API Reference
The operations below are the implemented server-to-server /api/v1 contract.
If needed request an X-API-Key to contact@talao.io
Authentication
Every operation in this section requires X-API-Key: hub_live_<secret> as
defined in Authentication and ownership.
That section also defines API Client ownership and authentication errors.
Health
GET /api/v1/health
X-API-Key: hub_live_<secret>
Accept: application/json
Success:
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "ok"
}
This verifies the Flask Application API and its authentication path. It is not a detailed PostgreSQL, Redis or external dependency diagnostic.
Rulebooks
A Rulebook defines the semantic, governance and policy framework of an EAA Credential Type available on the Wallet Hub. Rulebooks are managed by the Wallet Hub administrator and are read-only resources for API clients.
Rulebooks and their associated Credential Types are managed by the Wallet Hub administrator. Clients select an existing Rulebook to use as the basis for services such as an Issuer or Verifier. This version only exposes catalogue discovery: it does not create or configure those services.
Only Rulebooks with status=published are returned. Draft and retired
Rulebooks are not discoverable through this API. The endpoints do not currently
provide filters or pagination.
List published Rulebooks
GET /api/v1/rulebooks
X-API-Key: hub_live_<secret>
Accept: application/json
There are no path or query parameters.
Success:
HTTP/1.1 200 OK
Content-Type: application/json
{
"rulebooks": [
{
"id": 12,
"identifier": "employee-rulebook",
"name": "Employee Credential Rulebook",
"version": "1.0",
"status": "published",
"description": "Rules for the Example Corp employee EAA.",
"organization": "Example Corp",
"author": "Identity Governance Team",
"credential_type": {
"id": 7,
"identifier": "employee-credential",
"name": "Employee Credential"
}
}
]
}
An empty catalogue returns 200 with {"rulebooks": []}.
Get one published Rulebook
GET /api/v1/rulebooks/12
X-API-Key: hub_live_<secret>
Accept: application/json
rulebook_id is the stable positive integer returned as id by the list
endpoint. It is the reference reserved for future Issuer and Verifier
configuration workflows. The textual identifier and version describe the
versioned Rulebook but do not replace rulebook_id in future API references.
Success:
{
"id": 12,
"identifier": "employee-rulebook",
"name": "Employee Credential Rulebook",
"version": "1.0",
"status": "published",
"description": "Rules for the Example Corp employee EAA.",
"organization": "Example Corp",
"author": "Identity Governance Team",
"profile": "private_eaa_v1",
"governance": {
"organization": "Example Corp",
"author": "Identity Governance Team",
"contact": "identity@example.com"
},
"references": {
"source_url": "https://example.com/rulebooks/employee",
"legal": "Example Corp employment policy.",
"standards": "OpenID4VCI 1.0."
},
"policies": {
"issuance": "Only HR may approve issuance.",
"binding": "The credential is device bound.",
"status_or_revocation": "Revoke when employment ends.",
"validation": "Validate signature and validity.",
"usage": "Use only for employment verification.",
"presentation": "Request only necessary attributes."
},
"trust": {
"policy": "Trust Example Corp issuers."
},
"additional_information": "Policy owner HR.",
"credential_type": {
"id": 7,
"identifier": "employee-credential",
"name": "Employee Credential",
"description": "Employment information issued by Example Corp.",
"attributes": [
{
"name": "employee_id",
"display_name": "Employee ID",
"description": "Stable employee identifier.",
"data_type": "string",
"mandatory": true
}
],
"technical_representations": [
{
"id": 19,
"name": "Employee SD-JWT",
"credential_configuration_id": "employee_sd_jwt",
"format": "dc+sd-jwt",
"vct": "https://example.com/vct/employee",
"doctype": null
}
]
}
}
The response is assembled from the existing Rulebook, Credential Type, claims and Credential Configuration Templates. It does not create a second claim or technical-representation model. Claim-level semantic references are omitted because the current claim model has no separate semantic-reference field.
Errors:
| HTTP | Response | Cause |
|---|---|---|
| 401 | {"error":"missing_api_key"} |
The requiredX-API-Key is absent. |
| 401 | {"error":"invalid_api_key"} |
The API Key or API Client is invalid, disabled, revoked or expired. |
| 404 | {"error":"Rulebook not found"} |
The ID is unknown or the Rulebook is not published. The response is deliberately identical in both cases. |
There is no Application API POST, PUT, PATCH or DELETE Rulebook route.
API clients also cannot create a reference Credential Type through /api/v1.
Issuances
Create an issuance
POST /api/v1/issuances
X-API-Key: hub_live_<secret>
Content-Type: application/json
Accept: application/json
Creates an operation that delivers one or more attestations to a user's Wallet. Every requested Credential Configuration must be enabled and belong to the selected Issuer.
Basic issuance
Omit grant_type to select the Wallet Hub's standard issuance behavior. The
application supplies the Issuer, configured attestation type and business
claims; the Wallet Hub applies the configured format, signing policy, holder binding
and interoperability profile.
{
"issuer": "core-por-issuer",
"credentials": [
{
"credential_configuration_id": "proof_of_residence_sd_jwt",
"claims": {
"resident_address": "100 Kifisias Avenue, Athens",
"country": "GR"
}
}
]
}
Test-only Wallet Hub consent
{
"issuer": "core-pid",
"grant_type": "authorization_code",
"authorization": {
"mode": "hub_consent"
},
"expires_in": 600,
"credentials": [
{
"credential_configuration_id": "pid_sd_jwt",
"claims": {
"family_name": "DOE",
"given_name": "JOHN",
"birthdate": "1990-01-01"
}
}
]
}
This test-only variant asks the Wallet Hub to show its consent screen. It does not
authenticate the end user and it does not contain tx_code.
PID mdoc dataset with France Identite mappings
Input claims keys are business sources defined by the selected Credential
Configuration; they are not necessarily the final credential's claim names.
For eu.europa.ec.eudi.pid_mdoc copied from the France Identite template,
use a formatted address string and flat address components. For example:
{
"issuer": "core-pid-issuer",
"credentials": [
{
"credential_configuration_id": "eu.europa.ec.eudi.pid_mdoc",
"claims": {
"family_name": "Doe",
"given_name": "Jane",
"birthdate": "1990-01-01",
"place_of_birth": {"country": "FR", "region": "Ile-de-France", "locality": "Paris"},
"nationalities": ["FR"],
"sex": 2,
"email": "jane.doe@example.com",
"phone_number": "+33102030405",
"address": "10 rue de la Paix, 75002 Paris, France",
"address_country": "FR",
"address_region": "Ile-de-France",
"address_locality": "Paris",
"address_postal_code": "75002",
"address_street_address": "rue de la Paix",
"address_house_number": "10",
"issuing_authority": "France Identite Playground",
"issuing_country": "FR",
"date_of_issuance": "2026-08-24",
"date_of_expiry": "2035-01-01",
"document_number": "PID-TEST-0001",
"issuing_jurisdiction": "FR"
}
}
],
"tx_code": {"input_mode": "numeric", "length": 6, "value": "123456"}
}
This example uses the default Pre-Authorized Code grant; the selected Issuer,
configuration, SigningKey and Authorization Server must be enabled and ready
for mdoc issuance. For Authorization Code, select that grant and the explicit
supported authorization mode, and omit tx_code entirely.
The mdoc mappings publish birth_date, nationality, email_address,
mobile_phone_number, resident_address and the corresponding resident_*
components under eu.europa.ec.eudi.pid.1. Do not rename input birthdate or
email: these mappings expect those source names. sex is an integer, not
"2". For the SD-JWT VC configuration, retain the nested address object
(formatted, country, region, locality, postal_code, street_address,
house_number); the credential uses birthdate, nationalities, email and
phone_number directly. Custom configurations may define different business
sources, so inspect their mappings. These examples cover the sandbox subset,
not every mandatory/optional PID attribute.
Verify before issue
This example requests a PID presentation before issuing a Proof of Residence (PoR). The initial request deliberately contains no issuance claims:
{
"issuer": "core-por-issuer",
"grant_type": "authorization_code",
"authorization": {
"mode": "openid4vp",
"verifier": "core-verifier",
"presentation_configuration_id": "pid-sd-jwt"
},
"expires_in": 600,
"credentials": [
{
"credential_configuration_id": "proof_of_residence_sd_jwt"
}
]
}
The API Client must be associated with both core-por-issuer and
core-verifier. The Verifier and Presentation Configuration slugs select the
existing verification request; the application cannot override its configured
request rules. After the PID has been verified, the application reads the
normalized result from issuance polling, applies its own business rules, and
provides the PoR claims through the finalization endpoint described below.
PID-to-PoR is an example, not a hard-coded mapping. The same mode can use any supported Presentation Configuration and any Issuer Credential Configuration. The Wallet Hub verifies the selected attestation but never derives the credential to issue from it: the application remains solely responsible for deciding the outcome and supplying every issuance claim.
With Transaction Code
{
"issuer": "core-pid",
"grant_type": "urn:ietf:params:oauth:grant-type:pre-authorized_code",
"tx_code": {
"value": "123456",
"input_mode": "numeric",
"description": "Enter the code displayed by the issuer application"
},
"expires_in": 600,
"credentials": [
{
"credential_configuration_id": "pid_sd_jwt",
"claims": {
"family_name": "DOE",
"given_name": "JOHN",
"birthdate": "1990-01-01"
}
}
]
}
grant_type may be omitted in this second example because Pre-Authorized Code
is the default. The explicit value is shown to make the selected flow
unambiguous. authorization must be omitted for this grant. tx_code itself
is optional for Pre-Authorized Code; when present, its clear value is never
sent to the Wallet-facing flow.
Several datasets of the same type
The same Credential Configuration can be prepared with several distinct Credential Datasets in a Pre-Authorized Code issuance:
{
"issuer": "core-pid",
"grant_type": "urn:ietf:params:oauth:grant-type:pre-authorized_code",
"credentials": [
{
"credential_configuration_id": "pid_sd_jwt",
"claims": {
"family_name": "DOE",
"given_name": "ALICE"
}
},
{
"credential_configuration_id": "pid_sd_jwt",
"claims": {
"family_name": "MARTIN",
"given_name": "BOB"
}
}
]
}
Each entry represents a distinct attestation dataset. The Wallet Hub handles Wallet-side delivery independently. Dataset values and internal delivery identifiers are never exposed through Application API polling. See the protocol reference for the Wallet-side multiple-dataset exchange.
Top-level fields:
| Field | Type | Required | Description |
|---|---|---|---|
issuer |
string | yes | Enabled Issuer slug configured in the Wallet Hub. |
credentials |
array | yes | Non-empty array of credential requests. |
grant_type |
string | no | authorization_code or urn:ietf:params:oauth:grant-type:pre-authorized_code; defaults to the latter. |
authorization |
object | conditional | Required with grant_type=authorization_code. Use {"mode":"hub_consent"} for the test interaction, or {"mode":"openid4vp","verifier":"...","presentation_configuration_id":"..."} for the verify-before-issue workflow. Omit it for Pre-Authorized Code. |
tx_code |
object | no | Transaction Code configuration described below. |
expires_in |
integer | no | Session lifetime in seconds; defaults to 600. |
Fields in credentials[]:
| Field | Type | Required | Description |
|---|---|---|---|
credential_configuration_id |
string | yes | Enabled configuration ID belonging to the selected Issuer. |
claims |
object | conditional | Dataset stored for this credential item. Required for the standard and hub_consent flows; omit it with authorization.mode=openid4vp, then supply it through the finalization endpoint after verification. |
issuance_mode |
string | conditional | Required only when the selected Credential Configuration uses application_decides; allowed values are immediate and deferred. Rejected for configurations with a fixed mode. |
The same credential_configuration_id can appear more than once in a
Pre-Authorized Code issuance, with each entry representing a distinct
Credential Dataset. Repetition remains rejected for Authorization Code. The
Application selects the Credential Configuration; the Wallet Hub applies the
configured format, signing policy, holder binding and interoperability profile.
Technical format and multi-dataset behavior is retained in the
protocol reference.
Issuance create response
Success:
HTTP/1.1 201 Created
Location: /api/v1/issuances/5fbf6e42-8463-4b4a-a884-70ae94d66577
Content-Type: application/json
{
"id": "5fbf6e42-8463-4b4a-a884-70ae94d66577",
"issuance_id": "5fbf6e42-8463-4b4a-a884-70ae94d66577",
"status": "pending",
"created_at": "2026-08-24T10:00:00+00:00",
"expires_at": "2026-08-24T10:10:00+00:00",
"credentials": [
{
"item_id": 42,
"credential_configuration_id": "pid_sd_jwt",
"issuance_mode": "immediate",
"deferred_status": null,
"status": "pending",
"issued_at": null,
"notification": null
}
],
"credential_offer_uri": "https://hub.example/issuer/core-pid/credential-offer/offer_...",
"qr_code_content": "openid-credential-offer://?credential_offer_uri=https%3A%2F%2F..."
}
id and issuance_id currently contain the same UUID. New integrations should
use issuance_id as the Application API identifier.
qr_code_content is the exact string to encode in the QR code. The client must
not reconstruct or alter it. credential_offer_uri and qr_code_content are
returned only by the creation call. The status URL is provided by the HTTP
Location header rather than a status_url JSON property.
Get an issuance
GET /api/v1/issuances/{issuance_id}
X-API-Key: hub_live_<secret>
Accept: application/json
Success:
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "5fbf6e42-8463-4b4a-a884-70ae94d66577",
"issuance_id": "5fbf6e42-8463-4b4a-a884-70ae94d66577",
"status": "partially_issued",
"created_at": "2026-08-24T10:00:00+00:00",
"expires_at": "2026-08-24T10:10:00+00:00",
"credentials": [
{
"item_id": 42,
"credential_configuration_id": "pid_sd_jwt",
"issuance_mode": "immediate",
"deferred_status": null,
"status": "issued",
"issued_at": "2026-08-24T10:02:45+00:00",
"notification": {
"event": "credential_accepted",
"event_description": null,
"received_at": "2026-08-24T10:03:02+00:00"
}
},
{
"item_id": 43,
"credential_configuration_id": "employee_sd_jwt",
"issuance_mode": "deferred",
"deferred_status": "ready",
"status": "pending",
"issued_at": null,
"notification": null
}
]
}
The response never contains the original datasets, issued attestations,
one-time secrets or Wallet protocol artefacts. deferred_status exposes only
the internal lifecycle (pending, ready, failed, delivered or expired).
notification exposes only the latest Wallet event after it has been received.
The client can poll until it receives a terminal status. The included test application polls every 1.5 seconds through its own backend so the API Key is never exposed to browser JavaScript. A production client should use a bounded interval and stop after a terminal status or its own timeout.
For a verify-before-issue issuance, polling also returns the verification and application-decision lifecycle. After successful PID verification in the PoR example:
{
"issuance_id": "5fbf6e42-8463-4b4a-a884-70ae94d66577",
"status": "pending",
"credentials": [
{
"item_id": 42,
"credential_configuration_id": "proof_of_residence_sd_jwt",
"issuance_mode": "immediate",
"deferred_status": null,
"status": "pending",
"issued_at": null,
"notification": null
}
],
"authorization": {
"mode": "openid4vp",
"status": "awaiting_application",
"verifier": "core-verifier",
"presentation_configuration_id": "pid-sd-jwt",
"presentation": {
"presentation_id": "66ed765b-96fc-4143-b3a1-69db57063fa6",
"status": "completed",
"credentials": {
"pid": [
{
"format": "dc+sd-jwt",
"vct": "urn:eu.europa.ec.eudi:pid:1",
"issuer": "https://issuer.example.com",
"claims": {
"family_name": "DOE",
"given_name": "JOHN",
"birthdate": "1990-01-01"
},
"verification": {
"issuer_signature": "valid",
"disclosures": "valid",
"holder_binding": "valid"
}
}
]
},
"verification": {
"status": "valid",
"dcql": "valid",
"holder_binding": "valid",
"credential_count": 1
}
}
}
}
As with standalone presentation polling, this projection contains personal data and only normalized verified information. It never contains raw Wallet data, proofs or ephemeral keys. It also does not expose the initial or final issuance datasets.
Issuance status and decisions
Issuance statuses:
| Status | Meaning |
|---|---|
pending |
Waiting for the Wallet interaction to start. |
in_progress |
The Wallet interaction has started. |
partially_issued |
Some, but not all, attestations have been delivered. |
completed |
All attestations have been delivered. |
expired |
The operation expired before completion. |
failed |
The operation failed. |
Credential item statuses are:
pending
issued
failed
The implemented successful flow actively produces pending, in_progress,
partially_issued and completed. There is no background expiry worker;
instead, the polling endpoint compares the current time with expires_at and
persists expired before returning the response. Protocol endpoints also
reject expired sessions and temporary artifacts.
Verification-backed authorization statuses nested under authorization.status are:
| Status | Meaning |
|---|---|
pending_authorization |
Waiting for the user to start the verification step. |
presentation_pending |
Waiting for the Wallet verification to complete. |
awaiting_application |
Verification succeeded; the application must now provide or reject the issuance data. |
ready_for_authorization |
Final issuance data is frozen and the Wallet Hub can continue delivery. |
ready_for_consent |
Legacy transitional value migrated toready_for_authorization; accepted during rolling-deployment compatibility. |
authorized |
The Wallet Hub has completed the authorization step required to deliver the attestation. |
rejected |
The application refused issuance or the linked verification failed. |
failed |
The linked verification or authorization interaction failed. |
Finalize after verification
This operation exists only for an issuance using
authorization.mode=openid4vp and reuses the issuance ownership check:
POST /api/v1/issuances/{issuance_id}/authorization/finalize
X-API-Key: hub_live_<secret>
Content-Type: application/json
To continue the PoR example, the application evaluates the verified PID and provides the business data for every issuance item:
{
"outcome": "ready",
"credentials": [
{
"item_id": 42,
"claims": {
"resident_address": "10 rue de la Paix, 75002 Paris, France",
"street_name": "rue de la Paix",
"building_number": "10",
"postal_code": "75002",
"locality": "Paris",
"region": "Ile-de-France",
"country": "FR"
}
}
]
}
item_id values come from issuance polling. The request must contain each item
exactly once. Each claims object is checked against the selected Credential
Configuration: unknown claims, missing mandatory claims and incompatible JSON
types are rejected. A successful call returns 200 OK with the updated
issuance document and authorization.status=ready_for_authorization. The next
resume request consumes the opaque interaction, creates the Authorization Code
and redirects to the interaction-bound Wallet callback without displaying the
Wallet Hub consent screen.
The same finalization repeated with semantically identical JSON datasets is
idempotent. Different data after the first successful finalization is rejected
with 409 dataset_already_finalized; the application cannot alter claims after
the dataset has been frozen for authorization.
To stop issuance after evaluating the presentation:
{"outcome": "rejected"}
This returns the updated issuance document with
authorization.status=rejected. When the Wallet resumes the authorization
interaction, the Wallet Hub returns the standard OAuth access_denied response.
Deferred decision
When an issuance item has effective issuance_mode: deferred, its owning
application can transition the deferred transaction after the Wallet has made
the initial Credential Request:
POST /api/v1/issuances/{issuance_id}/credentials/{item_id}/decision
X-API-Key: hub_live_<secret>
Content-Type: application/json
Ready for delivery:
{"decision": "ready"}
Terminal failure:
{
"decision": "failed",
"description": "Manual review rejected"
}
Success returns 204 No Content. The operation reuses the existing API Client
ownership check. It returns 409 invalid_deferred_state if no pending deferred
transaction exists or the item is already terminal. The endpoint never accepts
or returns a Wallet proof, Access Token, credential, transaction_id or
notification_id.
Issuance errors
Error responses generated by the Application API use JSON. Depending on the
validation branch, the current implementation returns either only error, or
error with error_description and/or details.
Representative errors:
| HTTP | Response / error | Cause |
|---|---|---|
| 400 | A JSON object is required |
Body is absent or is not a JSON object. |
| 400 | issuer and credentials are required |
Missing Issuer orcredentials is not an array. |
| 400 | credentials must be a non-empty array |
Empty array. |
| 400 | Each credential requires credential_configuration_id and claims |
Malformed credential item for Pre-Authorized Code orhub_consent. |
| 400 | claims must be omitted when authorization.mode is openid4vp |
The application attempted to provide issuance data before presentation verification. |
| 400 | credential_configuration_id must be unique within an issuance |
Duplicate configuration ID in an Authorization Code request. Repetition is supported only for Pre-Authorized Code. |
| 400 | unsupported_credential_format |
A selected configuration has a format outside the supporteddc+sd-jwt and mso_mdoc set. |
| 400 | invalid_request |
authorization is missing or malformed for Authorization Code, is used with Pre-Authorized Code, or its mode is neither hub_consent nor openid4vp. error_description identifies the validation failure. |
| 400 | invalid_credential_dataset |
Verify-before-issue finalization contains unknown claims, omits mandatory claims or supplies an incompatible JSON type. |
| 400 | invalid_tx_code |
Invalid Transaction Code object; explanation is inerror_description. |
| 400 | invalid_expires_in |
Timeout is not an integer between 60 and 3600 seconds. |
| 400 | issuer_not_ready |
The selected EUDI Issuer fails the shared effective-policy readiness check.details contains stable machine-readable readiness codes. No PostgreSQL issuance session or Redis offer state is created. |
| 400 | issuer_not_ready / EUDI_PID_PRE_AUTHORIZED_CODE_PHYSICAL_PRESENCE_UNSUPPORTED |
PID Pre-Authorized Code was requested, but the Wallet Hub has no trustworthy physical-presence evidence model. Use Authorization Code for the supported Phase 1 subset. |
| 400 | decision must be ready or failed |
Invalid deferred decision. |
| 401 | missing_api_key |
NoX-API-Key while authentication is required. |
| 401 | invalid_api_key |
Unknown, disabled, revoked or expired key/client. |
| 403 | forbidden |
The API Client is not authorized for the requested Issuer or Verifier, or does not own the issuance.error_description identifies the protected resource when applicable. |
| 404 | issuer_not_found |
No Credential Issuer exists with the requested slug.error_description repeats the exact slug received by the API. |
| 409 | issuer_disabled |
The Credential Issuer exists but is disabled. |
| 404 | credential_configuration_not_found |
A requested Credential Configuration does not exist on the selected Issuer.details identifies every missing ID. |
| 409 | credential_configuration_disabled |
A requested Credential Configuration exists on the selected Issuer but is disabled.details identifies every disabled ID. |
| 409 | authorization_server_unavailable |
No enabled integrated Authorization Server compatible with the Issuer profile can execute the requested grant. |
| 409 | unsupported_authorization_profile |
The selected Issuer/Verifier profile pair is not enabled for verify-before-issue. The implementation accepts CORE + CORE and HAIP + HAIP. |
| 409 | invalid_authorization_state / presentation_not_completed |
Finalization was attempted before successful verification or after the decision had already changed state. |
| 409 | dataset_already_finalized |
A repeated finalization tried to replace already frozen issuance data. |
| 404 | Issuance session not found |
Unknown polling identifier. |
| 404 | Issuance item not found |
The item is not part of the issuance. |
| 409 | Issuance item is not deferred |
A decision was attempted for an immediate item. |
| 409 | invalid_deferred_state |
No pending deferred transaction can accept the decision. |
Examples:
{
"error": "forbidden",
"error_description": "This API client cannot access this issuance."
}
Presentations
Create a presentation
POST /api/v1/presentations
X-API-Key: hub_live_<secret>
Content-Type: application/json
Accept: application/json
A presentation asks a Wallet to provide information defined by a configured Presentation Configuration. The application selects the Verifier and Presentation Configuration; the Wallet Hub handles the Wallet request and cryptographic verification.
PID verification request example
{
"verifier": "eudi-verifier",
"presentation_configuration_id": "eudi-pid-mdoc",
"expires_in": 300
}
| Field | Type | Required | Description |
|---|---|---|---|
verifier |
string | yes | Enabled Verifier slug configured in the Wallet Hub. |
presentation_configuration_id |
string | yes | Enabled configuration ID belonging to the selected Verifier. |
expires_in |
integer | no | Session lifetime in seconds; defaults to 300. Allowed range: 60 through 900. |
redirect_uri |
string | no | Application return URL for CORE, HAIP and EUDI Wallet. HTTPS, or HTTP loopback; maximum 2048 characters, no credentials, fragment or existing response_code parameter. |
The PID journey uses eudi-verifier and eudi-pid-mdoc when these identifiers
are configured for the API Client. The latter is a configured identifier, not
an instruction for the application to choose a credential format. Other
verification journeys use the same selection parameters with their own configured
Verifier and Presentation Configuration identifiers.
The application does not supply request rules, cryptographic settings or Wallet-routing parameters. Those values come from the selected Verifier and Presentation Configuration. QES is described below; its protocol encoding and response-binding limitation are retained in the protocol reference.
Optional application return and same-browser confirmation
redirect_uri requires a managed Application API key associated with an
ApiClient; legacy master-key or unauthenticated access cannot enable this mode.
It is optional. Omit it for the existing polling journey, including
cross-device QR usage. Supplying it enables a result gate for CORE, HAIP and
EUDI Wallet, with both direct_post and direct_post.jwt. This URL belongs to
the individual transaction; it is not an ApiClient administration setting and
is never added to the Wallet Authorization Request. The Wallet still posts to
the Hub's response_uri.
Concrete same-device HAIP example (the selected identifiers must exist):
POST /api/v1/presentations
X-API-Key: hub_live_<secret>
Content-Type: application/json
{
"verifier": "haip-verifier-2",
"presentation_configuration_id": "proof-of-residence",
"expires_in": 300,
"redirect_uri": "https://application.example/wallet/callback"
}
The creation response includes presentation_id, qr_code_content,
status_uri, confirmation_uri and continuation_status: "pending".
The application backend stores presentation_id in the original browser's
server-side session before opening the Wallet. The API key stays on the backend.
After processing the Wallet's POST, the Hub returns HTTP 200 JSON:
{
"redirect_uri": "https://application.example/wallet/callback?response_code=<fresh-code>"
}
Existing query parameters are preserved. A valid Wallet error response can also receive this redirect. Invalid protocol/credential responses remain protocol errors and do not produce a continuation code. The 256-bit code is created after processing the response, stored only temporarily in Redis, bound to the presentation and API Client, and expires with the presentation.
Before confirmation, polling can report status: "completed" (cryptographic
verification succeeded) or status: "failed", but exposes neither
credentials, verification nor the Wallet error. It returns
continuation_status: "pending"; after the return deadline it reports
continuation_status: "expired" and keeps results withheld. The response code
is never exposed by polling, session creation or the QR code.
At the callback, recover the expected presentation_id from the original
browser session, not from callback query parameters. If the original browser
session is missing, reject the return. Then the backend submits the received
code to the returned confirmation_uri:
POST /api/v1/presentations/9bb3eb73-6029-4ca0-aad8-cf193482eb4a/confirm
X-API-Key: hub_live_<secret>
Content-Type: application/json
{"response_code": "<fresh-code-from-wallet-callback>"}
HTTP 200 returns the ordinary normalized presentation response, plus
continuation_status: "confirmed". For example:
{
"presentation_id": "9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
"status": "completed",
"continuation_status": "confirmed",
"verifier": "haip-verifier-2",
"presentation_configuration_id": "proof-of-residence",
"created_at": "2026-10-05T12:00:00Z",
"expires_at": "2026-10-05T12:05:00Z",
"status_uri": "https://hub.example/api/v1/presentations/9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
"confirmation_uri": "https://hub.example/api/v1/presentations/9bb3eb73-6029-4ca0-aad8-cf193482eb4a/confirm",
"credentials": {
"proof_of_residence": [{"format": "dc+sd-jwt", "claims": {"country": "FR"}}]
}
}
The response can also include the ordinary verification projection. A
confirmed Wallet refusal returns status: "failed" with its error instead.
Missing, wrong, expired or replayed codes return HTTP 400
invalid_response_code; another API Client receives 403 and an unknown
presentation receives 404. Code consumption is atomic. A code submitted for
the wrong presentation cannot consume the right presentation's code.
No further polling is necessary: confirmation returns the result directly. Polling after confirmation returns the same normalized result. If the confirmation response is lost after consumption, the backend can poll the original session to recover its confirmed result; it must not retry completion by accepting an unconfirmed result. Polling may also start before or after the Wallet return to display progress, but must never authorize a business action while continuation is pending or expired.
This implements the return-code mechanism described in OIDC4VP 1.0 Final §8.2 and §14.2. The browser-session check remains the application's responsibility: the Hub cannot check an application's cookie. This protection applies only when the Wallet returns to the original browser on the same device. It does not protect cross-device transactions or a return in a different browser; there is no automatic fallback to ungated polling. Rotate the application's session ID when this flow establishes a login, and avoid logging callback query strings.
Without an application URL, existing internal HAIP/EUDI result-page redirects and issuance-linked continuations remain unchanged. No database migration is required: the application URL and confirmation marker use the session's existing JSON snapshot.
Presentation create response
Success:
HTTP/1.1 201 Created
Location: https://hub.example/api/v1/presentations/9bb3eb73-6029-4ca0-aad8-cf193482eb4a
Content-Type: application/json
{
"presentation_id": "9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
"status": "pending",
"verifier": "core-verifier",
"presentation_configuration_id": "proof-of-residence",
"created_at": "2026-08-25T10:00:00+00:00",
"expires_at": "2026-08-25T10:05:00+00:00",
"status_uri": "https://hub.example/api/v1/presentations/9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
"wallet_invocation_uri": "openid4vp://authorize?client_id=...&request_uri=...&request_uri_method=get",
"qr_code_content": "openid4vp://authorize?client_id=...&request_uri=...&request_uri_method=get"
}
request_uri_method is selected by the Verifier configuration. For post, a
Wallet may POST form data to the Request URI; the Wallet Hub accepts the RFC 9101 GET
fallback as well, ignores wallet_metadata, and copies wallet_nonce into the
signed Authorization Request when present.
presentation_id is the Application API identifier to store. The HTTP
Location header and status_uri both identify the polling resource.
qr_code_content is the exact value to encode in the QR code. The client must
not reconstruct, decode or alter it. wallet_invocation_uri and
qr_code_content are returned only by the creation call. They are not included
in subsequent polling responses.
The Wallet invocation value contains an opaque, short-lived Wallet Hub reference. The application must display or invoke it promptly and must not call that reference itself.
Get a presentation
GET /api/v1/presentations/{presentation_id}
X-API-Key: hub_live_<secret>
Accept: application/json
The initial POST /api/v1/presentations only creates the session and returns
the Wallet invocation URI/QR code. It does not return verified attestation data.
For GET /api/v1/presentations/{presentation_id}, the result availability is:
| Creation request | When verified data becomes available | Confirmation needed? |
|---|---|---|
No redirect_uri |
As soon as status is completed: GET returns credentials and verification. |
No. |
With redirect_uri |
After the backend confirms the Wallet's response_code: /confirm returns the result immediately, and subsequent GET calls also return it. |
Yes. Before confirmation, GET exposes no verified data, even if status is completed. |
continuation_status and confirmation_uri are absent when no application
redirect_uri was supplied. With a redirect, both are present; only
continuation_status: "confirmed" permits result retrieval. No extra polling
is necessary after a successful confirmation.
While the Wallet has not responded:
{
"presentation_id": "9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
"status": "pending",
"verifier": "core-verifier",
"presentation_configuration_id": "proof-of-residence",
"created_at": "2026-08-25T10:00:00+00:00",
"expires_at": "2026-08-25T10:05:00+00:00",
"status_uri": "https://hub.example/api/v1/presentations/9bb3eb73-6029-4ca0-aad8-cf193482eb4a"
}
After successful verification (without redirect_uri, or after its return
has been confirmed):
{
"presentation_id": "9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
"status": "completed",
"verifier": "core-verifier",
"presentation_configuration_id": "proof-of-residence",
"created_at": "2026-08-25T10:00:00+00:00",
"expires_at": "2026-08-25T10:05:00+00:00",
"status_uri": "https://hub.example/api/v1/presentations/9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
"credentials": {
"proof_of_residence": [
{
"format": "dc+sd-jwt",
"vct": "urn:eu.europa.ec:por:1",
"issuer": "https://issuer.example.com",
"claims": {
"resident_address": "100 Kifisias Avenue, Athens",
"country": "GR"
},
"verification": {
"issuer_signature": "valid",
"issuer_trust": "local_signing_key",
"disclosures": "valid",
"holder_binding": "valid",
"credential_status": {
"checked": true,
"value": "valid"
}
}
}
]
},
"verification": {
"status": "valid",
"dcql": "valid",
"holder_binding": "valid",
"credential_count": 1
}
}
The Wallet Hub returns normalized verified claims. The Application does not need to
parse the original Wallet response. Each key inside credentials identifies a
configured information request, and each value is an array because a request
can allow multiple attestations. Protocol field mapping and raw-response
details are retained in the protocol reference.
credential_status is present only when the credential contains a Status List
reference. checked: true and value: "valid" mean that the Wallet Hub fetched the
list, validated its signature and freshness, and found neither revocation nor
suspension at the credential index.
For an mdoc result the same array entry uses format: "mso_mdoc", exposes its
doctype, nests claims by ISO namespace, and reports issuer_signature,
issuer_trust, digests, validity, device_authentication and
holder_binding verification states.
These responses contain personal data disclosed by the End-User. API clients must restrict access to them and apply an appropriate retention policy.
If the Wallet interaction returns an error or verification fails, polling
returns a terminal failed status. For example:
{
"presentation_id": "9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
"status": "failed",
"verifier": "core-verifier",
"presentation_configuration_id": "proof-of-residence",
"created_at": "2026-08-25T10:00:00+00:00",
"expires_at": "2026-08-25T10:05:00+00:00",
"status_uri": "https://hub.example/api/v1/presentations/9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
"error": {
"code": "access_denied",
"description": "User cancelled"
}
}
Presentation status model
| Status | Meaning |
|---|---|
pending |
Session created and waiting for the Wallet response. |
response_received |
Wallet response received; transient verification state. |
completed |
The requested information was successfully verified and is available in credentials. |
expired |
The Wallet did not complete the flow beforeexpires_at. |
failed |
The Wallet returned an error or the presentation failed validation. |
completed, expired and failed are terminal. There is no background expiry
worker. Polling compares the current time with expires_at and persists
expired for an unfinished session.
Presentation errors
Representative Application API errors:
| HTTP | Response / error | Cause |
|---|---|---|
| 400 | A JSON object is required |
Body is absent or is not a JSON object. |
| 400 | verifier is required |
verifier is absent, empty or not a string. |
| 400 | presentation_configuration_id is required |
Configuration ID is absent, empty or not a string. |
| 400 | invalid_expires_in |
Timeout is not an integer between 60 and 900 seconds. |
| 400 | invalid_verifier_configuration |
The selected Verifier is not ready to process the configured verification request. No presentation session is created. |
| 400 | unsupported_presentation_format |
The selected verification request uses a credential format the Wallet Hub does not support. |
| 400 | unsupported_presentation_constraint |
The selected query uses a runtime constraint that is not implemented. |
| 401 | missing_api_key |
NoX-API-Key while authentication is required. |
| 401 | invalid_api_key |
Unknown, disabled, revoked or expired key/client. |
| 403 | forbidden |
The API Client is not authorized for the Verifier or does not own the presentation. |
| 404 | Unknown or disabled verifier |
Verifier is absent or disabled. |
| 404 | Unknown or disabled presentation configuration |
Configuration is absent, disabled or belongs to another Verifier. |
| 404 | Presentation session not found |
Unknown polling identifier. |
Wallet-facing protocol errors are separate from the Application API contract; see the protocol reference.
Credential lifecycle
Change attestation status
POST /api/v1/credentials/{credential_id}/status
Content-Type: application/json
Request:
{"status":"invalid"}
status is required. Supported values are valid, invalid, and
suspended only for an attestation using a two-bit Status List. The opaque
credential_id is returned by issuance polling when lifecycle management is
enabled; the Application never sends a Status List URI or index.
Response (200 OK):
{
"credential_id": "cs_opaque_credential_id",
"status": "invalid",
"updated_at": "2026-09-24T15:30:00Z"
}
The API Client must own the original issuance; the transitional master-key
bypass retains the compatibility semantics in Authentication and ownership.
Errors use the Application API error shape: 400 for an absent or unsupported
status, 403 when a managed API Client does not own the attestation, 404 for
an unknown credential_id, and 409 when the list is unavailable or does not
permit suspension. See Suspend, restore or revoke an attestation
for the business workflow and the Protocol and interoperability reference
for Status List implementation details.
Issuance timing and test-only consent
Session expiration
expires_in lets the application select the issuance lifetime in seconds:
{
"issuer": "core-pid",
"expires_in": 60,
"credentials": [
{
"credential_configuration_id": "pid_sd_jwt",
"claims": {
"family_name": "DOE"
}
}
]
}
The value must be a JSON integer between 60 and 3600 seconds. Boolean, string and decimal values are rejected. When omitted, the default is 600 seconds (10 minutes).
expires_at is calculated as created_at + expires_in. Wallet-facing
artefacts managed by the Wallet Hub are capped at the remaining session lifetime. Their configured default
is 300 seconds, so:
expires_in: 60gives the session, offer and code at most 60 seconds;- the default
expires_in: 600keeps the offer and code at 300 seconds and the persistent issuance session at 600 seconds.
Polling after expires_at lazily reconciles a non-terminal session to
expired. A completed or failed session is not changed to expired.
Wallet test: Authorization Code with Wallet Hub consent
This is an advanced, test-only Wallet flow. It does not authenticate the End-User and must not be used as a production authentication mechanism.
- Create an issuance with
grant_type=authorization_codeandauthorization={"mode":"hub_consent"}, including the credential claims and omittingtx_code. - Display the returned
qr_code_content. The Wallet continues the standard Authorization Code flow and the Wallet Hub displays its test consent screen. - Poll the issuance until completion or a terminal failure.
The complete request contract is documented under Create an issuance.
6. Complete examples
If needed request an X-API-Key to contact@talao.io
The examples below illustrate the API calls made by an application server.
Set HUB_URL to the API origin supplied by the Wallet Hub operator and replace every
Issuer, Verifier and configuration identifier with one associated with your
API Client.
The JavaScript versions run as .mjs files with Node.js 22 and its built-in
fetch. Run them on the application server, with HUB_API_KEY set there;
browser JavaScript must not receive this key.
| Example | Issuer or Verifier | Attestation or presentation configuration | Application result |
|---|---|---|---|
| Issue an attestation to a known user | Enabled Issuer | One or more enabled Credential Configurations | Displayqr_code_content; poll the issuance status |
| PID verification with an EUDI Wallet | Verifiereudi-verifier |
Presentation Configurationeudi-pid-mdoc |
Read the verified result fromcredentials.pid after completed |
| Verify a PID before issuing an attestation | Enabled Issuer and Verifier | PID Presentation Configuration and attestation configuration | Evaluate the PID, then finalize or reject issuance |
The Wallet Hub operator must associate the enabled Issuer, Credential Configuration, Verifier and Presentation Configuration with the API Client. The examples contain no API Key; give each integrator a managed key privately and keep it on the application server.
Issue an attestation to a known user (Python)
This server-side example creates an issuance with two datasets. It hands the
exact QR content to the application's display layer and polls until the Wallet
has received both credentials. Replace the example Issuer, Credential
Configuration, claims and Transaction Code with values accepted by your Wallet Hub.
Set HUB_URL and HUB_API_KEY in the application-server environment; never
embed the key in the QR code or send it to a Wallet.
import json
import os
import time
from datetime import datetime, timezone
from urllib.error import HTTPError
from urllib.request import Request, urlopen
hub_url = os.environ["HUB_URL"].rstrip("/")
api_key = os.environ["HUB_API_KEY"]
tx_code = os.environ["HUB_TX_CODE"] # Deliver separately from the QR code.
def api_json(method, path, payload=None):
body = None if payload is None else json.dumps(payload).encode("utf-8")
request = Request(
f"{hub_url}{path}",
data=body,
method=method,
headers={
"X-API-Key": api_key,
"Accept": "application/json",
**({"Content-Type": "application/json"} if body else {}),
},
)
try:
with urlopen(request, timeout=15) as response:
return json.load(response)
except HTTPError as error:
raise RuntimeError(
f"Wallet Hub returned HTTP {error.code}: {error.read().decode('utf-8')}"
) from error
created = api_json(
"POST",
"/api/v1/issuances",
{
"issuer": "core-por-issuer",
"grant_type": "urn:ietf:params:oauth:grant-type:pre-authorized_code",
"expires_in": 300,
"tx_code": {
"value": tx_code,
"input_mode": "numeric",
"description": "Enter the code displayed by the issuer application",
},
"credentials": [
{
"credential_configuration_id": "proof_of_residence_sd_jwt",
"claims": {
"resident_address": "100 Kifisias Avenue, Marousi 151 24, Athens, Greece",
"street_name": "Kifisias Avenue",
"building_number": "100",
"postal_code": "151 24",
"locality": "Marousi",
"region": "Athens",
"country": "GR",
},
},
{
"credential_configuration_id": "proof_of_residence_sd_jwt",
"claims": {
"resident_address": "10 rue de la Paix, 75002 Paris, France",
"street_name": "rue de la Paix",
"building_number": "10",
"postal_code": "75002",
"locality": "Paris",
"region": "Ile-de-France",
"country": "FR",
},
},
],
},
)
issuance_id = created["issuance_id"]
qr_content = created["qr_code_content"]
# Give qr_content unchanged to a QR renderer in the application UI.
# This example prints it only for interactive testing.
print("Issuance:", issuance_id)
print("QR content:", qr_content)
expires_at = datetime.fromisoformat(created["expires_at"])
while datetime.now(timezone.utc) < expires_at:
status = api_json("GET", f"/api/v1/issuances/{issuance_id}")
if status["status"] in {"completed", "expired", "failed"}:
print("Issuance status:", status["status"])
break
time.sleep(2)
else:
print("The application stopped polling at the session expiry time.")
The application's HTTP response to its browser should contain only the QR
content and an application-owned session reference. The API Key and residence
datasets stay server-side. The Wallet Hub handles delivery after the QR is displayed;
this code only creates and polls the issuance. Communicate the Transaction Code
to the user separately from the QR code. The issuance reaches completed
after both attestations are delivered. See the protocol reference for the
Wallet-side exchange.
Issue an attestation to a known user (JavaScript / Node.js)
This server-side JavaScript example performs the same two-dataset issuance.
Replace the example Issuer, Credential Configuration, claims and Transaction
Code with values accepted by your Wallet Hub. Pass qrCodeContent unchanged to the
application's QR renderer and deliver txCode separately.
const hubUrl = process.env.HUB_URL;
const apiKey = process.env.HUB_API_KEY;
const txCode = process.env.HUB_TX_CODE; // Share separately from the QR.
if (!hubUrl || !apiKey || !txCode) throw new Error("Set HUB_URL, HUB_API_KEY and HUB_TX_CODE on the application server");
async function apiJson(method, url, payload) {
const response = await fetch(url, {
method,
headers: {
"X-API-Key": apiKey,
Accept: "application/json",
...(payload === undefined ? {} : { "Content-Type": "application/json" }),
},
body: payload === undefined ? undefined : JSON.stringify(payload),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
throw new Error(`Wallet Hub returned HTTP ${response.status}: ${await response.text()}`);
}
return response.json();
}
const created = await apiJson("POST", `${hubUrl}/api/v1/issuances`, {
issuer: "core-por-issuer",
grant_type: "urn:ietf:params:oauth:grant-type:pre-authorized_code",
expires_in: 300,
tx_code: {
value: txCode,
input_mode: "numeric",
description: "Enter the code displayed by the issuer application",
},
credentials: [
{
credential_configuration_id: "proof_of_residence_sd_jwt",
claims: {
resident_address: "100 Kifisias Avenue, Marousi 151 24, Athens, Greece",
street_name: "Kifisias Avenue",
building_number: "100",
postal_code: "151 24",
locality: "Marousi",
region: "Athens",
country: "GR",
},
},
{
credential_configuration_id: "proof_of_residence_sd_jwt",
claims: {
resident_address: "10 rue de la Paix, 75002 Paris, France",
street_name: "rue de la Paix",
building_number: "10",
postal_code: "75002",
locality: "Paris",
region: "Ile-de-France",
country: "FR",
},
},
],
});
const issuanceId = created.issuance_id;
const qrCodeContent = created.qr_code_content;
console.log("Issuance:", issuanceId);
console.log("QR content:", qrCodeContent); // For interactive testing only.
const expiresAt = Date.parse(created.expires_at);
let finished = false;
while (Date.now() < expiresAt) {
const status = await apiJson("GET", `${hubUrl}/api/v1/issuances/${issuanceId}`);
if (["completed", "expired", "failed"].includes(status.status)) {
console.log("Issuance status:", status.status);
finished = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
if (!finished) console.log("The application stopped polling at session expiry.");
Verify a PID before issuing an attestation (Python)
This example creates an issuance, waits for the verified PID, then either
provides the attestation data or rejects the issuance. It reuses the api_json
helper from the preceding Python example. Replace the environment values and
the business decision with those of your application.
issuer = os.environ["HUB_ISSUER"]
attestation_configuration_id = os.environ["HUB_ATTESTATION_CONFIGURATION_ID"]
pid_verifier = os.environ.get("HUB_PID_VERIFIER", "eudi-verifier")
pid_configuration_id = os.environ.get(
"HUB_PID_CONFIGURATION_ID", "eudi-pid-mdoc"
)
created = api_json(
"POST",
"/api/v1/issuances",
{
"issuer": issuer,
"grant_type": "authorization_code",
"authorization": {
"mode": "openid4vp",
"verifier": pid_verifier,
"presentation_configuration_id": pid_configuration_id,
},
"credentials": [
{"credential_configuration_id": attestation_configuration_id}
],
},
)
issuance_id = created["issuance_id"]
print("Display this QR content:", created["qr_code_content"])
while True:
status = api_json("GET", f"/api/v1/issuances/{issuance_id}")
authorization = status.get("authorization", {})
if authorization.get("status") == "awaiting_application":
pid_claims = authorization["presentation"]["credentials"]["pid"][0]["claims"]
# Apply your own business rules to pid_claims. These claims must not
# be logged or returned to browser code.
approve = True # Replace with the application's decision.
if approve:
attestation_claims = {
# Data held and approved by the application. It must match
# HUB_ATTESTATION_CONFIGURATION_ID.
}
item_id = status["credentials"][0]["item_id"]
api_json(
"POST",
f"/api/v1/issuances/{issuance_id}/authorization/finalize",
{
"outcome": "ready",
"credentials": [{"item_id": item_id, "claims": attestation_claims}],
},
)
else:
api_json(
"POST",
f"/api/v1/issuances/{issuance_id}/authorization/finalize",
{"outcome": "rejected"},
)
break
if status["status"] in {"expired", "failed"}:
raise RuntimeError(f"Issuance ended with {status['status']}")
time.sleep(2)
Verify a PID before issuing an attestation (JavaScript / Node.js)
This example performs the same workflow. It reuses the apiJson helper from
the preceding JavaScript example.
const issuer = process.env.HUB_ISSUER;
const attestationConfigurationId = process.env.HUB_ATTESTATION_CONFIGURATION_ID;
const pidVerifier = process.env.HUB_PID_VERIFIER || "eudi-verifier";
const pidConfigurationId = process.env.HUB_PID_CONFIGURATION_ID || "eudi-pid-mdoc";
if (!issuer || !attestationConfigurationId) {
throw new Error("Set HUB_ISSUER and HUB_ATTESTATION_CONFIGURATION_ID");
}
const createdAfterPid = await apiJson("POST", `${hubUrl}/api/v1/issuances`, {
issuer,
grant_type: "authorization_code",
authorization: {
mode: "openid4vp",
verifier: pidVerifier,
presentation_configuration_id: pidConfigurationId,
},
credentials: [{ credential_configuration_id: attestationConfigurationId }],
});
const issuanceAfterPidId = createdAfterPid.issuance_id;
console.log("Display this QR content:", createdAfterPid.qr_code_content);
while (true) {
const status = await apiJson(
"GET", `${hubUrl}/api/v1/issuances/${issuanceAfterPidId}`
);
if (status.authorization?.status === "awaiting_application") {
const pidClaims = status.authorization.presentation.credentials.pid[0].claims;
// Apply your own business rules to pidClaims. Do not log it or return it
// to browser code.
const approve = true; // Replace with the application's decision.
const finalization = approve
? {
outcome: "ready",
credentials: [{
item_id: status.credentials[0].item_id,
claims: {
// Data held and approved by the application. It must match
// HUB_ATTESTATION_CONFIGURATION_ID.
},
}],
}
: { outcome: "rejected" };
await apiJson(
"POST",
`${hubUrl}/api/v1/issuances/${issuanceAfterPidId}/authorization/finalize`,
finalization,
);
break;
}
if (["expired", "failed"].includes(status.status)) {
throw new Error(`Issuance ended with ${status.status}`);
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
PID verification (Python)
This example uses Verifier eudi-verifier and Presentation Configuration
eudi-pid-mdoc. The API Client must be associated with that Verifier. The
application does not supply or alter the Wallet request rules. Set HUB_URL
and HUB_API_KEY to the values supplied by the Wallet Hub operator.
import json
import os
import time
from datetime import datetime, timezone
from urllib.error import HTTPError
from urllib.request import Request, urlopen
hub_url = os.environ["HUB_URL"].rstrip("/")
api_key = os.environ["HUB_API_KEY"]
def api_json(method, url, payload=None):
body = None if payload is None else json.dumps(payload).encode("utf-8")
request = Request(
url,
data=body,
method=method,
headers={
"X-API-Key": api_key,
"Accept": "application/json",
**({"Content-Type": "application/json"} if body else {}),
},
)
try:
with urlopen(request, timeout=15) as response:
return json.load(response)
except HTTPError as error:
raise RuntimeError(
f"Wallet Hub returned HTTP {error.code}: {error.read().decode('utf-8')}"
) from error
created = api_json(
"POST",
f"{hub_url}/api/v1/presentations",
{
"verifier": "eudi-verifier",
"presentation_configuration_id": "eudi-pid-mdoc",
"expires_in": 300,
},
)
presentation_id = created["presentation_id"]
status_uri = created["status_uri"]
qr_content = created["qr_code_content"]
# Give qr_content unchanged to a QR renderer in the application UI.
print("Presentation:", presentation_id)
print("QR content:", qr_content)
expires_at = datetime.fromisoformat(created["expires_at"])
while datetime.now(timezone.utc) < expires_at:
status = api_json("GET", status_uri)
if status["status"] == "completed":
pid_credentials = status["credentials"]["pid"]
pid_claims = pid_credentials[0]["claims"]
# Apply your business rules to pid_claims on the application server.
print("PID verified; credential count:", len(pid_credentials))
break
if status["status"] in {"expired", "failed"}:
print("Presentation status:", status["status"])
break
time.sleep(2)
else:
print("The application stopped polling at the session expiry time.")
The status_uri is returned by the Wallet Hub. The Application presents the exact
qr_code_content to the Wallet and polls that URI with its API Key. After
completed, credentials.pid contains normalized, verified claims; handle
them as personal data and keep them out of browser logs and shared caches.
The raw PID presentation and Wallet response are not returned by this API.
PID verification (JavaScript / Node.js)
This server-side JavaScript example creates the same PID verification. It polls
the Wallet Hub's returned status_uri and leaves the verified PID claims on the
application server.
const hubUrl = process.env.HUB_URL;
const apiKey = process.env.HUB_API_KEY;
if (!hubUrl || !apiKey) throw new Error("Set HUB_URL and HUB_API_KEY on the application server");
async function apiJson(method, url, payload) {
const response = await fetch(url, {
method,
headers: {
"X-API-Key": apiKey,
Accept: "application/json",
...(payload === undefined ? {} : { "Content-Type": "application/json" }),
},
body: payload === undefined ? undefined : JSON.stringify(payload),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
throw new Error(`Wallet Hub returned HTTP ${response.status}: ${await response.text()}`);
}
return response.json();
}
const created = await apiJson("POST", `${hubUrl}/api/v1/presentations`, {
verifier: "eudi-verifier",
presentation_configuration_id: "eudi-pid-mdoc",
expires_in: 300,
});
const presentationId = created.presentation_id;
const statusUri = created.status_uri;
const qrCodeContent = created.qr_code_content;
console.log("Presentation:", presentationId);
console.log("QR content:", qrCodeContent); // Pass unchanged to the QR renderer.
const expiresAt = Date.parse(created.expires_at);
let finished = false;
while (Date.now() < expiresAt) {
const status = await apiJson("GET", statusUri);
if (status.status === "completed") {
const pidCredentials = status.credentials.pid;
const pidClaims = pidCredentials[0].claims;
// Apply business rules to pidClaims on the application server.
console.log("PID verified; credential count:", pidCredentials.length);
finished = true;
break;
}
if (["expired", "failed"].includes(status.status)) {
console.log("Presentation status:", status.status);
finished = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
if (!finished) console.log("The application stopped polling at session expiry.");
7. Protocol and interoperability reference
This section is for Wallet Hub operators, Wallet implementers and interoperability
testing. It is not required to call /api/v1; it records the Wallet-facing
behavior behind the application-level operations above.
Issuance transport, datasets and formats
The Application API creates an issuance and returns a Credential Offer and QR content. Wallet-facing PAR, Token and Credential requests are separate protocol endpoints and never carry the Application API Key.
When a Pre-Authorized Code issuance contains several datasets for the same
Credential Configuration, the Credential Offer contains that configuration
identifier only once. The Wallet is not required to send
authorization_details in its Token Request. The Wallet Hub returns an
authorization_details Token Response containing one opaque
credential_identifier per dataset. The Wallet then sends one Credential
Request per returned identifier; in those requests,
credential_identifier is required and credential_configuration_id is
forbidden. These identifiers and dataset values are never exposed through
Application API polling. credential_identifier is consequently not an
Application API identifier; it is neither credential_id, a VC jti, nor a
Status List URI or index.
The runtime supports configured dc+sd-jwt and mso_mdoc credentials. Both
use OIDC4VCI and a JWT proof of possession. CORE and HAIP use cnf.jwk for an
SD-JWT VC and a COSE key in the mdoc Mobile Security Object. The DIIP V5 subset
requires a Wallet did:jwk proof and emits its verification method as
cnf.kid.
For dc+sd-jwt, every mapped business claim present in the dataset is encoded
as an RFC 9901 Disclosure. Its
name and value are absent from the Issuer-signed JWT; only its salted SHA-256
digest is placed in the relevant _sd array. iss, iat, vct, cnf and
_sd_alg remain visible. The Credential Endpoint returns the Issuer-signed
JWT followed by all necessary Disclosures and the final ~ separator. A
legacy must_not business mapping is refused with credential_request_denied.
Issuer SD-JWT mappings support non-empty string path components, including
nested paths such as ["address", "city"]; array wildcards (null) and
numeric indexes are not supported.
For mso_mdoc, mapped paths contain exactly a namespace and data-element
identifier. The Credential Endpoint returns base64url-encoded CBOR
IssuerSigned. Each data element has an independent random salt and MSO
digest. The MSO binds the Wallet P-256 key, doctype and validity period;
IssuerAuth is a COSE_Sign1 using ES256 (-7) and the configured X.509
document-signer chain. Date elements use CBOR full-date tag 1004 by default;
a mapping can set another non-negative cbor_tag.
Detailed issuance protocol and profile behavior
authorization_code selects an enabled associated Authorization Server whose
Authorization Code runtime is executable. On HAIP and EUDI Wallet, the flow
uses scope, PKCE S256, PAR and Wallet Attestation/PoP. It uses DPoP when the AS
transaction policy requires it or when the Wallet establishes an optional DPoP
binding. The Application API
only creates the issuance and Credential Offer; Wallet-facing PAR, Token and
Credential requests remain protocol endpoints and never carry the Application
API Key.
Test-only Wallet Hub consent interaction
The supported Application API interaction setting is:
"authorization": {
"mode": "hub_consent"
}
hub_consent is a test-only interaction mode. It performs user
authorization/consent through a Wallet Hub-hosted screen but does not
authenticate the end user. It must not be considered a production
authentication mechanism.
hub_consent must be explicitly requested by the Application API. The Wallet Hub
never enables this mode implicitly: grant_type=authorization_code without an
authorization.mode is rejected with invalid_request. The OAuth grant and
the Wallet Hub interaction strategy are intentionally separate concepts so future
production strategies can be added without changing the meaning of
authorization_code.
The setting is internal to the Wallet Hub. The Credential Offer contains the standard
grants.authorization_code object and opaque issuer_state; the Wallet uses
the standard authorization_code grant at the Token Endpoint. Neither
hub_consent nor another test-specific value is exposed in OIDC4VCI or OAuth
messages.
This mode is issuer-initiated and works only with an integrated Authorization
Server. Wallet-initiated authorization, end-user authentication, an external
Authorization Server and Refresh Tokens are not implemented by this mode. On
HAIP, the Wallet must select the Credential Configuration through scope; the
existing PKCE S256, PAR, response iss, Wallet Attestation/PoP and DPoP policy
remain enforced.
OpenID4VP-backed authorization interaction
The verification-backed interaction setting implemented for CORE is:
"authorization": {
"mode": "openid4vp",
"verifier": "core-verifier",
"presentation_configuration_id": "pid-sd-jwt"
}
This mode reuses the standard Authorization Code flow and the existing
Verifier runtime. Once the Wallet reaches the Authorization Endpoint, the Wallet Hub
creates a linked Presentation Session from the selected Verifier and
Presentation Configuration, then returns a same-device HTML page with an explicit
Open Wallet button. The user clicks to launch the presentation; the page does not
automatically invoke or redirect to the Wallet. The button’s Wallet Invocation URI is
built from the selected Verifier's wallet_invocation_base; the Wallet Hub does not
force openid4vp://, so a configured custom scheme or HTTPS Universal Link is
preserved. The presentation request, response, cryptographic verification and
DCQL checks are the same as for POST /api/v1/presentations.
The browser carries only opaque, expiring interaction references. Presentation
credentials, proofs and the future issuance datasets are never copied through
browser form fields. The API Key remains server-side. The linked presentation,
authorization interaction, issuer_state, Request Object, nonces and private
ephemeral material retain their normal expiration and single-use controls.
After successful presentation verification, issuance polling reports
authorization.status=awaiting_application and includes only the normalized
verified presentation result. The application then either:
- supplies all final issuance datasets with
outcome=ready; or - refuses issuance with
outcome=rejected.
ready freezes the Application dataset and allows the Wallet Hub to complete the
ordinary Authorization Code response automatically when the Wallet resumes the
interaction. The final issuance consent is handled by the Wallet; the Wallet Hub does
not display a second consent screen. An application rejection produces the
ordinary OAuth access_denied response. No proprietary grant, scope, PAR
parameter, Token parameter or Credential Offer property is introduced.
The compatibility policy allows CORE Issuer + CORE Verifier and HAIP Issuer + HAIP Verifier. The mode is intentionally profile-neutral at the API and model levels; enabling EUDI Wallet, DIIP V5 or EBSI V4 later requires an explicit compatibility policy and interoperability tests, without changing this request shape.
For an Authorization Code flow, CORE accepts the public Wallet's client_id
without prior OAuth client registration and uses the absolute safe
redirect_uri supplied by the Wallet. HAIP and EUDI Wallet bind client_id to
the Wallet Attestation validated at PAR and Token and likewise use the safe
Wallet-provided redirect. Redirect URIs remain bound to the validated request,
Authorization Code and Token exchange; relative or unsafe schemes and
non-loopback HTTP URIs are rejected. DIIP V5 and EBSI V4 keep their
profile-specific client registration policy.
The default Pre-Authorized Code grant selects an associated server whose
configured mode is executable. CORE and EBSI V4 can use anonymous access with
Client Authentication none, returning a Bearer token unless DPoP is
configured. DIIP V5 uses a non-anonymous public did:jwk client_id without
WIA or confidential-client authentication and binds the later proof iss to
that token context. HAIP and EUDI Wallet use a non-anonymous mode: the Wallet sends
OAuth-Client-Attestation and OAuth-Client-Attestation-PoP. At the Token
Endpoint, client_id is optional and is derived from the Attestation sub when
omitted; when supplied, it must equal that sub. It also sends a nonce-bearing
DPoP proof when
DPoP is required or selected, in which case the Token Endpoint returns a
DPoP-bound Access Token. tx_code is accepted only with the Pre-Authorized
Code grant.
The application does not provide the format, VCT, signing algorithm, proof algorithms, signing key or metadata display values. Those properties come from the Issuer Credential Configuration.
For the supported EUDI non-qualified Email EAA subset, the selected
Credential Configuration must snapshot a published Email EAA Rulebook. The
Application API applies the same effective-policy readiness check as discovery
and the Credential Endpoint before creating PostgreSQL or Redis state. An
unselected/retired Rulebook, mismatched Type Metadata integrity, missing EAA
Provider signing trust, or another unsupported EAA combination returns
400 issuer_not_ready. The application still sends only the business dataset;
the Rulebook-derived VCT, vct#integrity, technical validity and EAA envelope
cannot be overridden through /api/v1.
For a QTSP Service User Attestation configuration copied from the catalogue, a manual test dataset is:
{
"credential_configuration_id": "qtsp_service_user_sd_jwt",
"claims": {
"userName": "willeke",
"credentialID": "GX0112348"
}
}
The static credentialID is meaningful to the remote signing provider; it is
not an OpenID4VP DCQL query ID. The dynamic
org.cloudsignatureconsortium.dm.1.qesApproval value is deliberately absent
from issuance and is reserved for a future Wallet-generated Key Binding JWT at
presentation time.
Current dataset validation boundary
At creation time, the Wallet Hub verifies that claims is a JSON object. It does not
generally enforce required claims, claim value types, or reject unknown claim
names against the Credential Type catalogue. During SD-JWT generation, only
claims mapped by the Issuer Credential Configuration are included. The Email
EAA runtime is stricter: it rejects an absent/empty mandatory email value or
the configured sub/also_known_as source before signing.
The OpenID4VP finalization endpoint is deliberately stricter because it is the boundary where previously unknown issuance data becomes immutable: it rejects unknown claims, requires every mandatory mapped claim and checks the configured JSON data type.
Advanced credential formats and data validation
Applications must therefore provide datasets that match the selected
configuration. Strong schema validation and a structured 422 invalid_claims
response are not implemented yet and are not part of the current contract.
For dc+sd-jwt, every mapped business claim that is present in the dataset is
encoded as an RFC 9901
Disclosure. Its name and value are absent from the
Issuer-signed JWT and only its salted SHA-256 digest is placed in the relevant
_sd array. The protocol claims iss, iat, vct, cnf and _sd_alg remain
visible. The Credential Endpoint returns the Issuer-signed JWT followed by all
Disclosures needed by the wallet and by the final ~ separator. A legacy
configuration containing a must_not business mapping is refused with
credential_request_denied rather than issuing that claim in cleartext.
Issuer SD-JWT claim mappings currently support only non-empty string path
components, including nested object paths such as ["address", "city"].
Array wildcards (null) and numeric indexes cannot be configured because the
Issuer does not implement their issuance. The Application API does not accept
claim paths in an issuance request; it uses the saved Issuer configuration.
For mso_mdoc, mapped claim paths contain exactly a namespace and data element
identifier. The Credential Endpoint returns the base64url encoding of the CBOR
IssuerSigned structure required by OIDC4VCI 1.0. Each data element gets an
independent random salt and MSO digest. The MSO binds the Wallet P-256 key,
doctype and validity period; IssuerAuth is a COSE_Sign1 using ES256 (-7)
and the configured X.509 document-signer chain. Date data elements use CBOR
full-date tag 1004 by default; a claim mapping can explicitly configure another
non-negative cbor_tag.
Authorization profiles and Wallet security
grant_type=authorization_code selects an enabled associated Authorization
Server whose Authorization Code runtime is executable. HAIP and EUDI Wallet
flows use scope, PKCE S256, PAR and Wallet Attestation/PoP. They use DPoP when
the Authorization Server policy requires it or the Wallet establishes an
optional DPoP binding.
hub_consent is an internal, test-only interaction setting. It must be
explicit: grant_type=authorization_code without authorization.mode is
rejected with invalid_request. The Credential Offer contains the standard
grants.authorization_code object and opaque issuer_state; neither
hub_consent nor another test-specific value is exposed in OIDC4VCI or OAuth
messages. This mode is issuer initiated, works only with an integrated
Authorization Server, does not authenticate the end user, and does not
implement wallet-initiated authorization, an external Authorization Server or
Refresh Tokens.
For authorization.mode=openid4vp, the Wallet Hub uses the selected Verifier and
Presentation Configuration to create a linked Presentation Session. The Wallet
invocation URI is based on the Verifier's wallet_invocation_base; a custom
scheme or HTTPS Universal Link is preserved rather than forcing
openid4vp://. Browser references are opaque and expiring. Presentation
credentials, proofs and future issuance datasets never pass through browser
form fields. issuer_state, Request Object, nonces and private ephemeral
material retain their expiration and single-use controls.
After verification, outcome=ready freezes application datasets and lets the
Wallet Hub complete the ordinary Authorization Code response when the Wallet resumes.
outcome=rejected produces the ordinary OAuth access_denied response. No
proprietary grant, scope, PAR parameter, Token parameter or Credential Offer
property is introduced. The compatibility policy accepts CORE Issuer +
CORE Verifier and HAIP Issuer + HAIP Verifier; EUDI Wallet, DIIP V5 and EBSI V4 require explicit
compatibility policy and interoperability tests.
CORE accepts a public Wallet client_id without prior OAuth registration and
uses its absolute safe redirect_uri. HAIP and EUDI bind client_id to the
Wallet Attestation validated at PAR and Token, and bind the redirect URI to the
validated request, Authorization Code and Token exchange. Relative or unsafe
schemes and non-loopback HTTP are rejected. DIIP V5 and EBSI V4 retain their
profile-specific client-registration policy.
For the default Pre-Authorized Code grant, CORE and EBSI V4 can use Client
Authentication none and return a Bearer token unless DPoP is configured.
DIIP V5 uses a non-anonymous public did:jwk client_id, without WIA or
confidential-client authentication, and binds the later proof iss to the
token context. HAIP and EUDI use non-anonymous Wallet authentication:
OAuth-Client-Attestation, OAuth-Client-Attestation-PoP, and, when required
or selected, a nonce-bearing DPoP proof. In that case Token returns a
DPoP-bound Access Token. tx_code is accepted only with Pre-Authorized Code.
Presentation protocol, QES and trust
The selected Presentation Configuration supplies DCQL, optional QES approval data, Client Identifier, response mode, encryption algorithms and Authorization Request signing key. For a QES Approval Request, the Wallet Hub performs this exact transformation once and freezes the result in the Presentation Session:
plain qes_approval_request JSON object
-> compact JSON with separators=(",", ":") and ensure_ascii=False
-> UTF-8 bytes
-> base64url without "=" padding
-> transaction_data[0]
The generated transaction_data is not returned through /api/v1. In the
decoded QES object, credential_ids are DCQL Credential Query IDs (for
example qes_approval), not the QTSP attestation business claim
credentialID (GX0112348). QES approval response binding is not yet
validated. The object is encoded once; neither the application nor the Wallet
should pre-encode it. Its exact encoded array remains in the persistent
Presentation Session request snapshot and temporary protocol state, so response
processing never depends on later JSON reformatting or configuration changes.
The runtime verifies dc+sd-jwt with ES256 and a Key Binding JWT, and
mso_mdoc DeviceResponses with COSE ES256 DeviceSignature holder binding. For
mdoc it validates IssuerAuth, certificate path, MSO digests, doctype,
validity period, requested claims and OIDC4VP SessionTranscript. It accepts
active Document Issuer Trust Anchors and roots of local enabled Signing Keys;
DCQL trusted_authorities of type aki is enforced for mdoc when present.
Supported Token Status List references are validated, with an mdoc reference
protected in the MSO. A non-valid credential status fails presentation.
The Verifier requires an enabled Authorization Request signing key. With
direct_post.jwt, the Wallet Hub creates a fresh ephemeral ECDH-ES P-256 response
encryption key for the session. Wallet-facing Request URI and Response URI
errors use protocol codes including invalid_request, invalid_request_uri
and invalid_vp_token.
For an eudi_wallet Verifier, creation evaluates the selected policy before
PostgreSQL or Redis protocol state exists. The legacy
eu-2026-1731-annex-xii policy requires a valid WRP Access Certificate and a
global WRP Registration Certificate. The
etsi_119472_2_v1_3_1_static_trust policy requires a valid legal-person
NCP-l WRP Access Certificate, a selected typed Intended Use and a DCQL subset
limited to PID dc+sd-jwt; it derives a registrar_dataset, optionally
validates a per-use Registration Certificate, and requires an active
pid_provider_authority Trust Anchor. The Wallet Hub derives and snapshots
verifier_info; API callers cannot override it.
Status Lists and non-API artefacts
For issuance expiry, Credential Offer and Pre-Authorized Code TTLs are each capped at the session lifetime. Any subsequently created Access Token is capped at the remaining session lifetime. The configured Offer and Pre-Authorized Code default is 300 seconds.
When Status Lists are enabled, the Wallet Hub reserves a random non-reusable index
while issuing. It checks the list signature, freshness and referenced index
during verification. credential_id is an opaque lifecycle handle for the
Application API; applications never send a Status List URI or index. A status
value of invalid or suspended fails presentation verification.
Issuance polling intentionally omits original datasets, issued credentials,
Pre-Authorized Code, Access Token, Transaction Code, OIDC4VCI
transaction_id, notification_id, raw VP Token, SD-JWT, Disclosures, Key
Binding JWT, mdoc DeviceResponse, Wallet proofs and ephemeral keys. It exposes
only normalized verified information and the documented lifecycle projections.