Talao · Client integration

Custom APIs

Verify identity, issue attestations and integrate the service from your backend.

Digital Attestations — Custom APIs

API document version: 1.3.4

Last updated: 2026-11-13

API URL: https://openid4vc-hub.com

Default prefix: /api/v1

Request an API key

1. Introduction

This guide shows how your backend can verify a person's identity and issue an employee badge using REST requests. Start with the Quick Start and its runnable Python program. Workflows, other languages, the detailed API reference, limits and data protection follow. The Application calls /api/v1 from its server. The Talao service handles the Wallet-facing interaction.

Your Application
       |
       | REST API
       v
Talao service
       |
       | 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.
Digital identity document 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 Verify a digital identity document 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 digital identity document before issuing an attestation Verify a digital identity document 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

Start with the Quick Start, including a complete Python example. Additional Node.js, PHP and badge issuance programs are under Complete examples.

Wallets

A Wallet is an application that lets a user hold digital documents and approve sharing them. A digital identity document proves who a person is; an employee badge is another kind of attestation. Your backend calls the service, not the Wallet directly.

The user needs a Wallet compatible with your configured service. To verify a digital identity, that Wallet must already contain an accepted identity document. Installing a Wallet alone does not create one. For example, Talao Wallet is one option; contact us to confirm the Wallet and document needed for your use case. This is not a promise that any Wallet supports every service.

Purpose and scope

The Application API is the server-to-server interface between a client Application and the Talao service. It lets an Application:

Your Application communicates only with /api/v1. The Talao service 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 by default; optional include_evidence: true also returns the validated VP Token as evidence.response.vp_token.

This document is the implemented /api/v1 client contract. Practical limits and result handling are documented in Part 7.

What a verification result means

The service checks the presented document and its connection to this request under the configured verification and issuer-trust policy. Your application receives readable attributes and the outcome of those checks; it does not need to validate document signatures or decode Wallet messages itself.

Before using identity data, your server must check that:

  1. The result belongs to the request it created: presentation_id, verifier and presentation_configuration_id match the saved session and service.
  2. status is completed and the expected data is present. If continuation_status is present, it must be confirmed before you use it.
  3. The returned verification checks are valid, no warnings are present and the credential's issuer_trust is one of the trusted policies agreed with Talao for that service. Never accept unanchored_test_mode as issuer trust.

completed alone is not a guarantee of trusted identity: a service deliberately configured for evaluation can accept a document without established issuer trust and report warnings. Agree the production trust policy with Talao and use the checks shown in the examples. These are checks on the service's result, not a second implementation of Wallet cryptography.

Your application still decides whether the identified person may access a particular account or service. It must bind the saved request to the correct application/browser session. The local example below demonstrates the API exchange; it is not a complete production account-login system.

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}
GET  /api/v1/issuances/{issuer_slug}/wallet-sessions
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}
POST /api/v1/presentations/{presentation_id}/confirm

These are the Application API routes currently supported. The /confirm call is optional: it is used only when your verification request supplies an application redirect_uri. The default QR and polling journey does not need it.

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

Before you start

Request a free API key for testing to run the examples below. Complete the form with your first name, last name, business email and company name.

The Quick Start uses the same settings as the complete examples:

Task Service Configuration What you see
Verify a digital identity document haip-verifier-2 france-identite-pid-mdoc The complete API response, including evidence.request and evidence.response.vp_token when available
Issue an employee badge haip-employee-issuer employee_mdoc The issuance status returned by the API

Run a complete digital identity example: Python, Node.js or PHP. Each requests evidence explicitly and displays the full result; the API default remains include_evidence: false. Evidence is returned only for a successful verification with a complete captured request/response pair and any required confirmation. Changing this option requires creating a new verification.

You need:

The identifiers below illustrate one digital identity configuration and one employee badge model. They are not automatically provisioned for every API key. Replace them only with the values Talao has enabled for your integration.

Parameter Supplied by Talao Chosen by your application
HUB_URL, HUB_API_KEY API origin and private key Where to store the key on your backend
verifier, presentation_configuration_id Verification service and requested-information template Which of your authorized verification services to use
issuer, credential_configuration_id Issuing service and attestation model Which of your authorized badge models to issue
Result keys and allowed issuer-trust labels Expected shape and policy for the selected service The decision your application makes with accepted data
claims Field names accepted by the badge model The approved employee data you send
expires_in Allowed ranges in chapter 7 A lifetime within those ranges

Define the connection parameters before the first API call:

export HUB_URL="https://openid4vc-hub.com"
export HUB_API_KEY="<your-private-api-key>"

Check your connection

curl --silent --show-error --include \
  --header "X-API-Key: $HUB_API_KEY" \
  --header "Accept: application/json" \
  "$HUB_URL/api/v1/health"

Expect HTTP 200 and {"status":"ok"}. HTTP 401 means the key is missing or invalid. This checks API connectivity and key acceptance, not access to a particular service and not a successful Wallet interaction. Creating a request can still return 403 if your key is not authorized for that service.

Verify a digital identity document with an EUDI Wallet

  1. Create a verification from your backend:
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": "haip-verifier-2",
    "presentation_configuration_id": "france-identite-pid-mdoc",
    "expires_in": 300,
    "include_evidence": true
  }'

Expect HTTP 201 with presentation_id, qr_code_content, status_uri and expires_at. This response creates the request; it contains no verified identity. Save the identifier and expected service identifiers in your application session.

  1. Let the user continue with their Wallet.

  2. Computer and phone: show a QR encoding the returned qr_code_content exactly as supplied. The user scans it with their phone/compatible Wallet.

  3. On the same phone: use the returned wallet_invocation_uri as the target of an “Open my Wallet” button. Opening the link depends on the installed Wallet and phone/browser support; retain a suitable fallback in your interface.

The Wallet shows what information is requested and asks the user to approve sharing it. Your API key is never displayed or sent to the Wallet. A refusal or an expired request does not yield usable identity data.

  1. Read the result from your backend using the status_uri returned above:
export STATUS_URI="<paste-the-status_uri-from-the-creation-response>"
curl --silent --show-error \
  --header "X-API-Key: $HUB_API_KEY" \
  --header "Accept: application/json" \
  "$STATUS_URI"

The command prints the complete API JSON, including evidence when returned; do not select only credentials if you want to inspect the supporting proof.

Repeat status checks while the request is in progress, for example every two seconds, stopping at expires_at. Expect HTTP 200 with pending or response_received while waiting, then completed, failed or expired.

  1. Validate the result before using it. This illustrative success response matches the configuration above; its result group is pid_mdoc and its identity attributes are under claims["eu.europa.ec.eudi.pid.1"]:
{
  "presentation_id": "9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
  "status": "completed",
  "verifier": "haip-verifier-2",
  "presentation_configuration_id": "france-identite-pid-mdoc",
  "created_at": "2026-11-13T10:00:00Z",
  "expires_at": "2026-11-13T10:05:00Z",
  "status_uri": "https://openid4vc-hub.com/api/v1/presentations/9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
  "credentials": {
    "pid_mdoc": [
      {
        "format": "mso_mdoc",
        "doctype": "eu.europa.ec.eudi.pid.1",
        "claims": {
          "eu.europa.ec.eudi.pid.1": {
            "given_name": "Jane",
            "family_name": "Doe",
            "birth_date": "1990-01-01",
            "birth_place": "PARIS"
          }
        },
        "verification": {
          "issuer_signature": "valid",
          "holder_binding": "valid",
          "digests": "valid",
          "validity": "valid",
          "device_authentication": "valid",
          "session_transcript": "valid",
          "issuer_trust": "x509_trust_anchor"
        }
      }
    ]
  },
  "verification": {
    "dcql": "valid",
    "holder_binding": "valid",
    "credential_count": 1,
    "status": "valid"
  },
  "evidence": {
    "request": {
      "format": "jwt",
      "content": "<exact-signed-request-sent-to-the-Wallet>"
    },
    "response": {
      "vp_token": {
        "pid_mdoc": [
          "<received-mdoc-presentation>"
        ]
      }
    }
  }
}

Apply the result checks. For this example, Talao must have confirmed the accepted trust label x509_trust_anchor; do not copy that assumption to an unrelated service. The complete Python program below performs the session, confirmation, verification and trust-label checks.

This request deliberately omits redirect_uri: no /confirm call is needed, even if the Wallet opens a service completion page. If you choose to supply your own callback URL, your backend must confirm the return before using the data; see Optional application return. This demonstration explicitly requests include_evidence: true so you can inspect the supporting request and proof in the complete API response. The API option itself remains optional and false by default. If your integration only needs readable attributes, omit it. Evidence contains sensitive data; see its contract and data retention.

Issue an employee badge to a known user

Your application first checks who the employee is and approves their badge data. Then create one badge using your authorized issuing service and model:

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": "haip-employee-issuer",
    "grant_type": "urn:ietf:params:oauth:grant-type:pre-authorized_code",
    "initiation_mode": "issuer_initiated",
    "expires_in": 300,
    "credentials": [
      {
        "credential_configuration_id": "employee_mdoc",
        "claims": {
          "given_name": "Jane",
          "family_name": "Doe",
          "employee_id": "EMP-0001",
          "job_title": "Software Engineer",
          "department": "Engineering",
          "employer_name": "Example Company",
          "issuing_authority": "Example Company HR Department"
        }
      }
    ]
  }'

