Build a KYC verification flow
In this tutorial, you build a complete identity verification flow using the Truvity EUDIW Connector. You start from an empty project, create a backend service that requests government-issued identity credentials from a user's EUDI Wallet, and process the verified data for KYC compliance. By the end, you have a working integration that handles the full lifecycle: requesting credentials, receiving verification results, handling errors, and integrating verified identity data into your app.
You learn how to:
- Construct a DCQL query to request specific identity attributes
- Call the connector's management API to create a presentation request
- Generate a QR code for cross-device wallet interaction
- Implement a callback endpoint that processes all verification outcomes
- Extract and validate verified identity data from PID credentials
- Extend the flow to support same-device deep links
Estimated time: 30–45 minutes.
What you'll build
You build a backend service with two main components:
- A request endpoint that creates a presentation request for PID (Personal Identification Data) credentials and displays a QR code for the user to scan with their EUDI Wallet.
- A callback endpoint that receives the verification result from the connector, extracts identity attributes (name, date of birth, address, nationality), validates the credential, and stores the verified data.
The user experience looks like this: a customer visits your app, scans a QR code with their EUDI Wallet, selects the requested credential, approves sharing their identity data, and your backend receives the verified attributes within seconds. No document scans, no manual review—cryptographically verified government-issued identity data delivered directly to your app.
- 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/kyc). 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
- 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 wallet request.
- Shell
mkdir kyc-verification && cd kyc-verification
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 verification results from the connector.
- axios / OkHttp: Calls the connector's management API to create presentation requests.
- 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 verification 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 verification sessions. In production, use a database, but a simple map works for this tutorial. Create a session store that maps the connector's state value to your app's session data (app ID, status, verified customer data). You use this store in the callback handler to correlate incoming results with the original request.
Step 2: Create a presentation request
Now you create a presentation request that tells the connector which credentials and attributes you need from the user's wallet. You use a DCQL (Digital Credentials Query Language) query to specify the exact attributes required for KYC verification.
DCQL is the standard query language for requesting credentials in the EUDI Wallet ecosystem. Instead of asking for an entire credential, you specify exactly which claims (attributes) you need. This is called selective disclosure—the wallet reveals only the requested attributes, nothing more. For KYC, you need five PID attributes: given name, family name, date of birth, address, and nationality.
The VCT values, requested claims, and credential values in this tutorial are examples for a typical KYC scenario. In production, request the attributes required by your regulatory obligations and available in the target attestation scheme. Available attributes vary by issuer and member state.
The request goes to POST /oidc4vp on the connector's management API (port 8081). The connector creates a signed authorization request and returns three values:
state—a correlation token you use to match the callback with this requestcross_device_request_uri—a URI to render as a QR code (for when the user is on a different device than their wallet)same_device_request_uri—a deep link URI (for when the user is on the same device as their wallet)
For now, you use only the cross_device_request_uri to generate a QR code. You add same-device support in Step 4.
- cURL
curl -X POST http://connector:8081/oidc4vp \
-H "Content-Type: application/json" \
-d '{
"dcql_query": {
"credentials": [
{
"id": "pid_kyc",
"format": "dc+sd-jwt",
"meta": {
"vct_values": ["urn:eudi:pid:1"]
},
"claims": [
{ "path": ["given_name"] },
{ "path": ["family_name"] },
{ "path": ["birthdate"] },
{ "path": ["address"] },
{ "path": ["nationalities"] }
]
}
]
},
"expires_in": 600,
"redirect_uri": "http://localhost:3000/verification-result"
}'
Example response:
{
"state": "abc123",
"same_device_request_uri": "openid4vp://?client_id=x509_hash%3A<certificate_hash>&request_uri=https%3A%2F%2Fconnector.example.com%2Foidc4vp%2Fabc123%2Frequest%3Fflow_type%3Dsame-device",
"cross_device_request_uri": "openid4vp://?client_id=x509_hash%3A<certificate_hash>&request_uri=https%3A%2F%2Fconnector.example.com%2Foidc4vp%2Fabc123%2Frequest%3Fflow_type%3Dcross-device"
}
The two URIs share the same structure but differ in the flow_type query parameter (same-device vs cross-device). Each URI includes a client_id derived from your X.509 access certificate hash and a request_uri pointing to the connector's public endpoint.
The redirect_uri parameter is optional. It tells the wallet where to redirect the user's browser after the same-device flow completes. Omit it for cross-device-only flows. You use it in Step 4.
Store the state value to correlate with the callback later. Render cross_device_request_uri as a QR code or redirect the user to same_device_request_uri for same-device flows.
A few things to notice in this code:
-
The
idfield ("pid_kyc") is your own identifier for this credential in the query. You use it later in the callback to look up the presented credential. -
The
formatisdc+sd-jwt, which is the standard format for PID credentials in the EUDI Wallet ecosystem. It supports selective disclosure, meaning the wallet only reveals the claims you request. -
The
claimsarray lists exactly which attributes you need. The wallet shows the user which data is being requested and asks for consent before sharing. -
The connector validates the
vct(Verifiable Credential Type) claim in the presented credential against thevct_valuesyou specified in the query before delivering the callback. If the credential type does not match, the connector rejects it and you receive aVERIFICATION_FAILEDstatus. -
The
expires_infield controls how long the QR code remains valid, in seconds. Set it long enough for the user to scan, but short enough to limit the window for misuse. -
You store the
statevalue because the connector uses it to correlate the callback with this specific request. Without it, you cannot match incoming verification results to the right user session.
Step 3: Implement the callback endpoint
When the user scans the QR code and approves the credential presentation in their wallet, the connector verifies the credential and delivers the result to your callback endpoint as a PresentedCredentialsEvent. 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 five possible statuses, and your code must handle all of them:
| Status | What happened | Your response |
|---|---|---|
FULFILLED | The user approved and the credential is verified | Extract and process the identity data |
REJECTED | The user declined the request in their wallet (the errorDetails field contains the wallet's OAuth 2.0 error code, for example access_denied) | Log the error code, inform the user, and offer to retry |
EXPIRED | The session timed out before the user responded | Create a new request with a fresh QR code |
PROCESSING_ERROR | An internal error occurred (decryption, infrastructure) | Log the error, alert your team, offer retry |
VERIFICATION_FAILED | The credential failed verification (invalid signature, revoked, DCQL mismatch) | Log the error, deny access |
The credentials and credentialsRaw fields are only present when the status is FULFILLED. For REJECTED, EXPIRED, PROCESSING_ERROR, and VERIFICATION_FAILED, these fields are absent from the payload. Always check for their existence before accessing them. The errorDetails field is present only for REJECTED, PROCESSING_ERROR, and VERIFICATION_FAILED.
The credentials field contains verified and parsed credential data (claims as native objects, plus verification flags like signatureIsValid and supportRevocation). The credentialsRaw field contains the same credential data without verification flags: claims as a base64-encoded JSON object, plus issuer, validFrom, validUntil, kbKeyId, and transactionDataHashes. Use credentialsRaw when you need the disclosed attributes in their raw encoded form for forwarding to another system or for audit purposes.
- cURL
# Simulate a FULFILLED callback for testing
curl -X POST http://localhost:3000/callback/kyc \
-H "Content-Type: application/json" \
-d '{
"status": "FULFILLED",
"state": "abc123",
"responseCode": "resp_xyz789",
"credentials": {
"pid_kyc": [
{
"issuer": "https://vc-issuer.test.example/",
"claims": {
"given_name": "Erika",
"family_name": "Mustermann",
"birthdate": "1964-08-12",
"address": {
"street_address": "Domplein 1",
"house_number": "1",
"locality": "Utrecht",
"postal_code": "90210",
"region": "Utrecht",
"country": "NL"
},
"nationalities": ["NL"]
},
"signatureIsValid": true,
"supportRevocation": false,
"supportTrustAnchor": true,
"isTrusted": false,
"kbKeyId": "<key-binding-key-id>",
"kbSignatureIsValid": true
}
]
},
"credentialsRaw": {
"pid_kyc": [
{
"claims": "<base64-encoded-claims>",
"issuer": "https://vc-issuer.test.example/",
"kbKeyId": "<key-binding-key-id>"
}
]
}
}'
The responseCode field is used in same-device flows to correlate the browser redirect with this callback (covered in Step 4). It is only present in same-device flows — for cross-device flows, this field is absent from the payload. The kbSignatureIsValid field confirms the presenter holds the private key bound to the credential, proving they are the legitimate holder. See key binding for details.
When testing with the mock wallet, expect supportRevocation to be false and isTrusted to be false. Test credentials are not issued by trust-listed issuers and do not include revocation status lists. The validFrom and validUntil fields are optional and depend on the credential type and issuer — they may be absent from the callback payload entirely.
A few important details about the callback:
- The
credentialsfield is a map where the key is the DCQL credential ID you specified in Step 2 ("pid_kyc"), and the value is an array of presented credentials. For a single-credential query like this one, the array typically contains one entry. - The fields
isRevoked,isTrusted,validFrom, andvalidUntilare optional. TheisRevokedfield is only present whensupportRevocationistrue, andisTrustedis only present whensupportTrustAnchoristrue. Always check thesupport*flags before reading the dependent fields. - The
kbSignatureIsValidfield confirms key binding. It proves the person presenting the credential holds the private key bound to it, preventing credential replay by a third party. Check this field alongsidesignatureIsValidfor full cryptographic validation. - The callback may also include
transactionDataHasheswhen transaction data binding is used. This field is not relevant for basic KYC flows. - The connector delivers the callback synchronously with a default two-second timeout and one retry. These defaults are configurable in the connector configuration. Keep your callback handler fast and offload heavy processing to an asynchronous queue if needed.
- Always respond with HTTP 200 to acknowledge receipt. If your endpoint is unavailable or too slow, the wallet user sees an error on their device.
For the complete list of statuses and error codes, see the callback events reference.
Step 4: Extend to same-device flow
So far, your integration supports the cross-device flow where the user scans a QR code from a desktop browser. But what if the user is already on their mobile device? Scanning a QR code on the same device is awkward. For this scenario, you use the same_device_request_uri as a deep link that opens the wallet app directly.
The same-device flow introduces one additional concept: the responseCode. In the cross-device flow, your backend learns the result through the callback alone. In the same-device flow, the user's browser also needs to know the result so it can display the right page. After the wallet completes the flow, it redirects the user's browser to your redirect_uri with a response_code query parameter. Your backend matches this response_code to the responseCode from the callback to look up the verification result.
- cURL
curl -X POST http://connector:8081/oidc4vp \
-H "Content-Type: application/json" \
-d '{
"dcql_query": {
"credentials": [
{
"id": "pid_kyc",
"format": "dc+sd-jwt",
"meta": {
"vct_values": ["urn:eudi:pid:1"]
},
"claims": [
{ "path": ["given_name"] },
{ "path": ["family_name"] },
{ "path": ["birthdate"] },
{ "path": ["address"] },
{ "path": ["nationalities"] }
]
}
]
},
"expires_in": 600,
"redirect_uri": "http://localhost:3000/verification-result"
}'
Example response:
{
"state": "abc123",
"same_device_request_uri": "openid4vp://?client_id=x509_hash%3A<certificate_hash>&request_uri=https%3A%2F%2Fconnector.example.com%2Foidc4vp%2Fabc123%2Frequest%3Fflow_type%3Dsame-device",
"cross_device_request_uri": "openid4vp://?client_id=x509_hash%3A<certificate_hash>&request_uri=https%3A%2F%2Fconnector.example.com%2Foidc4vp%2Fabc123%2Frequest%3Fflow_type%3Dcross-device"
}
The response is the same as Step 2. Use same_device_request_uri as a deep link for mobile users, and cross_device_request_uri as a QR code for desktop users.
To complete the same-device flow, your callback handler and your frontend need two additions:
- In your callback handler, store the
responseCodefrom the callback payload in a map keyed byresponseCode→state. Add this after the status switch statement: if the event contains aresponseCode, save the mapping so you can look it up later. - Add a
GET /verification-resultendpoint that accepts aresponse_codequery parameter. This endpoint looks up thestatefrom the response code map, finds the session, and displays the result to the user. The wallet redirects the user's browser to yourredirect_uriwith?response_code=<value>after the flow completes.
Your frontend decides which flow to use based on the user's device. On desktop, display the QR code. On mobile, render the deep link as a button or redirect to it directly. Both flows use the same callback endpoint—the only difference is how the user initiates the interaction and how the browser learns the result.
In cross-device flows, the connector returns an empty JSON object ({}) to the wallet after processing the response, per the OID4VP specification. Your backend learns the result exclusively through the callback.
Step 5: Integrate verified data
With the callback handler in place, you now have verified identity data flowing into your app. The final step is mapping the PID claims to your app's data model and storing them appropriately.
The PID credential provides standardized identity attributes. Map these to your own data structures for use in your KYC workflow, account creation, or compliance records.
Map the PID claims from the callback to your app's data model:
- Access the credential using your DCQL credential ID (
"pid_kyc") as the key in thecredentialsmap. - Extract the claims you requested:
given_name,family_name,birthdate,address, andnationalitiesfrom theclaimsobject. - Store the extracted data in your database alongside the verification timestamp and the
issuervalue for audit purposes. - In production, apply your data retention and access control policies to this data.
You are responsible for applying your own data retention and access control policies to credential data received through callbacks. The connector does not persist credential data after delivering the callback. Consider your regulatory obligations (GDPR, sector-specific rules) when deciding how long to store verified identity data and who can access it.
Testing your integration
Follow these steps to test the complete KYC verification flow end to end.
Start your backend service
Create a server entry point that combines the callback handler from Step 3 with a simple endpoint to trigger new verifications:
- Add a
GET /start-kycroute that calls the presentation request function from Step 2, generates a QR code from thecross_device_request_uri, and renders an HTML page with the QR code and a deep link button using thesame_device_request_uri. - Start the server on port 3000 (TypeScript) or 8080 (Java).
- Open
http://localhost:3000/start-kycorhttp://localhost:8080/start-kycin your browser to test.
Walk through the flow
- Start your backend service and open
http://localhost:3000/start-kyc(TypeScript) orhttp://localhost:8080/start-kyc(Java) in your browser. - You see a QR code on the page. Open your test EUDI Wallet app and scan the QR code.
- The wallet displays a consent screen showing the requested attributes: given name, family name, date of birth, address, and nationality. Review and approve the request.
- After approval, the wallet submits the credential to the connector. The connector verifies the credential and delivers the result to your callback endpoint.
- Check your server logs. You should see a log entry confirming the verification with the user's name and the credential issuer.
Test error scenarios
- Reject the request: Scan the QR code but decline the consent screen in the wallet. Your callback receives a
REJECTEDstatus. - Let the session expire: Create a presentation request but do not scan the QR code. After the
expires_induration passes, your callback receives anEXPIREDstatus. - Test same-device flow: Open the
/start-kycpage on a mobile device and tap the "Open in wallet" link. After completing the flow, the wallet redirects your browser to/verification-resultwith theresponse_codeparameter.
What you learned
In this tutorial, you built a complete KYC verification flow using the EUDIW Connector. Here is what you covered:
- DCQL queries: You constructed a query that requests specific PID attributes using the DCQL query language, specifying the credential format, type, and individual claims.
- Presentation requests: You called the connector's management API to create a presentation request and received URIs for both cross-device (QR code) and same-device (deep link) flows.
- Callback-based delivery: You implemented a callback endpoint that receives the
PresentedCredentialsEventasynchronously from the connector, rather than polling for results. - Credential validation: You checked the cryptographic properties of the presented credential (
signatureIsValid,kbSignatureIsValid) and used thesupportRevocationandsupportTrustAnchorflags to safely read conditional fields. - Status handling: You handled all five verification outcomes (
FULFILLED,REJECTED,EXPIRED,PROCESSING_ERROR,VERIFICATION_FAILED) with appropriate responses for each. - Same-device correlation: You used the
responseCodefield to correlate the browser redirect with the callback result in same-device flows. - Security properties: The entire flow is built on the OID4VP protocol, which provides encrypted credential transmission, signed authorization requests, and cryptographic verification of credential authenticity.
Next steps
- Build passwordless authentication—learn how to replace passwords with cryptographic key binding using the same connector API
- KYC verification how-to guide—a task-oriented reference for KYC integration when you already know the concepts
- Use transactional data—bind contextual data to presentation requests for audit trails and consent proof
- Handle verification errors—implement robust error handling patterns for production deployments
Further reading
- DCQL query language—how to specify credential requirements and attribute selection
- Connector architecture—how the connector processes requests and delivers results
- Error codes—wallet-facing HTTP error responses
- Callback events—Presented Credentials Event statuses and payload fields
- Manage certificates—configure X.509 access certificates for production use