Skip to main content

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:

  1. 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.
  2. 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.

Prerequisites

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.

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.

Session management

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 offer
  • credential_offer_uri—an openid-credential-offer:// URI to render as a QR code or use as a deep link
  • tx_code_value (conditional)—present only when you include tx_code in the request (covered in Step 5)
note

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 -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_id references a credential configuration in the connector's Credential Issuer Metadata. It must match a type defined in your Type Metadata.
  • The claims object 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_id because 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.

# 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:

StatusWhat happenedYour response
OFFER_CREATEDThe offer was created and the session is activeUse for audit logging or to start a timeout timer
ISSUEDThe credential was successfully issued to the walletUpdate your records, confirm to the user
FAILEDIssuance failed (the errorDetails field describes the reason)Log the error, alert your team, offer retry
EXPIREDThe session timed out before the wallet completed the flowCreate a new offer with a fresh QR code
Handle absent fields defensively

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.

# 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:

FieldDescription
eventIdCorrelation key tied to the offer (same value as offerId). To deduplicate retries, combine eventId + status.
statusOne of OFFER_CREATED, ISSUED, FAILED, or EXPIRED.
offerIdCorrelation token matching the offer_id from Step 2.
errorDetailsPresent 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 errorDetails for 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 -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

Server setup

Create a server entry point that combines the callback handler from Step 4 with a simple endpoint to trigger new issuance flows:

  1. Add a GET /start-issuance route that calls the offer creation function from Step 2, generates a QR code from the credential_offer_uri, and renders an HTML page with the QR code and a deep link button.
  2. Start the server on port 3000 (TypeScript) or 8080 (Java).
  3. Open http://localhost:3000/start-issuance or http://localhost:8080/start-issuance in your browser to test.

Walk through the flow

  1. Start your backend service and open http://localhost:3000/start-issuance in your browser.
  2. You see a QR code on the page. Open your test EUDI Wallet app and scan the QR code.
  3. The wallet discovers the connector's metadata, exchanges the pre-authorized code for an access token, and requests the credential.
  4. After the wallet receives the credential, the connector delivers a callback to your endpoint.
  5. 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 EXPIRED status.
  • Simulate a failure: Send a test callback with "status": "FAILED" and an errorDetails value to verify your error handling logic.
  • Test with tx_code: Create an offer with tx_code enabled, scan the QR code, and enter the code in the wallet. Verify that the issuance completes and your callback receives ISSUED.

Test checklist

  • Credential offer creates and returns offer_id and credential_offer_uri
  • QR code renders from credential_offer_uri
  • Deep link opens the wallet on mobile
  • Callback receives the issuance event with ISSUED status
  • Callback correctly correlates offerId with the original session
  • Callback handles all four statuses (OFFER_CREATED, ISSUED, FAILED, EXPIRED)
  • FAILED callback includes errorDetails
  • Expired sessions are handled gracefully
  • tx_code flow returns tx_code_value in 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_uri for 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_code for 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

Further reading