Expect HTTP 201 with issuance_id, qr_code_content and expires_at. Display the QR unchanged (or use its URI as a Wallet-opening link on the same phone when supported). The user reviews and accepts the attestation in their Wallet. From your backend, poll the issuance status:

export ISSUANCE_ID="<paste-the-issuance_id-from-the-creation-response>"
curl --silent --show-error \
  --header "X-API-Key: $HUB_API_KEY" \
  --header "Accept: application/json" \
  "$HUB_URL/api/v1/issuances/$ISSUANCE_ID"

completed means the badge was issued to the Wallet. pending or in_progress means keep waiting within the deadline; failed and expired end the request. The result reports delivery state, not the original employee data or the badge. The fixed grant_type and initiation_mode values in this example select this direct-delivery workflow; copy those values unchanged.

For complete issuance programs, use the chapter 6 Python, Node.js or PHP example. The first runnable digital identity program follows immediately below.

Identity verification (Python)

Install Python 3.12 and run python -m pip install qrcode. Save the following as verify_pid.py, then run python verify_pid.py. Use a computer for this localhost demonstration and scan its QR with your phone. This single file starts its own local server: no second terminal or static server is needed. Use the exported HUB_API_KEY, set API_KEY at the top, or enter it at the hidden prompt. Open http://127.0.0.1:8080, scan the QR code, and read the verified data and full API result, including evidence when returned, on the same page. The page stays available until Ctrl+C. This is a local API demonstration, not a production login/session system. Only this page is served; your source file and API key are not exposed. The result stays in memory and is not written to disk.

"""Run with python verify_pid.py, then open http://127.0.0.1:8080."""

import getpass
import json
import os
import time
from datetime import datetime, timezone
from html import escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Event, Thread
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen

import qrcode
import qrcode.image.svg

# Integration parameters. The key stays in this process, never in the page.
API_KEY = os.environ.get("HUB_API_KEY", "")  # Or enter it at the hidden prompt.
VERIFIER = "haip-verifier-2"
PRESENTATION_TEMPLATE = "france-identite-pid-mdoc"
# Talao also confirms these response keys and trusted policy for this service.
RESULT_GROUP = "pid_mdoc"
CLAIM_GROUP = "eu.europa.ec.eudi.pid.1"
ALLOWED_ISSUER_TRUST = {"x509_trust_anchor"}

HUB_URL = "https://openid4vc-hub.com"
LOCAL_HOST = "127.0.0.1"
LOCAL_PORT = 8080
PAGE = b""


def show(title, content="", waiting=False):
    """Replace the local page in memory; no personal data is written to disk."""
    global PAGE
    refresh = '<meta http-equiv="refresh" content="2">' if waiting else ""
    PAGE = (
        '<!doctype html><html lang="en"><head><meta charset="utf-8">'
        '<meta name="viewport" content="width=device-width, initial-scale=1">'
        + refresh + '<title>Identity verification</title>'
        '<style>body{font:16px system-ui;max-width:1000px;margin:40px auto;padding:0 20px}'
        'pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f4f6f8;padding:20px}'
        'svg{max-width:100%;height:auto}</style></head><body><h1>'
        + escape(title) + '</h1>' + content + '</body></html>'
    ).encode("utf-8")


class LocalPage(BaseHTTPRequestHandler):
    def do_GET(self):
        # Only the result page is served: source files and API key are inaccessible.
        if urlsplit(self.path).path != "/":
            self.send_error(404)
            return
        page = PAGE
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Cache-Control", "no-store")
        self.send_header("Content-Length", str(len(page)))
        self.end_headers()
        self.wfile.write(page)

    def log_message(self, format, *args):
        pass


def api(method, path, payload=None):
    """Swagger > Authorize uses the same key, in the X-API-Key header."""
    url = path if path.startswith("https://") else HUB_URL + path
    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",
    })
    with urlopen(request, timeout=15) as response:
        return json.load(response)


def verified_identity(result, created):
    """Check the service's result, not the underlying document signatures."""
    if result.get("status") != "completed":
        raise ValueError("Verification has not completed")
    if (result.get("presentation_id") != created["presentation_id"]
            or result.get("verifier") != VERIFIER
            or result.get("presentation_configuration_id") != PRESENTATION_TEMPLATE):
        raise ValueError("Result does not match this verification")
    if result.get("continuation_status") not in (None, "confirmed"):
        raise ValueError("The application return has not been confirmed")
    summary = result.get("verification", {})
    if (summary.get("dcql") != "valid" or summary.get("holder_binding") != "valid"
            or summary.get("warnings")):
        raise ValueError("The service did not return all required verification checks")
    items = result.get("credentials", {}).get(RESULT_GROUP, [])
    if len(items) != 1:
        raise ValueError("Expected one identity result")
    credential = items[0]
    checks = credential.get("verification", {})
    if (checks.get("issuer_signature") != "valid" or checks.get("holder_binding") != "valid"
            or checks.get("issuer_trust") not in ALLOWED_ISSUER_TRUST
            or checks.get("warnings")):
        raise ValueError("The document issuer is not trusted for this integration")
    return credential["claims"][CLAIM_GROUP]


def verify():
    # Swagger: createPresentation. Ask the service to verify the user's identity.
    created = api("POST", "/api/v1/presentations", {
        "verifier": VERIFIER,
        "presentation_configuration_id": PRESENTATION_TEMPLATE,
        "expires_in": 300,
        "include_evidence": True,  # Request the sent request and received proof.
    })
    # Display the exact QR content returned by the API, without changing it.
    image = qrcode.make(created["qr_code_content"],
                        image_factory=qrcode.image.svg.SvgPathImage)
    show("Scan with your Wallet", image.to_string().decode("utf-8"), waiting=True)
    deadline = datetime.fromisoformat(created["expires_at"].replace("Z", "+00:00"))
    while datetime.now(timezone.utc) < deadline:
        # Swagger: getPresentation. Read the result at the API-provided URL.
        result = api("GET", created["status_uri"])
        if result["status"] in {"completed", "failed", "expired"}:
            break
        time.sleep(2)
    else:
        result = {"status": "expired"}

    # Only use/display identity as verified after checking the saved session and policy.
    # Display the complete polling response, including verification details and
    # evidence when returned. Keep untrusted/unconfirmed data clearly labelled.
    title = "Verification ended"
    notice = ""
    if result["status"] == "completed":
        try:
            verified_identity(result, created)
            title = "Verified identity — full API response"
        except (ValueError, KeyError, TypeError, AttributeError):
            title = "API response — identity not accepted"
            notice = "<p>This result is unconfirmed, incomplete or not trusted. Do not use it as verified identity.</p>"
    show(title, notice + "<pre>" + escape(json.dumps(result, ensure_ascii=False, indent=2)) + "</pre>")
    print("Result available in your browser. Press Ctrl+C to stop.")


def main():
    global API_KEY
    if not API_KEY:
        API_KEY = getpass.getpass("API key (hidden input): ").strip()
    if not API_KEY:
        raise SystemExit("An API key is required.")
    try:
        server = ThreadingHTTPServer((LOCAL_HOST, LOCAL_PORT), LocalPage)
    except OSError as error:
        raise SystemExit(
            f"Cannot open port {LOCAL_PORT}. Stop the previous local server, then run this program again."
        ) from error
    show("Preparing identity verification...", waiting=True)
    Thread(target=server.serve_forever, daemon=True).start()
    print(f"Open http://{LOCAL_HOST}:{LOCAL_PORT} - press Ctrl+C to stop.", flush=True)
    try:
        try:
            verify()
        except (HTTPError, URLError, ValueError, KeyError) as error:
            # Stop refreshing and remove the QR when the API request fails.
            message = f"API error: HTTP {error.code}" if isinstance(error, HTTPError) else "The result is unavailable, unconfirmed or not trusted. Check your configured service and policy."
            show("Verification interrupted", "<p>" + escape(message) + "</p>")
            print(message + " Check your settings and run this program again.")
        Event().wait()  # Keep the result accessible until Ctrl+C.
    except KeyboardInterrupt:
        print("\nLocal server stopped.")
    finally:
        server.shutdown()
        server.server_close()


if __name__ == "__main__":
    main()

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 Talao service 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 Talao service.
Credential Dataset One Application-supplied set of business claims to be issued as an attestation under a Credential Configuration.
Verifier A configured Talao 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. Talao 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 Talao service does not authenticate the user for this workflow.

Application -> Talao service: create issuance
Talao service -> Application: QR content
Application -> User: display QR
Talao service <-> Wallet: deliver attestation
Application -> Talao service: 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 Talao service. 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 Talao service 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.

For immediate issuance, a Wallet can also obtain further instances of one authorized dataset using the same valid Access Token. Every request validates fresh proofs and nonces, and retains the token's configuration/dataset and Wallet bindings. This does not require another Application API creation or authorization. Token and session expiry still apply; deferred issuance retains its existing transaction lifecycle.

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 digital identity document or another attestation

Use POST /api/v1/presentations when the Application needs verified claims without issuing an attestation. The primary documented journey is digital identity verification with the authorized service and template used in the Quick Start. 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 processing is completed, apply the result checks and any required confirmation before using the normalized information.

The Talao service returns normalized verified information and, when explicitly requested with include_evidence: true, the validated VP Token. 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 Talao service terminal result page. Cross-device applications continue polling normally.

Verify a digital identity document before issuing an attestation

