Skip to main content
Version: v0.25.0 (Latest)

OID4VP Integration Guide

This guide walks you through integrating the Universal OID4VP API into your application to verify credentials from digital wallets.

The integration has two boundaries. Your backend creates the verifier session, receives or polls its terminal result, and maps the verified claims into a business operation. The wallet performs QR/deep-link handling, consent, presentation, and cryptographic holder/device operations. Treat an incoming callback as an untrusted status notification until its configured delivery authentication has been verified. It is not a replacement for an authenticated read of the verifier's authoritative session state.

Prerequisites​

Before starting, ensure you have:

  1. API Access - OAuth 2.0 client credentials for the Universal OID4VP API
  2. Query ID - A pre-configured query_id from your administrator
  3. HTTPS Endpoint - Your backend must be accessible via HTTPS for callbacks

Integration Steps​

Step 1: Get Your Query ID​

Contact your administrator to obtain the query_id for your use case. Common examples:

Query IDDescription
age_verificationVerify user is over 18
identity_checkFull identity verification
license_verificationProfessional license check

The administrator configures which credentials and claims each query requests.

Step 2: Create Authorization Request​

When a user needs to verify their credentials:

interface CreateRequestOptions {
queryId: string;
correlationId?: string;
callbackUrl?: string;
}

async function createVerificationRequest(options: CreateRequestOptions) {
const response = await fetch(`${API_BASE}/oid4vp/backend/auth/requests`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${await getAccessToken()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
query_id: options.queryId,
correlation_id: options.correlationId || crypto.randomUUID(),
qr_code: {
size: 300,
color_dark: '#1a1a1a'
},
callback: options.callbackUrl ? {
url: options.callbackUrl,
statuses: ['authorization_response_verified', 'error']
} : undefined
})
});

if (!response.ok) {
throw new Error(`Failed to create request: ${response.statusText}`);
}

return response.json();
}

Step 3: Display QR Code to User​

Present the QR code to the user for wallet scanning:

<!-- Frontend: Display QR code -->
<div id="verification-container">
<h3>Scan with your wallet</h3>
<img id="qr-code" alt="Scan to verify" />
<p id="status">Waiting for wallet...</p>

<!-- Same-device flow: deep link button -->
<a id="open-wallet" class="button" style="display: none;">
Open Wallet
</a>
</div>

<script>
async function startVerification() {
const session = await createVerificationRequest({
queryId: 'age_verification',
correlationId: orderId
});

// Display QR code
document.getElementById('qr-code').src = session.qr_uri;

// Show deep link for same-device flow
const openWalletLink = document.getElementById('open-wallet');
openWalletLink.href = session.request_uri;
openWalletLink.style.display = 'inline-block';

// Start polling for status
pollForCompletion(session.correlation_id);
}
</script>

Step 4: Poll for Completion​

Poll the status endpoint until verification completes:

The create response supplies qr_uri for displaying the QR image and request_uri for opening the wallet. It also returns status_uri for polling. Keep these values with the pending business transaction. Before following a returned HTTP URL with an access token, require the expected tenant API origin; do not forward credentials to an arbitrary callback or wallet URL.

Verified claims are serialized as verified_data.credential_claims, keyed by each credential query's id. Preserve that association when more than one credential is requested; selecting the first credential can apply claims to the wrong business check. Successful protocol verification still needs your application's decision about the requested claims.

interface VerificationResult {
success: boolean;
claimsByQuery?: Record<string, Record<string, unknown>>;
error?: string;
}

async function pollForCompletion(
correlationId: string,
maxAttempts = 60,
intervalMs = 2000
): Promise<VerificationResult> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const response = await fetch(
`${API_BASE}/oid4vp/backend/auth/requests/${encodeURIComponent(correlationId)}`,
{
headers: {
'Authorization': `Bearer ${await getAccessToken()}`
}
}
);

if (!response.ok) {
throw new Error(`Status lookup failed: ${response.status}`);
}
const status = await response.json();
if (status.correlation_id !== correlationId) {
throw new Error('Status response does not match the pending verification');
}

switch (status.status) {
case 'authorization_response_verified':
if (!Array.isArray(status.verified_data?.credential_claims) ||
status.verified_data.credential_claims.length === 0) {
return { success: false, error: 'Verified credential claims are unavailable' };
}
return {
success: true,
claimsByQuery: Object.fromEntries(
(status.verified_data?.credential_claims ?? []).map(
credential => [credential.id, credential.claims ?? {}]
)
)
};

case 'error':
return {
success: false,
error: status.error?.message || 'Verification failed'
};

case 'authorization_request_retrieved':
// User scanned QR, update UI
updateStatus('User is selecting credentials...');
break;
}

await new Promise(resolve => setTimeout(resolve, intervalMs));
}

return { success: false, error: 'Verification timed out' };
}

Step 5: Handle Webhook Callbacks (Optional)​

Use callbacks as status notifications that trigger an authenticated status read. Do not approve a business operation from claims or a success flag supplied in the incoming callback. The session status notification uses the same OpenAPI wire shape as status polling: correlation_id, status, last_updated, and an optional nested error object with status, message, and error_details. It is not the verified_data.credential_claims response from the status API.

