Build an AOC issuance flow
In this tutorial, you walk through a complete credential issuance flow using the Truvity EUDIW Connector. You create a credential offer for an Account Ownership Credential (AOC), display it to a user's EUDI Wallet, and handle the issuance callback. By the end, you understand the full lifecycle—creating a credential offer, displaying it to the user, receiving issuance results, and handling errors—and have tested each step against the connector's API.
You learn how to:
- Call the connector's management API to create a credential offer
- Generate a QR code for cross-device wallet interaction
- Implement a deep link for same-device flows
- Implement a callback endpoint that processes all issuance outcomes
- Extend the flow with transaction code authorization
Estimated time: 30–45 minutes.
What you'll build
Your integration needs two main components:
- An offer endpoint that creates a credential offer for an AOC and displays a QR code for the user to scan with their EUDI Wallet.
- A callback endpoint that receives the issuance result from the connector and updates your records based on the outcome.
The user experience looks like this: a customer completes a bank account opening in your app, you create a credential offer containing their account details, the customer scans a QR code with their EUDI Wallet, approves the issuance, and your backend receives a callback confirming the credential was issued. No manual certificate exchange, no file downloads—a cryptographically signed credential delivered directly to the customer's wallet.
- A running connector instance with its public base URL configured and a callback URL pointing to your backend endpoint (for example,
http://localhost:3000/callback/issuance). See connector architecture for the deployment model and how callbacks are delivered. - Access to the internal management API (port 8081)
- An X.509 access certificate configured in the connector
- An Issuer Signing Certificate configured in the connector. See Manage certificates to generate one.
- Type Metadata for AOC configured in the connector
- Node.js 18+ development environment (the setup steps use npm)
- Basic understanding of the EUDI Wallet ecosystem
Step 1: Set up your project
Start by creating a new project and installing the dependencies you need. The project uses an HTTP framework for the callback endpoint, an HTTP client for calling the connector's API, and a QR code library for displaying the credential offer.
- Shell
mkdir aoc-issuance && cd aoc-issuance
npm init -y
npm install express axios qrcode
npm install -D @types/express @types/qrcode typescript
Here is what each dependency does:
- express / Spring Boot: Hosts the callback endpoint that receives issuance results from the connector.
- axios / OkHttp: Calls the connector's management API to create credential offers.
- qrcode / ZXing: Generates QR codes from the connector's response URI so users can scan with their wallet.
You also need an in-memory store to track issuance sessions. In production, you would use a database, but a simple map works for this tutorial.
Your integration needs an in-memory store to track issuance sessions. In production, use a database, but a simple map works for this tutorial. Create a session store that maps the connector's offer_id value to your app's session data (customer ID, status, timestamps). You use this store in the callback handler to correlate incoming results with the original offer.
Step 2: Create a credential offer
Now you create a credential offer that tells the connector which credential to issue and what claims to include. The offer uses the OID4VCI protocol's pre-authorized code flow, where your backend pre-authorizes the issuance without requiring the wallet holder to authenticate at an authorization server.
The request goes to POST /offers on the connector's management API (port 8081). You provide the credential configuration ID (which references a type in your Type Metadata) and the claims to embed in the credential. The connector creates a credential offer and returns three values:
offer_id—a correlation token you use to match the callback with this offercredential_offer_uri—anopenid-credential-offer://URI to render as a QR code or use as a deep linktx_code_value(conditional)—present only when you includetx_codein the request (covered in Step 5)
The VCT value (urn:eudi:aoc:1) and claim values in this tutorial are examples for a typical AOC scenario. In production, use the VCT and claim structure defined in your Type Metadata. Available claims vary by credential type and issuer configuration.
- cURL
curl -X POST http://connector.example.com:8081/offers \
-H "Content-Type: application/json" \
-d '{
"credential_configuration_id": "AccountOwnershipCredential",
"claims": {
"bankName": "Example Bank",
"accountHolder": "Erika Mustermann",
"accountNumber": "1234567890",
"iban": "DE99370501981234567890",
"bic": "COLSDE33XXX",
"currency": "EUR",
"accountType": "checking",
"sub": "user-uuid-123",
"userId": "user-uuid-123"
}
}'
Example response (HTTP 201 Created):
{
"offer_id": "abc123def456",
"credential_offer_uri": "openid-credential-offer://?credential_offer_uri=https%3A%2F%2Fissuer.example.com%2Foidc4vci%2Foffers%2Fabc123def456"
}
Store the offer_id to correlate with the callback later. Render credential_offer_uri as a QR code or redirect the user to it for same-device flows.
A few things to notice in this code:
- The
credential_configuration_idreferences a credential configuration in the connector's Credential Issuer Metadata. It must match a type defined in your Type Metadata. - The
claimsobject contains the AOC-specific attributes: bank details (bankName,iban,bic), account details (accountNumber,accountType,currency), the account holder's name, and identifiers (sub,userId). The connector embeds these claims in the signed SD-JWT VC. - Standard SD-JWT VC claims (
iss,sub,iat,exp,vct,cnf) are added automatically by the connector during credential signing. You don't need to include them in the request. - You store the
offer_idbecause the connector uses it to correlate the callback with this specific offer. Without it, you cannot match incoming issuance results to the right customer session.
Step 3: Display the credential offer
Use credential_offer_uri to generate a QR code when the user is on a desktop. Use it as a deep link when the user is on a mobile device. The wallet app recognizes the openid-credential-offer:// URI scheme and initiates the issuance flow.
- cURL
# Set the credential_offer_uri from the Step 2 response
credential_offer_uri="openid-credential-offer://?credential_offer_uri=https%3A%2F%2Fissuer.example.com%2Foidc4vci%2Foffers%2Fabc123def456"
# Cross-device: generate a QR code PNG from the URI (requires qrencode)
qrencode -o qrcode.png "$credential_offer_uri"
# Same-device: open the deep link directly (macOS)
open "$credential_offer_uri"
After the user scans the QR code or taps the deep link, the wallet takes over. Behind the scenes, the wallet discovers the connector's metadata, exchanges the pre-authorized code for an access token (secured with DPoP), requests a nonce, creates a key proof, and requests the credential. The connector signs the credential as an SD-JWT VC and delivers it to the wallet. You don't need to implement any of these protocol steps—the connector and wallet handle them automatically.
Step 4: Implement the callback handler
When the wallet completes the issuance flow, the connector delivers the result to your callback endpoint as an Issuance Event. This happens asynchronously—the connector calls your endpoint, not the other way around.
The callback payload contains a status field that tells you the outcome. There are four possible statuses, and your code must handle all of them:
| Status | What happened | Your response |
|---|---|---|
OFFER_CREATED | The offer was created and the session is active | Use for audit logging or to start a timeout timer |
ISSUED | The credential was successfully issued to the wallet | Update your records, confirm to the user |
FAILED | Issuance failed (the errorDetails field describes the reason) | Log the error, alert your team, offer retry |
EXPIRED | The session timed out before the wallet completed the flow | Create a new offer with a fresh QR code |
The errorDetails field is only present when the status is FAILED. For OFFER_CREATED, ISSUED, and EXPIRED, this field is absent from the payload. Always check for its existence before accessing it.
- cURL
# Simulate an ISSUED callback for testing
curl -X POST http://backend.example.com:3000/callback/issuance \
-H "Content-Type: application/json" \
-d '{
"eventId": "abc123def456",
"status": "ISSUED",
"offerId": "abc123def456"
}'
Example OFFER_CREATED callback (informational, no action required):
{
"eventId": "abc123def456",
"status": "OFFER_CREATED",
"offerId": "abc123def456"
}
Example FAILED callback:
{
"eventId": "abc123def456",
"status": "FAILED",
"offerId": "abc123def456",
"errorDetails": "issuer service unavailable"
}
Example EXPIRED callback:
{
"eventId": "abc123def456",
"status": "EXPIRED",
"offerId": "abc123def456"
}
The callback payload contains:
| Field | Description |
|---|---|
eventId | Correlation key tied to the offer (same value as offerId). To deduplicate retries, combine eventId + status. |
status | One of OFFER_CREATED, ISSUED, FAILED, or EXPIRED. |
offerId | Correlation token matching the offer_id from Step 2. |
errorDetails | Present for FAILED status. Describes the failure reason. |
Handle each status:
- OFFER_CREATED—the offer was created and the session is active. Use for audit logging or to start a timeout timer. No user-facing action is needed.
- ISSUED—the credential was successfully issued to the wallet. Update your records accordingly (for example, mark the account as having an AOC issued).
- FAILED—issuance failed. Check
errorDetailsfor the reason, log the error, and consider offering the user a retry. - EXPIRED—the session time-to-live (TTL) expired before the wallet completed the flow. The default TTL is five minutes. Create a new offer if the user wants to try again.
The connector delivers callbacks asynchronously (fire-and-forget). Always respond with HTTP 200 to acknowledge receipt. Keep your callback handler fast and offload heavy processing to a background queue if needed.
For the complete list of payload fields and status descriptions, see the callback events reference.
Step 5: Extend with tx_code (optional)
For higher-assurance issuance, you can add a transaction code to the offer. The transaction code is a one-time value (for example, a six-digit numeric code) that the user must enter in their wallet before the credential is issued. This adds an extra authorization layer—useful when regulatory requirements demand it or when the credential grants access to sensitive resources.
To add a transaction code, include a tx_code object in the offer request. The connector returns a tx_code_value in the response that you deliver to the user via a separate channel (for example, SMS or email). The authorization server validates the code at the token endpoint before the wallet can proceed.
- cURL
curl -X POST http://connector.example.com:8081/offers \
-H "Content-Type: application/json" \
-d '{
"credential_configuration_id": "AccountOwnershipCredential",
"claims": {
"bankName": "Example Bank",
"accountHolder": "Erika Mustermann",
"accountNumber": "1234567890",
"iban": "DE99370501981234567890",
"bic": "COLSDE33XXX",
"currency": "EUR",
"accountType": "checking",
"sub": "user-uuid-123",
"userId": "user-uuid-123"
},
"tx_code": {
"input_mode": "numeric",
"length": 6
}
}'
Example response (HTTP 201 Created):
{
"offer_id": "abc123def456",
"credential_offer_uri": "openid-credential-offer://?credential_offer_uri=https%3A%2F%2Fissuer.example.com%2Foidc4vci%2Foffers%2Fabc123def456",
"tx_code_value": "123456"
}
The tx_code object contains:
input_mode—the type of input expected (for example,"numeric"for a numeric code)length—the number of characters in the code (for example,6)description(optional)—a human-readable description of the transaction code purpose (for example,"Enter the code sent to your email")
The response includes tx_code_value alongside the standard offer_id and credential_offer_uri. You are responsible for delivering this value to the user through a channel separate from the QR code or deep link. The wallet prompts the user to enter the code before proceeding with the token exchange.
For more details on transaction code configuration and error handling, see Use transaction codes.
How the issuance flow works
The following diagram shows the complete end-to-end flow between your backend, the connector, the authorization server, and the EUDI Wallet. Steps 1–6 correspond to your backend code. Steps 7–22 happen automatically between the wallet and the connector.
The connector purges the credential claims from its session storage after the flow completes (whether the credential is issued, the flow fails, or the session expires). This ephemeral data model means the connector does not retain sensitive claim data beyond the issuance session.
Testing your integration
Start your backend service
Create a server entry point that combines the callback handler from Step 4 with a simple endpoint to trigger new issuance flows:
- Add a
GET /start-issuanceroute that calls the offer creation function from Step 2, generates a QR code from thecredential_offer_uri, and renders an HTML page with the QR code and a deep link button. - Start the server on port 3000 (TypeScript) or 8080 (Java).
- Open
http://localhost:3000/start-issuanceorhttp://localhost:8080/start-issuancein your browser to test.
Walk through the flow
- Start your backend service and open
http://localhost:3000/start-issuancein your browser. - You see a QR code on the page. Open your test EUDI Wallet app and scan the QR code.
- The wallet discovers the connector's metadata, exchanges the pre-authorized code for an access token, and requests the credential.
- After the wallet receives the credential, the connector delivers a callback to your endpoint.
- Check your server logs. You should see a log entry confirming the issuance with the customer ID.
Test error scenarios
- Let the session expire: Create a credential offer but do not scan the QR code. After the session TTL passes (default five minutes), your callback receives an
EXPIREDstatus. - Simulate a failure: Send a test callback with
"status": "FAILED"and anerrorDetailsvalue to verify your error handling logic. - Test with tx_code: Create an offer with
tx_codeenabled, scan the QR code, and enter the code in the wallet. Verify that the issuance completes and your callback receivesISSUED.
Test checklist
- Credential offer creates and returns
offer_idandcredential_offer_uri - QR code renders from
credential_offer_uri - Deep link opens the wallet on mobile
- Callback receives the issuance event with
ISSUEDstatus - Callback correctly correlates
offerIdwith the original session - Callback handles all four statuses (
OFFER_CREATED,ISSUED,FAILED,EXPIRED) -
FAILEDcallback includeserrorDetails - Expired sessions are handled gracefully
- tx_code flow returns
tx_code_valuein the offer response - tx_code flow completes when the correct code is entered
What you learned
In this tutorial, you built a complete AOC issuance flow using the EUDIW Connector. Here is what you covered:
- Credential offers: You called the connector's management API to create a credential offer with AOC-specific claims and received a
credential_offer_urifor the wallet. - QR code and deep link delivery: You rendered the offer URI as a QR code for cross-device flows and as a deep link for same-device flows.
- Callback-based delivery: You implemented a callback endpoint that receives the issuance event asynchronously from the connector, rather than polling for results.
- Status handling: You handled all four issuance outcomes (
OFFER_CREATED,ISSUED,FAILED,EXPIRED) with appropriate responses for each. - Transaction codes: You extended the flow with
tx_codefor additional authorization, delivering the code via a separate channel. - Protocol flow: The entire flow is built on the OID4VCI protocol, which provides DPoP-secured token exchanges, nonce-based replay prevention, and cryptographic key binding in the issued credential.
Troubleshooting
unknown_credential_configuration error
The credential_configuration_id in your offer request doesn't match any configured credential type. Verify that your Type Metadata is configured and that the credential_configuration_id matches a key in the connector's Credential Issuer Metadata.
invalid_proof error
The wallet's Key Proof JWT failed validation. This typically indicates a mismatch between the wallet's proof audience and the connector's expected value. Check the connector logs for details.
Offer expired before wallet completes
The default session TTL is five minutes. If users consistently time out, guide them to scan the QR code promptly after it is displayed.
Callback not receiving events
The connector delivers callbacks asynchronously when the flow completes. Verify that your callback endpoint is reachable from the connector and returns a 2xx response promptly.
Next steps
- Implement AOC issuance—a task-oriented reference for production-ready AOC issuance when you already know the concepts
- Issue a credential—generic credential issuance for any credential type
- Use transaction codes—detailed guide for transaction code configuration and error handling
Further reading
- AOC issuance use case—business context and conceptual flow for Account Ownership Credentials
- OID4VCI protocol—how the issuance protocol works
- Callback events—issuance event statuses and payload fields
- Error codes—wallet-facing HTTP error responses