Use this workflow when verified digital identity information must inform the application's decision to issue an attestation.

Application -> Talao service -> Wallet
                    <- verified presentation
Application <- normalized verified claims
Application -> Talao service: final attestation datasets or rejection
Talao service -> 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 Talao service never derives it from the digital identity. The finalization operation defines the exact request and state rules.

API operations:

Wallet-initiated issuance with presentation-backed authorization

An Issuer can allow Credential Offers, direct Wallet starts, or both. When it allows both with Authorization Code, the Application explicitly selects the operation on POST /api/v1/issuances: initiation_mode=issuer_initiated creates an individual offered issuance; initiation_mode=wallet_initiated prepares the permanent direct-start workflow. Preparing the workflow does not create an offer, and creating an offer does not replace the permanent workflow. Each direct Wallet start has its own expiring session and Application decision. The existing ownership, polling and finalization operations apply to both. For Issuers allowing just one start, existing requests omit this selector.

Use this workflow when the Wallet starts issuance from the Issuer URL and the Application requires a verified presentation before deciding whether to issue an attestation. The Issuer must allow wallet_initiated (either that mode or both); the API Client must be associated with both the Issuer and the selected Verifier.

The Issuer has a permanent configuration that defines the attestations that may be issued and the authorization mode. For openid4vp, it also defines the presentation required for authorization. Wallets can start independently from the same Issuer URL at any time while the Issuer is enabled. Each request creates an individual, expiring issuance session and, for openid4vp, its own presentation request. The configuration itself does not expire. The Application supplies the data for each issuance after successful presentation verification. The configuration itself contains no issuance data.

Application -> Talao service: configure permanent Wallet-initiated authorization
Talao service -> Application: Issuer URL and Wallet session listing URL
Wallet -> Talao service: request issuance from the Issuer URL
Talao service -> Wallet: request the configured presentation
Wallet -> Talao service: present the required attestation
Talao service -> Application: verified information for this issuance session
Application -> Talao service: approve with issuance data, or reject
Talao service -> Wallet: continue authorization and deliver the attestation

The Talao service validates the presentation and makes the normalized verified information available to the Application. The Application uses that information to identify the user when required, check eligibility and select the data to issue. The service does not automatically turn presented claims into issuance data. If the Application approves, the service resumes the Wallet's Authorization Code flow and delivers the requested attestation. If it rejects, the authorization ends with an access-denied response.

  1. Configure the workflow with POST /api/v1/issuances, selecting initiation_mode=wallet_initiated when the Issuer allows both, grant_type=authorization_code, authorization.mode=openid4vp, the Presentation Configuration and the Credential Configuration(s) to issue. Omit issuance claims and tx_code. expires_in sets the lifetime of each future issuance session; it does not expire the configuration.
  2. Store the returned issuer and wallet_sessions_uri, and provide issuer_url to Wallet users. This response has no issuance_id, Credential Offer or QR: configuring the workflow does not create an issuance session.
  3. Discover Wallet sessions through the returned wallet_sessions_uri, namely GET /api/v1/issuances/{issuer_slug}/wallet-sessions. Each has a distinct issuance_id, even when several Wallets request the same scope.
  4. For each session with authorization.status=awaiting_application, read the normalized verified information and make the Application's business decision.
  5. Call POST /api/v1/issuances/{issuance_id}/authorization/finalize with outcome=ready, that session's item_id values and the data to issue, or outcome=rejected. Poll the individual session until issuance completes.

The configuration remains available while the Issuer is enabled. Only the individual issuance sessions and their temporary protocol artifacts expire. Presentation results, application decisions and issuance data remain separate for each Wallet session.

See Wallet-initiated creation, Wallet-initiated create response, List wallet-initiated issuance sessions and Finalize after verification for the API contracts.

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 Talao service. It is not a Wallet protocol identifier or a Status List coordinate. 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

Mark an attestation as revoked (invalid):

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 Talao service 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 Talao service automatically checks the attestation status during verification. An attestation whose value is invalid or suspended does not produce a successful verification result.

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.

Request an API key to use these endpoints.

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 Talao service. Rulebooks are managed by the Talao service administrator and are read-only resources for API clients.

Rulebooks and their associated Credential Types are managed by the Talao service 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

This employee badge request matches the complete programs in chapter 6. Your application supplies approved employee data and selects the configured issuing service and badge model. Keep the fixed API values as written.

{
  "issuer": "haip-employee-issuer",
  "grant_type": "urn:ietf:params:oauth:grant-type:pre-authorized_code",
  "initiation_mode": "issuer_initiated",
  "expires_in": 300,
  "credentials": [
    {
      "credential_configuration_id": "employee_mdoc",
      "claims": {
        "given_name": "Jane",
        "family_name": "Doe",
        "employee_id": "EMP-0001",
        "job_title": "Software Engineer",
        "department": "Engineering",
        "employer_name": "Example Company",
        "issuing_authority": "Example Company HR Department"
      }
    }
  ]
}

Digital identity 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": "10 rue de la Paix",
        "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 illustrate a specific configured field mapping, not every mandatory/optional digital identity attribute.

Verify before issue

This example requests a digital identity document 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 digital identity 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.

Identity-to-residence is an example, not a hard-coded mapping. The same mode can use any supported Presentation Configuration and any Issuer Credential Configuration. The Talao service 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.

Wallet-initiated creation

For an Issuer configured as wallet_initiated or both, the same creation endpoint requires an explicit grant_type=authorization_code and authorization strategy. For an Issuer allowing only one initiation mode, omit initiation_mode; the existing default behavior is unchanged. For an Issuer configured as both, initiation_mode is required for Authorization Code creation: wallet_initiated prepares direct authorization, while issuer_initiated creates an individual issuance with a Credential Offer. both is an Issuer setting, not an accepted request value. Requesting a mode disabled on the Issuer returns invalid_request. For Pre-Authorized Code, an omitted initiation_mode defaults to issuer_initiated on a both Issuer, including when grant_type is omitted and uses its Pre-Authorized Code default. This never enables a start mode disabled on the Issuer; Wallet-initiated issuance requires Authorization Code. An active managed API Client associated with the Issuer is required; the legacy master key cannot prepare this flow.

With authorization.mode=openid4vp, creation saves a permanent authorization configuration. expires_in defines the lifetime of each future issuance session. The API Client must also be associated with the selected Verifier. Omit claims and tx_code:

{
  "issuer": "haip-wallet-por",
  "grant_type": "authorization_code",
  "authorization": {
    "mode": "openid4vp",
    "verifier": "haip-verifier-2",
    "presentation_configuration_id": "pid-sd-jwt"
  },
  "credentials": [
    {"credential_configuration_id": "proof_of_residence_sd_jwt_v2"}
  ],
  "expires_in": 1800
}

Each valid Wallet request creates an independent issuance owned by this API Client. Configured scopes must route unambiguously to an Issuer on the Authorization Server. Concurrent Wallet sessions are independent. Updating the configuration does not create a parent issuance session or an initiation ID.

See the Wallet-initiated workflow and response fields.

For a both Issuer, add "initiation_mode": "wallet_initiated" to the example above. To create an offered issuance for that same Issuer, use "initiation_mode": "issuer_initiated" with an existing offered-issuance request. The selector is independent of grant_type and authorization.mode. The direct operation requires Authorization Code and an active managed API Client. Issuer configuration both never appears as a session initiation value.

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 Talao service handles Wallet-side delivery independently. Dataset values and internal delivery identifiers are never exposed through Application API polling.

Top-level fields:

Field Type Required Description
issuer string yes Enabled Issuer slug configured in the Talao service.
credentials array yes Non-empty array of credential requests.
initiation_mode string conditional Required for Authorization Code with an Issuer configured as both. For Pre-Authorized Code on a both Issuer, defaults to issuer_initiated. issuer_initiated creates an offer; wallet_initiated prepares direct authorization. Single-mode Issuers retain their configured default.
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":"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 standard Pre-Authorized Code issuance. Omit it for openid4vp, then provide each session dataset through authorization finalization 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 Talao service applies the configured format, signing policy, holder binding and interoperability profile. The Application does not select signing or Wallet proof parameters.

For an mdoc configuration, supply binary claims such as portrait as base64 strings (standard or URL-safe), or as base64 data URIs. Supply dates as YYYY-MM-DD and date-times with a timezone, for example 2025-01-01T00:00:00Z. For mDL, driving_privileges is an array of objects with vehicle_category_code and optional issue_date / expiry_date dates.

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-11-13T10:00:00Z",
  "expires_at": "2026-11-13T10:05:00Z",
  "credentials": [
    {
      "item_id": 42,
      "credential_configuration_id": "employee_mdoc",
      "status": "pending",
      "issuance_mode": "immediate",
      "deferred_status": null,
      "issued_at": null,
      "notification": null
    }
  ],
  "credential_offer_uri": "https://openid4vc-hub.com/issuer/haip-employee-issuer/credential-offer/example",
  "qr_code_content": "openid-credential-offer://?credential_offer_uri=https%3A%2F%2Fopenid4vc-hub.com%2Fissuer%2Fhaip-employee-issuer%2Fcredential-offer%2Fexample"
}

id and issuance_id currently contain the same UUID. New integrations should use issuance_id as the Application API identifier.

For issuer-initiated creation, 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.

Wallet-initiated create response