The receiver below treats the POST only as a refresh hint for an already-pending verification; it does not authenticate or trust the callback's claimed outcome. configuredTenantId comes from trusted receiver configuration, never from the request body. pendingVerifications and verificationRefreshQueue are application-owned components, not EDK APIs. Bound request size, rate-limit the receiver, and coalesce repeated notifications for the same pending transaction. If you require authenticated delivery, verify your configured ingress authentication or callback signature before this handler; a correlationId is not a credential.

import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
import io.ktor.http.HttpStatusCode

@Serializable
data class Oid4vpCallback(
@SerialName("correlation_id")
val correlationId: String,
val status: String,
@SerialName("last_updated")
val lastUpdated: Long,
val error: Oid4vpCallbackError? = null
)

@Serializable
data class Oid4vpCallbackError(
val status: Int,
val message: String,
@SerialName("error_details")
val errorDetails: String? = null
)

fun Application.configureWebhooks() {
routing {
post("/webhooks/oid4vp") {
val callback = call.receive<Oid4vpCallback>()
val pending = pendingVerifications.findForTenant(configuredTenantId, callback.correlationId)
if (pending != null && !pending.completed) {
// The worker uses the stored tenant/session binding, not a callback URL
// or claims from this request, to fetch the authoritative result.
verificationRefreshQueue.enqueueOnce(pending.id)
}
call.respond(HttpStatusCode.Accepted)
}
}
}

The refresh worker uses the authenticated polling operation above, checks the returned correlation and terminal status, and applies your business policy to the expected credential query ids. Commit the business outcome idempotently. Receiving the same callback twice must not approve an order twice. Keep bounded polling available so a missed notification does not leave the transaction pending indefinitely. Callback delivery and connector destination delivery have separate outcomes; neither is a substitute for cryptographic verification.

Step 6: Clean Up Sessions​

After verification completes or times out, clean up the session:

async function cleanupSession(correlationId: string): Promise<void> {
await fetch(
`${API_BASE}/oid4vp/backend/auth/requests/${encodeURIComponent(correlationId)}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${await getAccessToken()}`
}
}
);
}

Complete Flow Example​

Here's a complete React component demonstrating the full flow:

import React, { useState, useEffect } from 'react';

interface VerificationProps {
queryId: string;
orderId: string;
onSuccess: (claimsByQuery: Record<string, Record<string, unknown>>) => void;
onError: (error: string) => void;
}

export function CredentialVerification({
queryId,
orderId,
onSuccess,
onError
}: VerificationProps) {
const [qrCode, setQrCode] = useState<string | null>(null);
const [deepLink, setDeepLink] = useState<string | null>(null);
const [status, setStatus] = useState<string>('initializing');
const [correlationId, setCorrelationId] = useState<string | null>(null);

// Create session on mount
useEffect(() => {
async function initSession() {
try {
const session = await createVerificationRequest({
queryId,
correlationId: orderId
});

setQrCode(session.qr_uri);
setDeepLink(session.request_uri);
setCorrelationId(session.correlation_id);
setStatus('waiting');
} catch (error) {
onError('Failed to start verification');
}
}

initSession();
}, [queryId, orderId]);

// Poll for completion
useEffect(() => {
if (!correlationId || status !== 'waiting') return;

const poll = async () => {
try {
const result = await pollForCompletion(correlationId);

if (result.success) {
setStatus('success');
onSuccess(result.claimsByQuery!);
} else {
setStatus('error');
onError(result.error!);
}
} catch (error) {
setStatus('error');
onError('Polling failed');
}
};

poll();
}, [correlationId, status]);

// Cleanup on unmount
useEffect(() => {
return () => {
if (correlationId) {
cleanupSession(correlationId).catch(console.error);
}
};
}, [correlationId]);

return (
<div className="verification-container">
{status === 'initializing' && <p>Starting verification...</p>}

{status === 'waiting' && (
<>
<h3>Verify Your Credentials</h3>
{qrCode && (
<img
src={qrCode}
alt="Scan with your wallet"
className="qr-code"
/>
)}
<p>Scan with your wallet app</p>

{deepLink && (
<a href={deepLink} className="button">
Open Wallet
</a>
)}
</>
)}

{status === 'success' && (
<div className="success">Verification successful</div>
)}

{status === 'error' && (
<div className="error">Verification failed</div>
)}
</div>
);
}

Best Practices​

Security​

  1. Always use HTTPS for your callback URLs
  2. Validate webhook signatures if your implementation supports them
  3. Use short session TTLs (10 minutes is recommended)
  4. Persist an idempotent business outcome, then clean up completed sessions according to your retention policy. Deleting a session is not a substitute for protocol replay protection.

User Experience​

  1. Show status updates as the wallet interacts with the request
  2. Provide a deep link for same-device flows
  3. Handle timeouts gracefully with clear messaging
  4. Allow retry if verification fails

Performance​

  1. Use webhooks instead of polling when possible
  2. Cache access tokens to avoid repeated OAuth flows

Troubleshooting​

Common Issues​

IssueCauseSolution
QR code not scanningWrong URL schemeEnsure wallet supports openid4vp://
Wallet shows errorInvalid query configurationContact administrator
TimeoutUser abandoned flowImplement retry mechanism
No verified dataWrong status checkPoll until authorization_response_verified

Debug Mode​

Enable verbose logging to diagnose issues:

// In your application configuration
sphereon.oid4vp.debug = true

Next Steps​