Wallet Hub · Client integration

Application API

Issuance, presentation and Rulebook contracts for server-side applications.

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.

EUDI-compliant private Wallets

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:

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:

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
  1. The Application calls POST /api/v1/issuances with an Issuer and one or more datasets to issue to that user.
  2. It stores issuance_id and gives the exact qr_code_content from the creation response to its QR renderer. If it requested a Transaction Code, it communicates that code separately from the QR code.
  3. The Wallet obtains the attestation from the Wallet Hub. The Application polls GET /api/v1/issuances/{issuance_id} until completed, expired or failed, bounded by expires_at.

API operations:

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:

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:

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.

  1. Create the presentation with POST /api/v1/presentations.
  2. Display the returned qr_code_content unchanged.
  3. Poll the returned status_uri.
  4. 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:

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"
      }
    }
  ]
}
{
  "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.

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:

Polling after expires_at lazily reconciles a non-terminal session to expired. A completed or failed session is not changed to expired.

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.

  1. Create an issuance with grant_type=authorization_code and authorization={"mode":"hub_consent"}, including the credential claims and omitting tx_code.
  2. Display the returned qr_code_content. The Wallet continues the standard Authorization Code flow and the Wallet Hub displays its test consent screen.
  3. 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.

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:

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.