For a Wallet-initiated Issuer, the 201 response returns issuer_url rather than credential_offer_uri or qr_code_content. In OpenID4VP mode, it describes the permanent configuration and has no issuance_id or expires_at:

{
  "issuer": "haip-wallet-por",
  "initiation_mode": "wallet_initiated",
  "issuer_url": "https://hub.example/issuer/haip-wallet-por",
  "status": "configured",
  "session_expires_in": 1800,
  "credentials": [
    {"credential_configuration_id": "proof_of_residence_sd_jwt_v2"}
  ],
  "authorization": {
    "mode": "openid4vp",
    "verifier": "haip-verifier-2",
    "presentation_configuration_id": "pid-sd-jwt"
  },
  "wallet_sessions_uri": "/api/v1/issuances/haip-wallet-por/wallet-sessions"
}
Field Meaning
issuer Issuer slug selected at configuration.
initiation_mode wallet_initiated, identifying the selected direct-start workflow (including an Issuer configured as both).
issuer_url Stable Issuer URL to supply to the Wallet.
session_expires_in Lifetime in seconds of each future issuance session.
wallet_sessions_uri Relative owner-only URL for listing independent Wallet sessions.

Get an issuance

completed means every item has been issued at least once; it does not revoke the Wallet's Access Token. For immediate items, further valid Wallet requests can issue new instances until token or session expiry. issued_at remains the first successful issuance time. Each new status-enabled instance adds its own status_credentials entry, independently controllable through the status API. No new Application API endpoint or request property is required.

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": "completed",
  "created_at": "2026-11-13T10:00:00Z",
  "expires_at": "2026-11-13T10:05:00Z",
  "credentials": [
    {
      "item_id": 42,
      "credential_configuration_id": "employee_mdoc",
      "status": "issued",
      "issuance_mode": "immediate",
      "deferred_status": null,
      "issued_at": "2026-11-13T10:01:00Z",
      "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 Identity 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.

List wallet-initiated issuance sessions

GET /api/v1/issuances/{issuer_slug}/wallet-sessions
X-API-Key: hub_live_<secret>
Accept: application/json

Lists independent Wallet issuances created using the permanent OpenID4VP configuration using openid4vp. This operation requires the owning managed API Client. Individual session expiry does not expire the configuration.

Parameter Location Required Description
issuer_slug path yes Issuer slug returned in the configuration response.
after query no Opaque next_cursor returned by the previous page. Omit for the first page.

Success: 200 OK with the following structure:

{
  "issuances": [],
  "next_cursor": null
}

issuances contains at most 50 documents using the Get an issuance representation. Each document has its own issuance_id and item identifiers, and includes normalized presentation results only after successful verification. The array is initially empty until a Wallet starts. It never exposes issuance datasets, raw credentials or proofs.

When next_cursor is non-null, fetch the next page with ?after=<next_cursor>. To discover new Wallet sessions on the next polling cycle, restart at the first page; a cursor is for pagination, not an incremental event stream.

HTTP Error Meaning
401 missing_api_key / invalid_api_key Authentication failed.
403 forbidden Caller is not the owning managed API Client.
404 issuer_not_found Unknown Issuer slug.
409 invalid_authorization_mode Issuer is not configured as Wallet-initiated.

Each session receives its own lifetime when the Wallet starts. Expiry of one session does not prevent another Wallet from starting. Authorization decisions, datasets and Wallet state values remain independent between sessions.

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. Expiration is reflected when you read the session; 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 The Application must supply or reject issuance data; openid4vp additionally requires successful verification.
ready_for_authorization Final issuance data is frozen and the Talao service can continue delivery.
ready_for_consent Older sessions can report this transitional value; wait for ready_for_authorization before continuing.
authorized The Talao service 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 supports openid4vp and reuses issuance ownership checks. Use each individual session's issuance_id and its item_id values returned by listing or polling. A successfully verified presentation is required. The permanent Issuer configuration is not an issuance and cannot be finalized.

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 digital identity 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 Talao service 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 reject issuance (after evaluating the presentation when using openid4vp):

{"outcome": "rejected"}

This returns the updated issuance document with authorization.status=rejected. When the Wallet resumes the authorization interaction, the Talao service 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.

The ETSI (EUDI Wallet) issuance subset also supports digital identity mso_mdoc with docType eu.europa.ec.eudi.pid.1, using the configured credential configuration and a CB-AdES-B-B signature structure. No additional Application API parameter is needed. The Issuer must pass readiness, including its digital identity signing certificate under pid_provider_authority and its dedicated Document Signer certificate under document_issuer. Device-bound issuance still requires a validated Key Attestation. Other EUDI mdoc types remain unsupported; digital identity Pre-Authorized Code retains the physical-presence restriction described below. This support does not establish complete ETSI conformance or real-Wallet interoperability.

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 an issuance requiring initial claims.
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 unsupported. 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 digital identity Pre-Authorized Code was requested, but the Talao service 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 wallet_issuance_already_pending An active Wallet initiation already covers a requested scope on the AS; only the owner receives the existing issuance_id in details.
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 Talao service handles the Wallet request and cryptographic verification.

Identity verification request example
{
  "verifier": "haip-verifier-2",
  "presentation_configuration_id": "france-identite-pid-mdoc",
  "expires_in": 300,
  "include_evidence": true
}
Field Type Required Description
verifier string yes Enabled Verifier slug configured in the Talao service.
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.
include_evidence boolean no Defaults to false. When true, capture the request and return it with the validated response as evidence after successful verification.
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 digital identity journey uses haip-verifier-2 and france-identite-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 approval response binding is not yet validated.

Optional verification evidence

Set include_evidence: true in POST /api/v1/presentations to receive evidence.request and evidence.response.vp_token in the completed polling/confirmation result. Omitted or false means no evidence is stored in the session result or returned. Other types (including null, strings and numbers) return HTTP 400 invalid_include_evidence. The option is local to the session and is not sent to the Wallet or accepted as a polling override.

evidence.request contains the actual request sent to the Wallet, not the JSON body sent to POST /api/v1/presentations. For signed requests, format is jwt and content is the exact signed text most recently served to the Wallet, including any Wallet-provided nonce. If the Wallet retrieves the request more than once, this records the last retrieval; it does not identify which version the Wallet used. For requests conveyed directly in a URL, format is url and content is the exact Wallet invocation URL. No JWT is reconstructed.

evidence.response.vp_token is the received DCQL JSON object, keyed by credential query ID; its presentation strings/objects remain unchanged. With direct_post.jwt, this is the VP Token extracted after decryption, not the encrypted JWE or the full Wallet response. See OID4VP 1.0 Final §8.1. For example (presentation shortened):

{
  "evidence": {
    "request": {
      "format": "jwt",
      "content": "<exact-signed-request-sent-to-the-Wallet>"
    },
    "response": {
      "vp_token": {"pid_mdoc": ["<received-mdoc-presentation>"]}
    }
  }
}

The request is captured in the session when created (URL) or served (JWT). The response proof is retained only after all verification checks succeed. Failed, expired and pending results expose none. Ownership checks and optional same-browser confirmation gate evidence exactly like verified claims. For older sessions with no captured request, including historical results with only evidence.vp_token, this API omits evidence. Start a new session to obtain the complete pair; requests are never fabricated for historical sessions. Evidence contains sensitive credential material and is persisted with the existing presentation result; opting in adds no separate retention timer. Keep it out of logs and shared caches. This option is for standalone verification, not the verify-before-issue authorization contract.

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 service's response_uri.

Omitting the application redirect_uri does not mean the Wallet will never open a browser page. For standalone HAIP and EUDI Wallet presentations, the service still returns its own terminal-page URL after processing a valid Wallet response:

{
  "redirect_uri": "https://hub.example/verifier/haip-verifier-2/resume/<handle>?response_code=<fresh-code>"
}

This is the service's return URL, not an application callback. The page displays Success after successful verification, or Verification unsuccessful after a processed Wallet error such as cancellation. It tells the user to return to the originating application, or to the other device after a QR scan. It contains no Verifier name, administration navigation or credential claims. The URL is single-use and expires with the presentation; reusing it returns a protocol error. The HTML response uses Cache-Control: no-store and Referrer-Policy: no-referrer. Malformed protocol responses or rejected credentials remain protocol errors; they are not converted into a redirect to this page.

Opening this service page is not required to release the Application API result. Without an application redirect_uri, the application's backend can retrieve verified results by polling as soon as the presentation is completed, even if the user never opens the terminal page. No /confirm call is needed. Standalone CORE presentations do not have this default service redirect. Presentations linked to an issuance retain their issuance continuation rather than this terminal page.

For cross-device QR journeys, omit the application redirect_uri: the smartphone may show the service result page while the original browser obtains the result by polling. For same-device journeys, an application callback is optional; omitting it uses the same result-page and polling behavior for HAIP/EUDI Wallet. Supplying one requires the return in the original browser session. Being on the same device alone is insufficient if the Wallet returns in a different browser.

Example with an application callback (use your configured service identifiers):

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

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": "haip-verifier-2",
  "presentation_configuration_id": "france-identite-pid-mdoc",
  "created_at": "2026-11-13T10:00:00Z",
  "expires_at": "2026-11-13T10:05:00Z",
  "status_uri": "https://openid4vc-hub.com/api/v1/presentations/9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
  "wallet_invocation_uri": "openid4vp://authorize?client_id=example&request_uri=https%3A%2F%2Fopenid4vc-hub.com%2Fverifier%2Fhaip-verifier-2%2Frequest%2Fexample",
  "qr_code_content": "openid4vp://authorize?client_id=example&request_uri=https%3A%2F%2Fopenid4vc-hub.com%2Fverifier%2Fhaip-verifier-2%2Frequest%2Fexample"
}

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 is generated by the service according to the Verifier configuration and expires with the presentation session. The application must display or invoke it promptly, using the returned value unchanged.

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. The default HAIP/EUDI Wallet redirect to the service terminal page does not activate this application confirmation gate and does not add these fields.

While the Wallet has not responded:

{
  "presentation_id": "9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
  "status": "pending",
  "verifier": "haip-verifier-2",
  "presentation_configuration_id": "france-identite-pid-mdoc",
  "created_at": "2026-11-13T10:00:00Z",
  "expires_at": "2026-11-13T10:05:00Z",
  "status_uri": "https://openid4vc-hub.com/api/v1/presentations/9bb3eb73-6029-4ca0-aad8-cf193482eb4a"
}

After successful verification (without redirect_uri, or after its return has been confirmed), with include_evidence: true requested at creation:

{
  "presentation_id": "9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
  "status": "completed",
  "verifier": "haip-verifier-2",
  "presentation_configuration_id": "france-identite-pid-mdoc",
  "created_at": "2026-11-13T10:00:00Z",
  "expires_at": "2026-11-13T10:05:00Z",
  "status_uri": "https://openid4vc-hub.com/api/v1/presentations/9bb3eb73-6029-4ca0-aad8-cf193482eb4a",
  "credentials": {
    "pid_mdoc": [
      {
        "format": "mso_mdoc",
        "doctype": "eu.europa.ec.eudi.pid.1",
        "claims": {
          "eu.europa.ec.eudi.pid.1": {
            "given_name": "Jane",
            "family_name": "Doe",
            "birth_date": "1990-01-01",
            "birth_place": "PARIS"
          }
        },
        "verification": {
          "issuer_signature": "valid",
          "holder_binding": "valid",
          "digests": "valid",
          "validity": "valid",
          "device_authentication": "valid",
          "session_transcript": "valid",
          "issuer_trust": "x509_trust_anchor"
        }
      }
    ]
  },
  "verification": {
    "dcql": "valid",
    "holder_binding": "valid",
    "credential_count": 1,
    "status": "valid"
  },
  "evidence": {
    "request": {
      "format": "jwt",
      "content": "<exact-signed-request-sent-to-the-Wallet>"
    },
    "response": {
      "vp_token": {
        "pid_mdoc": [
          "<received-mdoc-presentation>"
        ]
      }
    }
  }
}

The Talao service 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. This example requested evidence, so its result also contains evidence.request and evidence.response.vp_token. The request and proof strings shown are illustrative placeholders, not usable signed artifacts. Without that option, the API returns no evidence field. The full Wallet response is not returned.

credential_status is present only when the credential contains a Status List reference. checked: true and value: "valid" mean that the Talao service fetched the list, validated its signature and freshness, and found neither revocation nor suspension at the credential index.

For a configured signature-approval presentation, the relevant attestation's verification.transaction_data is present only after successful approval validation. It contains status: "valid", the transaction type, the checked binding (csc_qes_approval or csc_qes_approval+transaction_data_hashes) and hash_algorithm: "sha-256". Missing or invalid approval produces a terminal failed presentation. The Application uses the existing creation and polling endpoints; it does not supply a Wallet proof or receive transaction hashes, raw transaction data or a signed document through this result.

For the ETSI static-trust profile, Presentation Configurations may select an Intended Use of category pid or non-qualified eaa, in dc+sd-jwt or mso_mdoc. EAA VCT/doctype values are not restricted to a built-in catalogue; the selected Intended Use must authorize the requested types and claims. EAA SD-JWT chains require eaa_provider_authority trust; mdoc chains require document_issuer. The supported EAA structural checks require SD-JWT nbf/exp and a well-formed signed vct#integrity reference, or the supported CB-AdES building blocks for mdoc. Type Metadata contents, business schemas, qualified status and Trusted Lists are outside this validation subset. EAA QES configurations reuse the CSC SHA-256 KB-JWT transaction binding; this validates approval, not a completed qualified document signature. Creation and polling contracts are unchanged. An unauthorized request fails before session creation; an invalid credential fails validation without a successful normalized credential result.

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 Verification processing is complete; use data only after the result checks and any required confirmation.
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. Reading an unfinished session after expires_at can return expired. Completion does not waive the trust and confirmation checks.

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 Talao service 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 errors are not part of the Application API error contract.

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 current implementation allows changing invalid back to valid; it does not enforce irreversible revocation. Enforce that business rule in the Application when required.

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.

Issuance timing

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. Returned Wallet invocation content may expire before the session (normally after at most 300 seconds). Applications should display it promptly; the session lifetime does not guarantee that an old QR remains usable for that entire period.

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

6. Complete examples

This chapter covers two independent tasks: verify a person's identity and issue an employee badge. A digital identity document proves who a person is. Each task is available in Python, Node.js and PHP. Your application sends JSON to the service, displays a QR code for the user to scan and reads the result.

Request an API key and the service identifiers for your integration. Each program starts with its parameters: the identity verification service (verifier) and the identity information to request (presentation_configuration_id), or the badge issuing service (issuer) and badge model (credential_configuration_id), plus the API key. In the Python verification example, set API_KEY or enter it at the hidden prompt. The other examples read HUB_API_KEY on the application server. The service URL is the constant HUB_URL = "https://openid4vc-hub.com" at the start of each file. The API key is never placed in the QR code or the browser page.

Task API calls Example identifiers Result
Verify a digital identity document POST /api/v1/presentations, then GET the returned status_uri haip-verifier-2 / france-identite-pid-mdoc Verified identity attributes and optional evidence
Issue an employee badge POST /api/v1/issuances, then GET /api/v1/issuances/{issuance_id} haip-employee-issuer / employee_mdoc Delivery status; the badge itself goes to the Wallet

The identifiers shown are examples. Replace them with the service and configuration identifiers supplied for your integration. Your API key must be authorized to use those services. The keys pid_mdoc and eu.europa.ec.eudi.pid.1 locate the identity data in this example's response; you do not need to interpret their names. Contact us for the response structure associated with your configuration. Before issuing a badge, your application checks who the employee is and whether they are entitled to receive it. It then supplies the approved employee data. The examples use fictitious names and company details. Keep the fixed values of grant_type and initiation_mode exactly as written: they select direct badge delivery. No additional API step is needed to approve delivery.

The examples display a QR code and then the result at http://127.0.0.1:8080. Create a dedicated example directory. Each example is a complete, independent source file containing its parameters, API calls, QR generation and status checks. Copy only the code block for the task and language you need; no shared application source file is required. Save the blocks under the filenames indicated and run one program at a time. The Python verification example in the Quick Start serves the page itself and keeps the result in memory until you stop it. The other examples write public/index.html for a local static server; this file contains personal data after verification, so remove it when finished. Your own application can render the same data in its existing interface.

Python setup

Use Python 3.12. Install the QR library with python -m pip install qrcode. The Identity verification file includes its own server; just run that file. For the badge issuance example only, create an empty public directory and start this static server in a separate terminal:

python -m http.server 8080 --bind 127.0.0.1 --directory public

Issue an employee badge (Python)

Save as issue_badge.py, then run python issue_badge.py. The data fields match the employee badge configuration; change them to approved employee data.

import json
import time
from datetime import datetime, timezone
from html import escape
from pathlib import Path
from urllib.request import Request, urlopen
import qrcode
import qrcode.image.svg
import os

# Parameters supplied for your integration. Keep the API key on the server.
API_KEY = os.environ.get("HUB_API_KEY", "")  # Same key as Swagger > Authorize.
ISSUER = "haip-employee-issuer"
ATTESTATION_TEMPLATE = "employee_mdoc"

HUB_URL = "https://openid4vc-hub.com"  # Fixed API origin.

def api(method, path, payload=None, *, api_key):
    # Same key as Swagger > Authorize. Keep it on the application server.
    if not api_key:
        raise RuntimeError("Set HUB_API_KEY before running the example")
    url = path if path.startswith("https://") else HUB_URL + path
    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",
    })
    with urlopen(request, timeout=15) as response:
        return json.load(response)


