Integration Guide — espuni
This guide is for developers integrating EU age verification (OID4VP / DC API) into their applications using espuni as the service layer.
Base URL: https://api.espuni.com
Interactive reference: api.espuni.com/api-docs — Swagger UI with all three public endpoints.
0. Node.js SDK (@espuni/node)
If your backend is Node.js or TypeScript, the official SDK reduces integration to a few lines. It handles OAuth2 tokens automatically and exposes typed methods for all three public endpoints.
npm install @espuni/nodeimport { EspuniClient } from '@espuni/node';
const client = new EspuniClient({
clientId: process.env.ESPUNI_CLIENT_ID,
clientSecret: process.env.ESPUNI_CLIENT_SECRET,
});
// 1. Create a verification session
const { sessionId, crossDeviceUri } = await client.createSession({
webhookUrl: 'https://your-app.com/webhooks/av',
});
// Render crossDeviceUri as a QR code for the user
// 2. Receive the result (webhook — Express example)
app.post('/webhooks/av', express.json(), (req, res) => {
const event = client.parseWebhookPayload(req.body);
// event.verified → true | false
// event.claims → { age_over_18: true }
res.sendStatus(200);
});
// 3. Or poll session status
const result = await client.getSession(sessionId);
// result.status → 'pending' | 'completed' | 'failed' | 'expired'
// result.claims → { age_over_18: true }The SDK is available at npmjs.com/@espuni/node. The rest of this guide documents the underlying HTTP API — useful for other languages and for understanding what the SDK does internally.
1. Registration and credentials
Registration is a two-step process:
Step 1 — Create account: Go to app.espuni.com/register, fill in email, password, and organisation name, and accept the Terms of Service. You'll receive a verification email.
Step 2 — Verify email: Click the link in the email. This provisions your isolated tenant and OAuth2 client, and returns your API credentials:
{
"credentials": {
"clientId": "cp-a1b2c3d4",
"clientSecret": "...",
"tokenEndpoint": "https://api.espuni.com/api/oauth2/token",
"apiUrl": "https://api.espuni.com"
}
}The
clientSecretis shown only once. Store it in a secrets manager.
2. Get an access token
Tokens are short-lived. Obtain a new one before each batch of API calls (or implement caching with a 30s buffer before expiry):
POST /api/oauth2/token
Authorization: Basic base64(clientId:clientSecret)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentialsJSON body is also accepted:
POST /api/oauth2/token
Content-Type: application/json
{ "client_id": "cp-a1b2c3d4", "client_secret": "..." }Response:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 86400
}Use the access_token in the Authorization: Bearer <token> header for all subsequent calls.
2b. Sandbox / Testing environment
espuni includes a sandbox environment for testing your integration without consuming plan quota.
Use requestId: "age-verification-sandbox" instead of "age-verification". The rest of the integration is identical.
POST /api/verifier/offer
Authorization: Bearer <token>
{
"requestId": "age-verification-sandbox",
"response_type": "uri",
"webhook": { "url": "https://tu-app.com/webhook", "auth": { "type": "none" } }
}What's different in sandbox:
- Sessions are not counted against your monthly plan quota.
- Credentials issued by acceptance-environment issuers are accepted (not production issuers).
- The webhook is still delivered — you can test your handler end-to-end.
- Sessions appear in your dashboard with a "Sandbox" badge for easy identification.
- Usage limit: {100 sessions/day, 10 sessions/min} per tenant.
How to get a test credential:
- Install the EU AV Reference App on Android.
- Get a Proof-of-Age credential in the app itself, following the official EU AV Blueprint instructions.
- Use the app to scan the QR from your sandbox integration.
When ready for production, change requestId to "age-verification". Everything else in your integration stays exactly the same.
3. Create a verification offer
3a. OID4VP (QR / deep link)
The universal flow: generate a QR code that the user scans with their EUDI Wallet.
- 1
POST /api/oauth2/tokenBasic Auth (clientId:clientSecret) → Bearer token, cache it until expiry - 2
POST /api/verifier/offer{ requestId, response_type: "uri", webhook } - 3
{ session, uri, crossDeviceUri } - 4Show the QR (crossDeviceUri) or the same-device button (uri, av:// scheme)
- 5User scans/opens and approvesthe app fetches the request object and posts the vp_token via direct_post
- 6Validates the mdoc signature and the issuer against the AV Trusted List+ metering and SessionLog (session evidence)
- 7POST to your webhook{ age_over_18 } · retries 10s → 12h · alternative: GET /api/session/:id
POST /api/verifier/offer
Authorization: Bearer <access_token>
Content-Type: application/json
{
"requestId": "age-verification",
"response_type": "uri",
"webhook": {
"url": "https://your-server.com/webhooks/av",
"auth": { "type": "none" }
}
}Response (201):
{
"session": "abc123",
"uri": "openid4vp://...",
"crossDeviceUri": "https://eudiplo.espuni.com/vp/..."
}| Field | Usage |
|---|---|
session | Session ID for status polling |
crossDeviceUri | URL to generate the QR (cross-device) |
uri | Deep link to open directly in the wallet on the same device |
The uri uses the av:// scheme defined by the EU AV Blueprint for same-device wallet opening. The AV reference wallet requires this scheme; other wallets may also accept openid4vp://.
3b. DC API ISO 18013-7 (browser-native, no QR)
Flow for wallets conforming to the EU AV Blueprint (ISO 18013-7 Annex C), such as the EU AV Reference App. The wallet responds with an HPKE-encrypted CBOR payload that espuni decrypts and verifies server-side — your integration never touches cryptography.
- 1
POST /api/verifier/offer{ requestId, response_type: "iso-18013-7" } - 2
{ session, dc_api }org_iso_mdoc: device_request + encryption_info - 3Pass dc_api and session to the frontend
- 4
navigator.credentials.get()espuni-av.js builds the request for you - 5OS shows the credential picker; user approves with PIN/biometricsthe app responds inline with HPKE-encrypted CBOR
- 6Send the response to your backend
{ protocol, data } - 7
POST /api/session/:id/presentationor SDK: submitDcResponse() - 8Decrypts (HPKE) and validates mdoc + Trusted List+ metering and SessionLog
- 9POST to your webhook{ age_over_18 } · or polling GET /api/session/:id
POST /api/verifier/offer
Authorization: Bearer <access_token>
Content-Type: application/json
{
"requestId": "age-verification",
"response_type": "iso-18013-7",
"webhook": {
"url": "https://your-server.com/webhooks/av",
"auth": { "type": "none" }
}
}Response (201):
{
"session": "abc123",
"uri": "av://...",
"org_iso_mdoc": {
"device_request": "o2d...",
"encryption_info": "g2R..."
}
}| Field | Usage |
|---|---|
session | Session ID for polling and webhook |
org_iso_mdoc.device_request | ISO 18013-5 DeviceRequest in CBOR base64url — pass to frontend |
org_iso_mdoc.encryption_info | Public COSE_Key to encrypt the wallet response — pass to frontend |
With the SDK (§0), the backend reduces to:
// ISO 18013-7 DC API flow — browser-native, no QR
const session = await client.createSession({
responseType: 'iso-18013-7',
webhookUrl: 'https://your-app.com/webhooks/av',
});
// session.orgIsoMdoc = { deviceRequest, encryptionInfo }
// Pass these to the browser to drive navigator.credentials.get({ digital: {...} })
// Your frontend POSTs the HPKE-encrypted result to your backend:
app.post('/verify/dc-response/:id', async (req, res) => {
// Forwards the encrypted wallet response to espuni for decryption + verification
await client.submitDcResponse(req.params.id, req.body.data, req.body.protocol);
res.json({ ok: true });
// Result arrives at your webhook (same as QR flow)
});If you call the DC API directly from the frontend (without espuni-av.js):
// 1. Get the org-iso-mdoc data from your backend
const session = await fetch('/start-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dcApi: true }),
}).then(r => r.json());
// session.presentationRequest.dc_api.org_iso_mdoc = { device_request, encryption_info }
// 2. Invoke the Digital Credentials API (ISO 18013-7 Annex C)
const result = await navigator.credentials.get({
digital: {
requests: [{
protocol: 'org-iso-mdoc',
data: session.presentationRequest.dc_api.org_iso_mdoc,
}]
}
});
// result.data is the HPKE-encrypted CBOR EncryptedResponse — forward it to your backend
// 3. Forward to your backend for proxying to espuni
await fetch(session.dcApiResponseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ protocol: 'org-iso-mdoc', data: result.data }),
});
// espuni decrypts and verifies the mDoc — result arrives at your webhookNote:
espuni-av.jsautomatically handles the DC API call, binary payload normalization to base64url, and forwarding to your backend. See §6.
4. Receive the result (webhook)
When the user completes verification, espuni calls the webhook URL you specified in the offer:
POST https://your-server.com/webhooks/av
Content-Type: application/json
{
"session": "abc123",
"verified": true,
"claims": {
"age_over_18": true
}
}Your endpoint must respond with 2xx within 10 seconds.
Retry behaviour: if your endpoint is unreachable or returns a non-2xx status, espuni retries the delivery with exponential backoff — up to 7 total attempts:
| Attempt | Delay after previous |
|---|---|
| 1 (initial) | immediate |
| 2 | 10 seconds |
| 3 | 60 seconds |
| 4 | 5 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 12 hours |
This means espuni tolerates outages of up to ~15 hours on your webhook server. Make your webhook handler idempotent — the same session ID may be delivered more than once.
Webhook authentication
Protect your endpoint with an API key:
// Bearer token (recommended)
"auth": { "type": "bearer", "token": "your-secret-token" }
// Custom header
"auth": { "type": "apiKey", "config": { "headerName": "X-Webhook-Secret", "headerValue": "your-secret" } }
// No auth
"auth": { "type": "none" }5. Query session status
As an alternative or complement to webhooks, poll the session status:
GET /api/session/{sessionId}
Authorization: Bearer <access_token>Response:
{
"sessionId": "abc123",
"status": "completed",
"verified": true,
"claims": {
"age_over_18": true
},
"errorReason": null
}Possible statuses: pending · completed · failed · expired
Sessions expire after 300 seconds. Once in a terminal state (completed, failed, expired) the status no longer changes.
6. Ready-to-use snippet: espuni-av.js
If you're integrating directly in a web page, the espuni-av.js snippet automatically handles DC API detection, the OID4VP QR flow, and SSE events — with no external dependencies.
Load
<script src="https://app.espuni.com/espuni-av.js"></script>Usage
Your backend creates the session with POST /api/verifier/offer and returns the result to the frontend. Then call espuni.verify():
Backend (Node.js example):
import { EspuniClient } from '@espuni/node';
const client = new EspuniClient({ clientId: process.env.ESPUNI_CLIENT_ID, clientSecret: process.env.ESPUNI_CLIENT_SECRET });
// Create session
app.post('/start-verification', async (req, res) => {
const useDcApi = req.body.dcApi === true;
const session = await client.createSession({
responseType: useDcApi ? 'iso-18013-7' : 'uri',
webhookUrl: 'https://your-server.com/webhooks/av',
});
if (useDcApi) {
res.json({
sessionId: session.sessionId,
presentationRequest: {
dc_api: { org_iso_mdoc: session.orgIsoMdoc },
},
dcApiResponseUrl: `/verify/dc-response/${session.sessionId}`,
});
} else {
res.json({
sessionId: session.sessionId,
presentationRequest: {
oid4vp: {
qr_url: session.crossDeviceUri,
events_url: `/verify/events/${session.sessionId}`,
},
},
});
}
});
// Forward DC API wallet response to espuni
app.post('/verify/dc-response/:id', async (req, res) => {
await client.submitDcResponse(req.params.id, req.body.data, req.body.protocol);
res.json({ ok: true });
});
// SSE session status stream
app.get('/verify/events/:id', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
const poll = setInterval(async () => {
const token = await getAccessToken();
const s = await fetch(
`https://api.espuni.com/api/session/${req.params.id}`,
{ headers: { Authorization: `Bearer ${token}` } }
).then(r => r.json());
res.write(`data: ${JSON.stringify(s)}\n\n`);
if (['completed','failed','expired'].includes(s.status)) {
clearInterval(poll); res.end();
}
}, 1000);
req.on('close', () => clearInterval(poll));
});Frontend:
<div id="qr-container"></div>
<script src="https://app.espuni.com/espuni-av.js"></script>
<script>
async function startVerification() {
const useDcApi = espuni.hasDcApi();
const session = await fetch('/start-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dcApi: useDcApi }),
}).then(r => r.json());
espuni.verify({
sessionId: session.sessionId,
presentationRequest: session.presentationRequest,
dcApiResponseUrl: session.dcApiResponseUrl,
container: document.getElementById('qr-container'),
onSuccess: function(result) {
// QR: result.claims = { age_over_18: true }
// DC API: result = { ok: true } — claims arrive at your webhook
showVerifiedUI();
},
onFailure: function(reason) {
console.error('Failed:', reason);
},
});
}
</script>espuni.verify(opts) reference
| Field | Type | Required | Description |
|---|---|---|---|
sessionId | string | yes | Session ID returned by your backend |
presentationRequest | object | yes | Object with dc_api or oid4vp depending on mode |
dcApiResponseUrl | string | DC API | Your backend URL that forwards the VP to espuni |
container | string | Element | OID4VP | CSS selector or element to render the QR into |
onSuccess | function | no | Callback with { claims } on completion |
onFailure | function | no | Callback with { error, status? } on error/cancellation |
7. Limits and billing
espuni uses a usage-based pricing model. The free tier includes 100 verifications per month, with no credit card or contract. For production, pricing scales with your volume — contact us for a quote.
What counts as a billable verification: a verification.completed event is recorded only when espuni receives a credential presentation result. Sessions that expire before the user responds, or errors that occur before credential presentation, are not counted or billed.
When the hard limit is reached, POST /api/verifier/offer returns:
403 Forbidden
{
"message": "Monthly verification limit reached (100/100). Upgrade your plan to continue."
}8. Known limitations
DC API: org-iso-mdoc protocol
The DC API flow documented in §3b uses the org-iso-mdoc protocol (ISO 18013-7 Annex C), as defined by the EU AV Blueprint for AV-profile wallets. The wallet encrypts its response with HPKE (RFC 9180) using the public key in encryption_info — espuni holds the corresponding private key and decrypts the mDoc server-side. Your integration receives the verified claim (age_over_18: true) via webhook, same as the QR flow.
DC API flow availability depends on wallet support: the EU AV Reference App implements it; national EUDI wallets are progressively adding it. The QR flow (§3a) is the universal fallback compatible with all wallets.
9. Common errors
| Code | Cause | Solution |
|---|---|---|
401 on /api/oauth2/token | Wrong credentials | Check clientId and clientSecret |
403 Account suspended | Account suspended | Contact support |
403 Monthly limit reached | Monthly verification limit reached | Upgrade plan from the dashboard |
503 on your start proxy | Missing environment variables | Check your CLIENT_ID / CLIENT_SECRET env vars on the start proxy |
DC API: 'Failed to convert value' | data sent as JWT string | Make sure you pass the decoded JSON object, not the raw JWT |
DC API: NotAllowedError | User dismissed the prompt | Expected; treat as cancellation, not an error |
Webhook not received | URL not publicly reachable | Use a tunnel (ngrok, Cloudflare Tunnel) in development |
10. Complete minimal example (curl)
# 1. Token
TOKEN=$(curl -s -X POST https://api.espuni.com/api/oauth2/token \
-H "Authorization: Basic $(echo -n 'cp-a1b2c3d4:your-secret' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'grant_type=client_credentials' | jq -r .access_token)
# 2. Create offer
OFFER=$(curl -s -X POST https://api.espuni.com/api/verifier/offer \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"requestId": "age-verification",
"response_type": "uri",
"webhook": { "url": "https://webhook.site/your-id", "auth": { "type": "none" } }
}')
SESSION=$(echo $OFFER | jq -r .session)
QR_URL=$(echo $OFFER | jq -r .crossDeviceUri)
echo "Session: $SESSION"
echo "QR URL: $QR_URL"
# 3. Poll status
curl -s https://api.espuni.com/api/session/$SESSION \
-H "Authorization: Bearer $TOKEN" | jq .11. Reference implementations
espuni implements the EU Age Verification Blueprint. You can compare our implementation against the official reference code:
- Technical specification
- Android reference app
- iOS reference app
- Reference issuer (OID4VCI)
- Reference verifier UI
12. Support
| Channel | Address | Language |
|---|---|---|
| Technical support | support@espuni.com | English |
| Soporte técnico | soporte@espuni.com | Español |
| Security reports | security@espuni.com | English |
| Legal / compliance | legal@espuni.com | EN / ES |
Security disclosures follow the RFC 9116 standard — see /.well-known/security.txt.