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:
- API Access - OAuth 2.0 client credentials for the Universal OID4VP API
- Query ID - A pre-configured
query_idfrom your administrator - 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 ID | Description |
|---|---|
age_verification | Verify user is over 18 |
identity_check | Full identity verification |
license_verification | Professional 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:
- TypeScript
- Kotlin
- Python
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();
}
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.serialization.json.*
suspend fun createVerificationRequest(
client: HttpClient,
queryId: String,
correlationId: String? = null,
callbackUrl: String? = null
): CreateAuthorizationRequestOutput {
val response = client.post("$apiBase/oid4vp/backend/auth/requests") {
bearerAuth(getAccessToken())
contentType(ContentType.Application.Json)
setBody(buildJsonObject {
put("query_id", queryId)
correlationId?.let { put("correlation_id", it) }
putJsonObject("qr_code") {
put("size", 300)
}
callbackUrl?.let {
putJsonObject("callback") {
put("url", it)
putJsonArray("statuses") {
add("authorization_response_verified")
add("error")
}
}
}
})
}
return response.body()
}
import requests
import uuid
def create_verification_request(
query_id: str,
correlation_id: str = None,
callback_url: str = None
) -> dict:
"""Create an OID4VP authorization request."""
payload = {
"query_id": query_id,
"correlation_id": correlation_id or str(uuid.uuid4()),
"qr_code": {
"size": 300,
"color_dark": "#1a1a1a"
}
}
if callback_url:
payload["callback"] = {
"url": callback_url,
"statuses": ["authorization_response_verified", "error"]
}
response = requests.post(
f"{API_BASE}/oid4vp/backend/auth/requests",
headers={
"Authorization": f"Bearer {get_access_token()}",
"Content-Type": "application/json"
},
json=payload
)
response.raise_for_status()
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.
- TypeScript
- Kotlin
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' };
}
import kotlinx.coroutines.delay
import com.sphereon.openid.oid4vp.universal.GetAuthorizationRequestStatusOutput
import com.sphereon.openid.oid4vp.verifier.model.AuthorizationSessionStatus
import kotlinx.serialization.json.JsonElement
sealed class VerificationResult {
data class Success(val claimsByQuery: Map<String, Map<String, JsonElement>>) : VerificationResult()
data class Error(val message: String) : VerificationResult()
object Timeout : VerificationResult()
}
suspend fun pollForCompletion(
client: HttpClient,
correlationId: String,
maxAttempts: Int = 60,
intervalMs: Long = 2000
): VerificationResult {
repeat(maxAttempts) {
val response = client.get(
"$apiBase/oid4vp/backend/auth/requests/$correlationId"
) {
bearerAuth(getAccessToken())
}
val status: GetAuthorizationRequestStatusOutput = response.body()
require(status.correlationId == correlationId) {
"Status response does not match the pending verification"
}
when (status.status) {
AuthorizationSessionStatus.AUTHORIZATION_RESPONSE_VERIFIED -> {
val credentials = status.verifiedData?.credentialClaims
if (credentials.isNullOrEmpty()) {
return VerificationResult.Error("Verified credential claims are unavailable")
}
return VerificationResult.Success(
credentials.associate {
it.id to it.claims.orEmpty()
}
)
}
AuthorizationSessionStatus.ERROR -> {
return VerificationResult.Error(
status.error?.message ?: "Verification failed"
)
}
else -> Unit
}
delay(intervalMs)
}
return VerificationResult.Timeout
}
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
- Always use HTTPS for your callback URLs
- Validate webhook signatures if your implementation supports them
- Use short session TTLs (10 minutes is recommended)
- 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
- Show status updates as the wallet interacts with the request
- Provide a deep link for same-device flows
- Handle timeouts gracefully with clear messaging
- Allow retry if verification fails
Performance
- Use webhooks instead of polling when possible
- Cache access tokens to avoid repeated OAuth flows
Troubleshooting
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| QR code not scanning | Wrong URL scheme | Ensure wallet supports openid4vp:// |
| Wallet shows error | Invalid query configuration | Contact administrator |
| Timeout | User abandoned flow | Implement retry mechanism |
| No verified data | Wrong status check | Poll until authorization_response_verified |
Debug Mode
Enable verbose logging to diagnose issues:
// In your application configuration
sphereon.oid4vp.debug = true
Next Steps
- Interactive API Docs - Full OpenAPI documentation
- OID4VP Overview - Architecture and concepts