def show(content, waiting=False):
    # The local page refreshes while waiting and stops refreshing on completion.
    refresh = '<meta http-equiv="refresh" content="2">' if waiting else ""
    page = '<!doctype html><meta charset="utf-8">' + refresh + content
    Path("public").mkdir(exist_ok=True)
    Path("public/index.html").write_text(page, encoding="utf-8")


def follow(created, status_path, *, api_key):
    # qr_code_content is returned by the API: put it in the QR unchanged.
    image = qrcode.make(created["qr_code_content"],
                        image_factory=qrcode.image.svg.SvgPathImage)
    show("<h1>Scan with your Wallet</h1>" + image.to_string().decode(), waiting=True)
    print("Open http://127.0.0.1:8080 and scan with your Wallet")
    deadline = datetime.fromisoformat(created["expires_at"].replace("Z", "+00:00"))
    try:
        while datetime.now(timezone.utc) < deadline:
            # Swagger: getPresentation or getIssuance, depending on the example.
            result = api("GET", status_path, api_key=api_key)
            if result["status"] in {"completed", "failed", "expired"}:
                return result
            time.sleep(2)
        return {"status": "expired"}
    finally:
        show("<h1>Session ended</h1>")  # Remove the old QR, including on errors.

# Swagger: createIssuance. Send one badge and its approved employee data.
# Set the issuing service and badge model using the parameters above.
created = api("POST", "/api/v1/issuances", {'issuer': ISSUER,
 'grant_type': 'urn:ietf:params:oauth:grant-type:pre-authorized_code',
 'initiation_mode': 'issuer_initiated',
 'expires_in': 300,
 'credentials': [{'credential_configuration_id': ATTESTATION_TEMPLATE,
                  'claims': {'given_name': 'Jane',
                             'family_name': 'Doe',
                             'employee_id': 'EMP-0001',
                             'job_title': 'Software Engineer',
                             'department': 'Engineering',
                             'employer_name': 'Example Company',
                             'issuing_authority': 'Example Company HR Department'}}]}, api_key=API_KEY)
