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/node
import { 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 clientSecret is 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_credentials

JSON 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:

  1. Install the EU AV Reference App on Android.
  2. Get a Proof-of-Age credential in the app itself, following the official EU AV Blueprint instructions.
  3. 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.

Your web / backend
espuni
AV App / Wallet
  1. 1POST /api/oauth2/tokenBasic Auth (clientId:clientSecret) → Bearer token, cache it until expiry
  2. 2POST /api/verifier/offer{ requestId, response_type: "uri", webhook }
  3. 3{ session, uri, crossDeviceUri }
  4. 4Show the QR (crossDeviceUri) or the same-device button (uri, av:// scheme)
  5. 5User scans/opens and approvesthe app fetches the request object and posts the vp_token via direct_post
  6. 6Validates the mdoc signature and the issuer against the AV Trusted List+ metering and SessionLog (session evidence)
  7. 7POST to your webhook{ age_over_18 } · retries 10s → 12h · alternative: GET /api/session/:id
The full OID4VP flow. Your integration is steps 1, 2, 4 and 7 — cryptography and trust validation happen inside espuni.
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/..."
}
FieldUsage
sessionSession ID for status polling
crossDeviceUriURL to generate the QR (cross-device)
uriDeep 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.

Your frontend
Browser + AV app
Your backend
espuni
  1. 1POST /api/verifier/offer{ requestId, response_type: "iso-18013-7" }
  2. 2{ session, dc_api }org_iso_mdoc: device_request + encryption_info
  3. 3Pass dc_api and session to the frontend
  4. 4navigator.credentials.get()espuni-av.js builds the request for you
  5. 5OS shows the credential picker; user approves with PIN/biometricsthe app responds inline with HPKE-encrypted CBOR
  6. 6Send the response to your backend{ protocol, data }
  7. 7POST /api/session/:id/presentationor SDK: submitDcResponse()
  8. 8Decrypts (HPKE) and validates mdoc + Trusted List+ metering and SessionLog
  9. 9POST to your webhook{ age_over_18 } · or polling GET /api/session/:id
The full DC API flow. Your integration is steps 1, 3, 4 and 6-7 — HPKE decryption and mdoc validation happen inside espuni, never in your code.
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..."
  }
}
FieldUsage
sessionSession ID for polling and webhook
org_iso_mdoc.device_requestISO 18013-5 DeviceRequest in CBOR base64url — pass to frontend
org_iso_mdoc.encryption_infoPublic 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 webhook

Note: espuni-av.js automatically 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:

AttemptDelay after previous
1 (initial)immediate
210 seconds
360 seconds
45 minutes
530 minutes
62 hours
712 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

FieldTypeRequiredDescription
sessionIdstringyesSession ID returned by your backend
presentationRequestobjectyesObject with dc_api or oid4vp depending on mode
dcApiResponseUrlstringDC APIYour backend URL that forwards the VP to espuni
containerstring | ElementOID4VPCSS selector or element to render the QR into
onSuccessfunctionnoCallback with { claims } on completion
onFailurefunctionnoCallback 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

CodeCauseSolution
401 on /api/oauth2/tokenWrong credentialsCheck clientId and clientSecret
403 Account suspendedAccount suspendedContact support
403 Monthly limit reachedMonthly verification limit reachedUpgrade plan from the dashboard
503 on your start proxyMissing environment variablesCheck your CLIENT_ID / CLIENT_SECRET env vars on the start proxy
DC API: 'Failed to convert value'data sent as JWT stringMake sure you pass the decoded JSON object, not the raw JWT
DC API: NotAllowedErrorUser dismissed the promptExpected; treat as cancellation, not an error
Webhook not receivedURL not publicly reachableUse 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:


12. Support

ChannelAddressLanguage
Technical supportsupport@espuni.comEnglish
Soporte técnicosoporte@espuni.comEspañol
Security reportssecurity@espuni.comEnglish
Legal / compliancelegal@espuni.comEN / ES

Security disclosures follow the RFC 9116 standard — see /.well-known/security.txt.