Version: v0.13
OID4VP Integration Guide
This guide walks you through integrating the Universal OID4VP API into your application to verify credentials from digital wallets.
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