# The badge is delivered directly to the Wallet after the QR is scanned.
# Swagger: getIssuance. completed means delivery succeeded, not just QR creation.
result = follow(created, "/api/v1/issuances/" + created["issuance_id"], api_key=API_KEY)
show("<h1>Badge delivery result</h1><pre>" + escape(json.dumps(result, indent=2)) + "</pre>")

Node.js setup

Use Node.js 22 with its built-in fetch. Install the QR library with npm install qrcode. Serve the empty public directory on port 8080 using the Python static-server command above (or your existing local static server). Run the .mjs files on your application server with HUB_API_KEY set.

Identity verification (Node.js)

Save as verify-pid.mjs, then run node verify-pid.mjs.

import QRCode from "qrcode";
import { mkdir, writeFile } from "node:fs/promises";

// Parameters supplied for your integration. Keep the API key on the server.
const API_KEY = process.env.HUB_API_KEY || ""; // Same key as Swagger > Authorize.
const VERIFIER = "haip-verifier-2";
const PRESENTATION_TEMPLATE = "france-identite-pid-mdoc";
// Talao confirms these response keys and the accepted issuer-trust policy.
const RESULT_GROUP = "pid_mdoc";
const CLAIM_GROUP = "eu.europa.ec.eudi.pid.1";
const ALLOWED_ISSUER_TRUST = new Set(["x509_trust_anchor"]);

const HUB_URL = "https://openid4vc-hub.com"; // Fixed API origin.

async function api(method, path, payload, apiKey) {
  // Same key as Swagger > Authorize. Keep it on the application server.
  if (!apiKey) throw new Error("Set HUB_API_KEY before running the example");
  const response = await fetch(path.startsWith("https://") ? path : HUB_URL + path, {
    method,
    headers: { "X-API-Key": apiKey, Accept: "application/json", "Content-Type": "application/json" },
    body: payload === undefined ? undefined : JSON.stringify(payload),
    signal: AbortSignal.timeout(15_000),
  });
  if (!response.ok) throw new Error(`API returned HTTP ${response.status}`);
  return response.json();
}

const escapeHtml = (text) => text.replaceAll("&", "&amp;")
  .replaceAll("<", "&lt;").replaceAll(">", "&gt;");

async function show(content, waiting = false) {
  const refresh = waiting ? '<meta http-equiv="refresh" content="2">' : "";
  await mkdir("public", { recursive: true });
  await writeFile("public/index.html", '<!doctype html><meta charset="utf-8">' + refresh + content);
}

async function follow(created, statusPath, apiKey) {
  // Put the exact API-provided qr_code_content in the QR code.
  const svg = await QRCode.toString(created.qr_code_content, { type: "svg" });
  await show("<h1>Scan with your Wallet</h1>" + svg, true);
  console.log("Open http://127.0.0.1:8080 and scan with your Wallet");
  try {
    while (Date.now() < Date.parse(created.expires_at)) {
      // Swagger: getPresentation or getIssuance, depending on the example.
      const result = await api("GET", statusPath, undefined, apiKey);
      if (["completed", "failed", "expired"].includes(result.status)) return result;
      await new Promise((resolve) => setTimeout(resolve, 2_000));
    }
    return { status: "expired" };
  } finally {
    await show("<h1>Session ended</h1>"); // Remove old QR, including on errors.
  }
}

function verifiedIdentity(result, created) {
  // Check the service's result; do not reimplement document signature verification.
  if (result.status !== "completed" || result.presentation_id !== created.presentation_id
      || result.verifier !== VERIFIER || result.presentation_configuration_id !== PRESENTATION_TEMPLATE) {
    throw new Error("Result does not match this verification");
  }
  if (result.continuation_status !== undefined && result.continuation_status !== "confirmed") {
    throw new Error("The application return has not been confirmed");
  }
  const summary = result.verification || {};
  if (summary.dcql !== "valid" || summary.holder_binding !== "valid" || summary.warnings?.length) {
    throw new Error("Required verification checks are missing");
  }
  const items = result.credentials?.[RESULT_GROUP] || [];
  if (items.length !== 1) throw new Error("Expected one identity result");
  const checks = items[0].verification || {};
  if (checks.issuer_signature !== "valid" || checks.holder_binding !== "valid"
      || !ALLOWED_ISSUER_TRUST.has(checks.issuer_trust) || checks.warnings?.length) {
    throw new Error("The document issuer is not trusted for this integration");
  }
  return items[0].claims[CLAIM_GROUP];
}

// Swagger: createPresentation. Select the Identity verification service provided for your integration.
const created = await api("POST", "/api/v1/presentations", {
  "verifier": VERIFIER,
  "presentation_configuration_id": PRESENTATION_TEMPLATE,
  "expires_in": 300,
  "include_evidence": true // Request the sent request and received proof.
}, API_KEY);
// Use the returned result URL. No callback or confirmation call is needed here.
const result = await follow(created, created.status_uri, API_KEY);
// Display the complete API result for local inspection, without trusting it blindly.
let title = "Verification ended";
let notice = "";
if (result.status === "completed") {
  try {
    verifiedIdentity(result, created);
    title = "Verified identity — full API response";
  } catch (error) {
    title = "API response — identity not accepted";
    notice = "<p>This result is unconfirmed, incomplete or not trusted. Do not use it as verified identity.</p>";
  }
}
await show("<h1>" + title + "</h1>" + notice + "<pre>"
  + escapeHtml(JSON.stringify(result, null, 2)) + "</pre>");

Issue an employee badge (Node.js)

Save as issue-badge.mjs, then run node issue-badge.mjs.

import QRCode from "qrcode";
import { mkdir, writeFile } from "node:fs/promises";

// Parameters supplied for your integration. Keep the API key on the server.
const API_KEY = process.env.HUB_API_KEY || ""; // Same key as Swagger > Authorize.
const ISSUER = "haip-employee-issuer";
const ATTESTATION_TEMPLATE = "employee_mdoc";

const HUB_URL = "https://openid4vc-hub.com"; // Fixed API origin.

async function api(method, path, payload, apiKey) {
  // Same key as Swagger > Authorize. Keep it on the application server.
  if (!apiKey) throw new Error("Set HUB_API_KEY before running the example");
  const response = await fetch(path.startsWith("https://") ? path : HUB_URL + path, {
    method,
    headers: { "X-API-Key": apiKey, Accept: "application/json", "Content-Type": "application/json" },
    body: payload === undefined ? undefined : JSON.stringify(payload),
    signal: AbortSignal.timeout(15_000),
  });
  if (!response.ok) throw new Error(`API returned HTTP ${response.status}`);
  return response.json();
}

const escapeHtml = (text) => text.replaceAll("&", "&amp;")
  .replaceAll("<", "&lt;").replaceAll(">", "&gt;");

async function show(content, waiting = false) {
  const refresh = waiting ? '<meta http-equiv="refresh" content="2">' : "";
  await mkdir("public", { recursive: true });
  await writeFile("public/index.html", '<!doctype html><meta charset="utf-8">' + refresh + content);
}

async function follow(created, statusPath, apiKey) {
  // Put the exact API-provided qr_code_content in the QR code.
  const svg = await QRCode.toString(created.qr_code_content, { type: "svg" });
  await show("<h1>Scan with your Wallet</h1>" + svg, true);
  console.log("Open http://127.0.0.1:8080 and scan with your Wallet");
  try {
    while (Date.now() < Date.parse(created.expires_at)) {
      // Swagger: getPresentation or getIssuance, depending on the example.
      const result = await api("GET", statusPath, undefined, apiKey);
      if (["completed", "failed", "expired"].includes(result.status)) return result;
      await new Promise((resolve) => setTimeout(resolve, 2_000));
    }
    return { status: "expired" };
  } finally {
    await show("<h1>Session ended</h1>"); // Remove old QR, including on errors.
  }
}

