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 the callback as an authenticated, correlated event—not as a replacement for 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'],
include_verified_data: true
} : 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")
}
put("include_verified_data", true)
}
}
})
}
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"],
"include_verified_data": True
}
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_code_data_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:
- TypeScript
- Kotlin
interface VerificationResult {
success: boolean;
claims?: Record<string, any>;
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/${correlationId}`,
{
headers: {
'Authorization': `Bearer ${await getAccessToken()}`
}
}
);
const status = await response.json();
switch (status.status) {
case 'authorization_response_verified':
return {
success: true,
claims: status.verified_data?.credentials?.[0]?.claims
};
case 'error':
return {
success: false,
error: status.error?.description || '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
sealed class VerificationResult {
data class Success(val claims: Map<String, Any>) : 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: AuthorizationStatus = response.body()
when (status.status) {
"authorization_response_verified" -> {
return VerificationResult.Success(
status.verifiedData?.credentials?.firstOrNull()?.claims ?: emptyMap()
)
}
"error" -> {
return VerificationResult.Error(
status.error?.description ?: "Verification failed"
)
}
}
delay(intervalMs)
}
return VerificationResult.Timeout
}
Step 5: Handle Webhook Callbacks (Optional)
For server-to-server notification, implement a webhook handler:
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
@Serializable
data class Oid4vpCallback(
val correlationId: String,
val status: String,
val queryId: String?,
val verifiedData: VerifiedData? = null,
val error: CallbackError? = null
)
@Serializable
data class VerifiedData(val credentials: List<VerifiedCredential>)
@Serializable
data class VerifiedCredential(
val id: String,
val format: String,
val type: String?,
val claims: Map<String, JsonElement>
)
@Serializable
data class CallbackError(
val code: String,
val description: String?
)
fun Application.configureWebhooks() {
routing {
post("/webhooks/oid4vp") {
val callback = call.receive<Oid4vpCallback>()
when (callback.status) {
"authorization_response_verified" -> {
val claims = callback.verifiedData
?.credentials
?.firstOrNull()
?.claims
// Process verified claims
processVerifiedClaims(callback.correlationId, claims)
}
"error" -> {
handleVerificationError(
callback.correlationId,
callback.error?.description
)
}
}
call.respond(HttpStatusCode.OK)
}
}
}
private suspend fun processVerifiedClaims(
correlationId: String,
claims: Map<String, JsonElement>?
) {
// Update your database with verified data
val ageOver18 = claims?.get("age_over_18")
?.jsonPrimitive
?.booleanOrNull
if (ageOver18 == true) {
orderService.approveOrder(correlationId)
} else {
orderService.rejectOrder(correlationId, "Age verification failed")
}
}
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/${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: (claims: Record<string, any>) => 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_code_data_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.claims!);
} 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)
- Clean up sessions after completion to prevent replay attacks
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