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
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:
- authenticate with a managed API Key;
- discover the published Rulebook catalogue;
- start one issuance containing one or more attestations;
- receive QR-code content to display;
- poll the issuance and per-attestation statuses;
- verify information before an issuance, inspect the normalized result, then provide the issuance datasets;
- start one presentation from a configured Verifier use case;
- receive a Wallet invocation URI and QR-code content to display;
- poll the presentation status and retrieve normalized verified claims.
Your Application communicates only with /api/v1. The 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:
- The result belongs to the request it created:
presentation_id,verifierandpresentation_configuration_idmatch the saved session and service. statusiscompletedand the expected data is present. Ifcontinuation_statusis present, it must beconfirmedbefore you use it.- The returned
verificationchecks are valid, no warnings are present and the credential'sissuer_trustis one of the trusted policies agreed with Talao for that service. Never acceptunanchored_test_modeas 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:
- creation is allowed only for an Issuer associated with that API Client;
- a verify-before-issue issuance additionally requires the selected Verifier to be associated with the same API Client;
- polling is allowed to any active key of the API Client that created the issuance;
- another API Client receives
403 forbidden, even if it is authorized to use the same Issuer; - presentation creation is allowed only for a Verifier associated with that API Client;
- presentation polling is allowed to any active key of the API Client that created the presentation;
- another API Client receives
403 forbidden, even if it is authorized to use the same Verifier.
Every authenticated API Client can read the common published Rulebook catalogue. Rulebook discovery does not depend on the client's Issuer or Verifier associations. Draft and retired Rulebooks are not exposed.
When API_KEY_REQUIRED is enabled, authentication failures are:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"error": "missing_api_key"}
or:
{"error": "invalid_api_key"}
2. Quick Start
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:
- A backend that can make HTTPS requests and keep an API key private.
- A key supplied by Talao, authorized for the verification and/or issuing services you will call. Request a free API key for testing with your first name, last name, business email and company name. Talao will contact you to configure access.
- The exact service and configuration identifiers for your integration, plus the expected response keys and issuer-trust labels. Talao supplies these.
- A compatible Wallet. For identity verification, it must contain an identity document accepted by your configured service. For badge issuance, your application must already have checked the employee's entitlement to the badge.
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
- 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.
-
Let the user continue with their Wallet.
-
Computer and phone: show a QR encoding the returned
qr_code_contentexactly as supplied. The user scans it with their phone/compatible Wallet. - On the same phone: use the returned
wallet_invocation_urias 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.
- Read the result from your backend using the
status_urireturned 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.
- Validate the result before using it. This illustrative success response
matches the configuration above; its result group is
pid_mdocand its identity attributes are underclaims["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
- The Application calls
POST /api/v1/issuanceswith an Issuer and one or more datasets to issue to that user. - It stores
issuance_idand gives the exactqr_code_contentfrom the creation response to its QR renderer. If it requested a Transaction Code, it communicates that code separately from the QR code. - The Wallet obtains the attestation from the Talao service. The Application polls
GET /api/v1/issuances/{issuance_id}untilcompleted,expiredorfailed, bounded byexpires_at.
API operations:
POST /api/v1/issuancesGET /api/v1/issuances/{issuance_id}
The Application never sends its API Key to the Wallet. See the Python and JavaScript examples and the Create an issuance and Get an issuance contracts.
Issue an attestation with a transaction code
Use this workflow when a code must be communicated to the user separately from
the QR code, for example through an authenticated channel. The Application
supplies tx_code while creating the issuance, displays the QR content, and
communicates the code separately; the Talao service handles the rest.
API operations:
POST /api/v1/issuancesGET /api/v1/issuances/{issuance_id}
See With Transaction Code for the exact request and constraints.
Issue different attestations in the same session
Use this workflow when one user should receive several different attestation
types in one issuance. Each entry uses its own credential_configuration_id
and dataset.
One issuance
|
+-- Proof of Residence
+-- Employee Attestation
+-- Email Attestation
API operations:
POST /api/v1/issuancesGET /api/v1/issuances/{issuance_id}
See Create an issuance for the complete contract.
Issue several attestations of the same type
A single issuance can contain several datasets for the same Credential Configuration when the issuance mode permits it.
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.
- Create the presentation with
POST /api/v1/presentations. - Display the returned
qr_code_contentunchanged. - Poll the returned
status_uri. - 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:
POST /api/v1/issuancesGET /api/v1/issuances/{issuance_id}POST /api/v1/issuances/{issuance_id}/authorization/finalize
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.
- Configure the workflow with
POST /api/v1/issuances, selectinginitiation_mode=wallet_initiatedwhen the Issuer allowsboth,grant_type=authorization_code,authorization.mode=openid4vp, the Presentation Configuration and the Credential Configuration(s) to issue. Omit issuance claims andtx_code.expires_insets the lifetime of each future issuance session; it does not expire the configuration. - Store the returned
issuerandwallet_sessions_uri, and provideissuer_urlto Wallet users. This response has noissuance_id, Credential Offer or QR: configuring the workflow does not create an issuance session. - Discover Wallet sessions through the returned
wallet_sessions_uri, namelyGET /api/v1/issuances/{issuer_slug}/wallet-sessions. Each has a distinctissuance_id, even when several Wallets request the same scope. - For each session with
authorization.status=awaiting_application, read the normalized verified information and make the Application's business decision. - Call
POST /api/v1/issuances/{issuance_id}/authorization/finalizewithoutcome=ready, that session'sitem_idvalues and the data to issue, oroutcome=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("&", "&")
.replaceAll("<", "<").replaceAll(">", ">");
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("&", "&")
.replaceAll("<", "<").replaceAll(">", ">");
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
- Use the service and configuration identifiers provided for your integration. Your API key must be authorized to use those services.
- Send
claimsas a JSON object whose fields match the selected attestation model. Your application is responsible for approving this data before issuing it. - Creation does not perform general validation of all required fields, field types or unknown names. Only fields mapped in the configured model are issued. An accepted creation request is not proof that your dataset is complete.
- If your workflow uses the finalization endpoint, that step rejects unknown
fields, missing required fields and incompatible JSON types. An error is
reported as documented under Issuance errors; do not expect
a separate
422 invalid_claimsresponse. - The selected service controls signing, technical formats and verification rules. Your application selects an existing configuration rather than overriding these settings in an API request.
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.
pending,response_receivedand other non-terminal states mean the operation is still in progress. They do not mean verification or delivery succeeded.completedmeans verification succeeded or the attestation was issued, depending on the operation. For an issuance containing several items, also inspect the individual item statuses described in Get an issuance.failedandexpiredend that session. Show the failure or expiry to the user rather than continuing to wait for completion.
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.requestcontains the captured request, withformatandcontent.evidence.response.vp_tokencontains the received proof after successful verification. Your application can read the person's information fromcredentialswithout interpreting this proof.
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.
401: check that a valid API key was supplied.403: check service permissions and session ownership. Being allowed to use the same service does not grant access to another client's session.400or409: correct the request or respect the operation's current state; repeating it unchanged is not a general solution.- For network errors, stop displaying an old QR if you cannot continue the
operation. A timeout does not prove that a creation request failed: avoid
blindly repeating
POSTrequests, which can create additional sessions.
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
- Keep the API key on your server. Do not include it in browser code, a QR code, a URL or a public source repository. Protect source files in which you have inserted a key for local evaluation.
- Use HTTPS when calling the hosted API. Restrict access to your application's identity results, employee data and supporting evidence.
- Request only the identity information needed for your service. Choose an age-only verification when your application only needs the age result.
- Request evidence only when you need that additional record. Define who may access it, why it is retained and when your application will delete its copy.
- Apply a retention policy to your own accounts, local sessions, downloaded results and exported evidence. Do not put them in shared browser caches or retain them indefinitely by default.
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.