// Swagger: createIssuance. Send one badge and its approved employee data.
// Set the issuing service and badge model using the parameters above.
const created = await api("POST", "/api/v1/issuances", {
  "issuer": ISSUER,
  "grant_type": "urn:ietf:params:oauth:grant-type:pre-authorized_code",
  "initiation_mode": "issuer_initiated",
  "expires_in": 300,
  "credentials": [
    {
      "credential_configuration_id": ATTESTATION_TEMPLATE,
      "claims": {
        "given_name": "Jane",
        "family_name": "Doe",
        "employee_id": "EMP-0001",
        "job_title": "Software Engineer",
        "department": "Engineering",
        "employer_name": "Example Company",
        "issuing_authority": "Example Company HR Department"
      }
    }
  ]
}, API_KEY);
// The badge goes directly to the Wallet after scanning the QR code.
// Swagger: getIssuance. completed means delivery succeeded.
const result = await follow(created, "/api/v1/issuances/" + created.issuance_id, API_KEY);
await show("<h1>Badge delivery result</h1><pre>" + escapeHtml(JSON.stringify(result, null, 2)) + "</pre>");

PHP setup

Use PHP 8.2 or later with the cURL extension and Composer. Install the QR library with composer require endroid/qr-code:6.0.9. SVG output does not require GD. Create an empty public directory, then in a separate terminal run php -S 127.0.0.1:8080 -t public. The API examples below run in a second terminal as command-line programs; only the generated page is served.

Identity verification (PHP)

Save as verify-pid.php, then run php verify-pid.php.

<?php
// Parameters supplied for your integration. Keep the API key on the server.
$apiKey = getenv('HUB_API_KEY') ?: ''; // Same key as Swagger > Authorize.
$verifier = 'haip-verifier-2';
$presentationTemplate = 'france-identite-pid-mdoc';
// Talao confirms these response keys and the accepted issuer-trust policy.
$resultGroup = 'pid_mdoc';
$claimGroup = 'eu.europa.ec.eudi.pid.1';
$allowedIssuerTrust = ['x509_trust_anchor'];

const HUB_URL = 'https://openid4vc-hub.com'; // Fixed API origin.

require __DIR__ . '/vendor/autoload.php';

use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\SvgWriter;



function api(string $method, string $path, ?array $payload, string $apiKey): array {
    // Same key as Swagger > Authorize. Keep it on the application server.
    if (!$apiKey) throw new RuntimeException('Set HUB_API_KEY before running the example');
    $url = str_starts_with($path, 'https://') ? $path : HUB_URL . $path;
    $curl = curl_init($url);
    curl_setopt_array($curl, [
        CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => ['X-API-Key: ' . $apiKey, 'Accept: application/json', 'Content-Type: application/json'],
    ]);
    if ($payload !== null) curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload, JSON_THROW_ON_ERROR));
    $body = curl_exec($curl);
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    if ($body === false || $status < 200 || $status >= 300) {
        throw new RuntimeException('API request failed; HTTP ' . $status);
    }
    return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
}

function show(string $content, bool $waiting = false): void {
    if (!is_dir('public')) mkdir('public', 0700);
    $refresh = $waiting ? '<meta http-equiv="refresh" content="2">' : '';
    file_put_contents('public/index.html', '<!doctype html><meta charset="utf-8">' . $refresh . $content);
}

function jsonHtml(array $data): string {
    // Escape returned data before inserting it into HTML.
    return htmlspecialchars(json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function follow(array $created, string $statusPath, string $apiKey): array {
    // Put the exact API-provided qr_code_content in the QR code.
    $svg = (new SvgWriter())->write(new QrCode(data: $created['qr_code_content']))->getString();
    show('<h1>Scan with your Wallet</h1>' . $svg, true);
    echo "Open http://127.0.0.1:8080 and scan with your Wallet\n";
    try {
        while (time() < strtotime($created['expires_at'])) {
            // Swagger: getPresentation or getIssuance, depending on the example.
            $result = api('GET', $statusPath, null, $apiKey);
            if (in_array($result['status'], ['completed', 'failed', 'expired'], true)) return $result;
            sleep(2);
        }
        return ['status' => 'expired'];
    } finally {
        show('<h1>Session ended</h1>'); // Remove old QR, including on errors.
    }
}

function verifiedIdentity(array $result, array $created): array {
    global $verifier, $presentationTemplate, $resultGroup, $claimGroup, $allowedIssuerTrust;
    // Check the service's result; the service already verifies document signatures.
    if (($result['status'] ?? null) !== 'completed'
        || ($result['presentation_id'] ?? null) !== $created['presentation_id']
        || ($result['verifier'] ?? null) !== $verifier
        || ($result['presentation_configuration_id'] ?? null) !== $presentationTemplate) {
        throw new RuntimeException('Result does not match this verification');
    }
    if (isset($result['continuation_status']) && $result['continuation_status'] !== 'confirmed') {
        throw new RuntimeException('The application return has not been confirmed');
    }
    $summary = $result['verification'] ?? [];
    if (($summary['dcql'] ?? null) !== 'valid' || ($summary['holder_binding'] ?? null) !== 'valid'
        || !empty($summary['warnings'])) throw new RuntimeException('Required verification checks are missing');
    $items = $result['credentials'][$resultGroup] ?? [];
    if (count($items) !== 1) throw new RuntimeException('Expected one identity result');
    $checks = $items[0]['verification'] ?? [];
    if (($checks['issuer_signature'] ?? null) !== 'valid' || ($checks['holder_binding'] ?? null) !== 'valid'
        || !in_array($checks['issuer_trust'] ?? null, $allowedIssuerTrust, true)
        || !empty($checks['warnings'])) throw new RuntimeException('The document issuer is not trusted for this integration');
    return $items[0]['claims'][$claimGroup];
}

// Swagger: createPresentation. Select the Identity verification service provided for your integration.
$created = api('POST', '/api/v1/presentations', [
    'verifier' => $verifier,
    'presentation_configuration_id' => $presentationTemplate,
    'expires_in' => 300,
    'include_evidence' => true // Request the sent request and received proof.
], $apiKey);
// Use the returned result URL. No callback or confirmation call is needed here.
$result = follow($created, $created['status_uri'], $apiKey);
// Display the complete API result for local inspection, without trusting it blindly.
$title = 'Verification ended';
$notice = '';
if ($result['status'] === 'completed') {
    try {
        verifiedIdentity($result, $created);
        $title = 'Verified identity — full API response';
    } catch (Throwable $error) {
        $title = 'API response — identity not accepted';
        $notice = '<p>This result is unconfirmed, incomplete or not trusted. Do not use it as verified identity.</p>';
    }
}
show('<h1>' . $title . '</h1>' . $notice . '<pre>' . jsonHtml($result) . '</pre>');

Issue an employee badge (PHP)

Save as issue-badge.php, then run php issue-badge.php.

<?php
// Parameters supplied for your integration. Keep the API key on the server.
$apiKey = getenv('HUB_API_KEY') ?: ''; // Same key as Swagger > Authorize.
$issuer = 'haip-employee-issuer';
$attestationTemplate = 'employee_mdoc';

const HUB_URL = 'https://openid4vc-hub.com'; // Fixed API origin.

require __DIR__ . '/vendor/autoload.php';

use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\SvgWriter;



function api(string $method, string $path, ?array $payload, string $apiKey): array {
    // Same key as Swagger > Authorize. Keep it on the application server.
    if (!$apiKey) throw new RuntimeException('Set HUB_API_KEY before running the example');
    $url = str_starts_with($path, 'https://') ? $path : HUB_URL . $path;
    $curl = curl_init($url);
    curl_setopt_array($curl, [
        CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => ['X-API-Key: ' . $apiKey, 'Accept: application/json', 'Content-Type: application/json'],
    ]);
    if ($payload !== null) curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload, JSON_THROW_ON_ERROR));
    $body = curl_exec($curl);
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    if ($body === false || $status < 200 || $status >= 300) {
        throw new RuntimeException('API request failed; HTTP ' . $status);
    }
    return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
}

function show(string $content, bool $waiting = false): void {
    if (!is_dir('public')) mkdir('public', 0700);
    $refresh = $waiting ? '<meta http-equiv="refresh" content="2">' : '';
    file_put_contents('public/index.html', '<!doctype html><meta charset="utf-8">' . $refresh . $content);
}

function jsonHtml(array $data): string {
    // Escape returned data before inserting it into HTML.
    return htmlspecialchars(json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function follow(array $created, string $statusPath, string $apiKey): array {
    // Put the exact API-provided qr_code_content in the QR code.
    $svg = (new SvgWriter())->write(new QrCode(data: $created['qr_code_content']))->getString();
    show('<h1>Scan with your Wallet</h1>' . $svg, true);
    echo "Open http://127.0.0.1:8080 and scan with your Wallet\n";
    try {
        while (time() < strtotime($created['expires_at'])) {
            // Swagger: getPresentation or getIssuance, depending on the example.
            $result = api('GET', $statusPath, null, $apiKey);
            if (in_array($result['status'], ['completed', 'failed', 'expired'], true)) return $result;
            sleep(2);
        }
        return ['status' => 'expired'];
    } finally {
        show('<h1>Session ended</h1>'); // Remove old QR, including on errors.
    }
}

// Swagger: createIssuance. Send one badge and its approved employee data.
// Set the issuing service and badge model using the parameters above.
$created = api('POST', '/api/v1/issuances', [
    'issuer' => $issuer,
    'grant_type' => 'urn:ietf:params:oauth:grant-type:pre-authorized_code',
    'initiation_mode' => 'issuer_initiated',
    'expires_in' => 300,
    'credentials' => [[
        'credential_configuration_id' => $attestationTemplate,
        'claims' => [
            'given_name' => 'Jane',
            'family_name' => 'Doe',
            'employee_id' => 'EMP-0001',
            'job_title' => 'Software Engineer',
            'department' => 'Engineering',
            'employer_name' => 'Example Company',
            'issuing_authority' => 'Example Company HR Department'
        ]
    ]]
], $apiKey);
// The badge goes directly to the Wallet after scanning the QR code.
// Swagger: getIssuance. completed means delivery succeeded.
$result = follow($created, '/api/v1/issuances/' . $created['issuance_id'], $apiKey);
show('<h1>Badge delivery result</h1><pre>' . jsonHtml($result) . '</pre>');

For verification, completed means the identity has been verified. Use the identity object for application decisions. Optional evidence contains the sent request and received proof; it can be retained when your application needs it, without interpreting it to read the identity attributes.

For issuance, completed means the badge was issued to the Wallet. The API returns delivery status, not the badge or the original employee dataset. failed and expired are terminal in both tasks; the examples display those results rather than continuing to wait. Network/HTTP errors during result polling stop the program; the program removes the old QR page. To start again, rerun the program.

Inspecting evidence in the examples

The three verification programs request include_evidence: true and display the complete polling response, including credentials, verification, the session references and evidence when returned. Expand evidence.request to inspect the captured request and evidence.response.vp_token for the received proof. These are sensitive technical records, not the readable identity attributes.

The API default remains false. Omit the option if your integration needs only readable attributes. If you enable it, start a new verification session: an existing session without that option does not acquire evidence retrospectively. The field is available only after successful verification, request capture and any required confirmation. Its absence does not prevent the examples displaying the remaining response.

For inspection, the local page can also display a completed response that fails the checks, but labels it identity not accepted. Displaying it for debugging is not authorization to use it as trusted identity. These pages are local demonstrations, not production authentication interfaces. See the evidence contract and data protection chapter.

7. API limits and behavior

This section explains what your application must send, when it can use a result and how to handle incomplete or failed requests. It applies to the API calls and examples described in the preceding chapters.

Data you send

Some attestation models have additional readiness requirements. For example, an email attestation requires a non-empty email and its configured subject identifier. Unsupported or unready issuance configurations can return 400 issuer_not_ready before a session is created.

If you verify identity before issuing an attestation, contact us to confirm that your verification and issuing services can be used together. The service does not automatically convert verified identity data into badge data: your application supplies the approved dataset at the finalization step.

Session lifetime and status checks

Task Default session lifetime Allowed expires_in
Verify identity or another attestation 300 seconds Integer from 60 to 900 seconds
Issue an attestation 600 seconds Integer from 60 to 3600 seconds

Use the expires_at returned by the API to stop waiting. QR content can expire before the session itself, so display it promptly; increasing expires_in does not guarantee that an old QR remains usable for that whole period.

Keep the session identifier returned at creation. To read the result, use the returned status_uri for verification or the issuance status endpoint with issuance_id. Send your API key on every request. The examples check status every two seconds instead of creating another session.

A completed or failed session does not turn into expired merely because its original deadline has passed. expires_at is not a promise about how long the result is retained. Reading a non-terminal session after its deadline can update its status to expired.

When you supply an application redirect_uri for verification, the API can report completed while continuation_status is still pending. The data remains withheld until your server confirms the return in the original browser. Do not use the result until confirmation succeeds. Without that optional callback, the QR and status-checking examples require no confirmation call. See Optional application return.

Using verified information

credentials contains the verified data returned by the service. The names used to group these results depend on your configuration; chapter 6 shows the keys used by its digital identity example. Use those values for your application's decisions, with the verification checks and any warnings returned alongside them.

A result accepted under verification.issuer_trust="unanchored_test_mode" does not establish that the document issuer is trusted. Do not treat it as a trusted identity result for a production service. Review verification.warnings when present. An invalid or suspended credential status does not produce a successful verification result.

The result can also include diagnostic information about the document and its certificates. These fields describe the checks actually performed, not every possible check. In particular, certificate revocation is not currently checked; certificate_revocation_checked is false. Credential status is checked when a supported status reference is present. Your application should not infer additional checks from completed alone.

Optional supporting evidence

Set include_evidence: true when creating a verification if your application needs the supporting record as well as the readable identity data. The option is false by default.

Evidence follows the same access and confirmation rules as the verified data. It is not returned for pending, failed or expired results. Historical sessions without a captured request do not have a reconstructed evidence pair. Start a new session to obtain it. See the complete evidence contract.

Evidence and identity data contain personal information. Keep them out of shared caches and application logs, and define appropriate retention in your application. Requesting evidence adds no separate automatic retention timer.

Errors and retries

Use the HTTP status and the API's error and error_description fields to identify the problem. The error tables in chapter 5 specify each operation's response structure.

Any active API key belonging to the client that created the session can read it. Your server should retain that session's identifier so it can resume status checks after an interrupted connection.

Managing issued attestations

Use the returned credential_id to suspend, restore or revoke an issued attestation through the credential status API. Save this identifier when your application needs later lifecycle operations; it is not the same as issuance_id or an item identifier.

Your application never supplies the underlying status-list coordinates. Issuance polling returns delivery status and lifecycle information, not the issued badge or the original employee dataset. Private signing material is never returned by this API.

8. Data protection and retention

This chapter describes the service API's current technical storage behavior. Our Privacy Policy and Terms of use remain the reference notices. Your application also has its own data, access and retention policies.

A session's expiry time limits how long the operation can continue. It does not automatically erase the data stored for that session. The same applies when a user closes their browser or abandons the Wallet interaction.

8.1 Data retained by the service

Data Purpose and storage Retention and deletion
API client and service configuration The service stores application references, service permissions and configured verification or attestation models. API keys are stored as hashes, not as reusable plaintext keys. Persistent configuration; there is no general automatic expiry for these records. Revoking a key removes its ability to call the API; it does not delete the client's historical sessions.
Issuance data Employee or other attestation data supplied in claims, session and item identifiers, delivery state and dates are retained in the service database. Data can be retained for an unsuccessful or abandoned issuance too. Session expiry does not erase the supplied dataset. There is no general automatic deletion deadline for issuance history.
Verification requests and results The service retains the request configuration, session references, disclosed attributes and verification outcomes in its database. An age-only result can also be retained, even when no name or birth date was requested. There is no general automatic deletion deadline for verification history. A completed, failed or expired status is not a deletion action.
Optional evidence With include_evidence: true, the service captures the request in the session and retains the received proof after successful verification. The pair is returned as evidence.request and evidence.response.vp_token when available. Evidence is retained with the session data; the option adds no separate expiry or purge timer. The captured request can remain in the session even if verification later fails, while failed results return no evidence.
Issued-attestation status records Identifiers and status associations support later suspension, restoration or revocation when that feature is enabled. These are separate lifecycle records. Suspending or revoking an attestation does not erase its issuance dataset, verification history or copies already delivered to a Wallet.

Leaving include_evidence omitted or false prevents this optional evidence capture in the session. It does not disable the storage of normalized verification results or the service's separately configured diagnostic traces. The readable data returned in credentials can itself contain personal data.

Some individually managed service configurations can have an explicit history-cleanup policy. This is not a general API guarantee or a cleanup schedule applied to all clients. Such cleanup does not cover every historical record, sessions linked to an issuance, diagnostic traces, infrastructure logs or backups. Contact us to confirm the policy actually configured for your service.

8.2 Temporary state and session expiry

The service also uses temporary records with expiration times to continue Wallet interactions and prevent reuse of one-time operation references. Verification sessions default to five minutes and issuance sessions to ten minutes; the allowed ranges are described in chapter 7. Individual QR links and temporary authorization records can expire earlier.

Expiry or consumption of a temporary record prevents further use of that record. It does not delete the persistent request, result or dataset described above. It also does not erase information already received by your application or the user's Wallet, or copies in backups.

The API service and the OIDC bridge are separate services. The bridge's transaction, email and diagnostic lifetimes do not define the retention of service API data.

8.3 Diagnostic traces, logs and backups

service diagnostic traces are separate from the stored session history. Their content, size limits and expiration depend on the service configuration. With the default settings, a diagnostic trace list retains up to 1,000 recent events and expires after one hour without new events. Each new event renews that list's expiry; this is not a one-hour maximum age for every individual event.

The evidence object and the internally captured evidence request are masked in the service's diagnostic traces. Other request and response fields can still contain personal data or protocol contents, depending on the trace settings. Masking evidence does not make the entire trace anonymous. Trace expiry does not delete the corresponding database history.

Infrastructure access logs, monitoring records and backups have their own retention arrangements. They are not covered by API session or diagnostic expiry. IP addresses can be processed for service operation and abuse controls. Do not configure your own proxy or application logs to record API keys, request bodies containing identity data, or full evidence payloads.

8.4 Protecting data in your application

The Python verification example in chapter 6 keeps its local result page in memory until the process stops and sends Cache-Control: no-store. Other examples that write public/index.html leave a local file: delete it when your evaluation is finished. Stopping a local example does not erase the service's stored session or evidence.

8.5 Retention and deletion requests

There is no public Application API endpoint for deleting a verification history or an issuance dataset. For retention requirements, deletion requests or details of the hosting, log and backup arrangements for your service, contact us at contact@talao.io, using the contact process in our Privacy Policy where applicable.

Deleting an account in your application, revoking an API key and changing an attestation's status are separate operations. None alone promises deletion across the service, the bridge, the user's Wallet and backups. A shorter session lifetime must not be presented to your users as a data-erasure deadline.