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 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, without a webhook, await the result (polls until terminal)
const result = await client.waitForResult(sessionId);
// result.status    → 'completed' | 'failed' | 'expired'
// result.ageOver18 → true | false
// (client.getSession(sessionId) gives a single, non-blocking snapshot)

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, org_iso_mdoc }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. Ask your backend for an ISO 18013-7 (org-iso-mdoc) session
const session = await fetch('/start-verification', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ protocol: 'dc-api-iso' }),
}).then(r => r.json());
// session.orgIsoMdoc = { deviceRequest, encryptionInfo }

// 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.orgIsoMdoc }]
  }
});
// result.data is the HPKE-encrypted CBOR EncryptedResponse — forward it to your backend

// 3. Forward to your backend for proxying to espuni
await fetch(`/verify/dc-response/${session.sessionId}`, {
  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

// Verificación correcta
{
  "session": "abc123",
  "verified": true,
  "claims": { "age_over_18": true },
  "failureCode": null,
  "failureReason": null
}

// Verificación rechazada — también llega a tu webhook
{
  "session": "abc123",
  "verified": false,
  "claims": null,
  "failureCode": "trust_chain_not_trusted",
  "failureReason": "The credential issuer is not in the trusted list."
}

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
  },
  "failureCode": null,
  "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.

Sessions from ZK verification add a zk block with the same verdict POST /api/zk/response returned — so polling tells you which layer failed, not just that it was rejected:

{
  "sessionId": "zk_9f2c...",
  "status": "failed",
  "verified": false,
  "claims": null,
  "failureCode": "trust_chain_not_trusted",
  "errorReason": "issuer not on the AV Trusted List (untrusted Document Signer)",
  "zk": {
    "accepted": false,
    "cryptoVerified": true,
    "fresh": true,
    "issuerTrusted": false,
    "issuerSubject": "CN=...",
    "zkSystemId": "longfellow-libzk-v1",
    "verifyMs": 317,
    "proofBytes": 353102,
    "trustList": {
      "url": "https://ec.europa.eu/.../av-trust-list.xml",
      "env": "production"
    }
  }
}

5b. Zero-Knowledge verification (ZK)

In the Zero-Knowledge flow the wallet does not send the credential: it sends a cryptographic proof that its holder is over 18. This is the EU AV Blueprint ZKP profile (Annex A §A.8), and espuni acts as the verifier — no intermediate step and no webhook needed for the result: the verdict comes back synchronously.

Requirement: the proof travels over the browser Digital Credentials API (Chrome M130+). Unlike classic verification, there is no QR fallback: a browser without the DC API cannot do ZK.

Try it before integrating: the demo runs this exact path if you pick ZKP as the proof type (it goes against the sandbox, so it does not consume quota). And if you want to take the verifier apart — official test vectors, on-the-fly issuance, downloadable evidence you can re-verify outside our infrastructure — there is the ZK Lab.

5b.1. Create the request

POST /api/zk/request
Authorization: Bearer <token>

{
  "origin": "https://tu-app.com",
  "requestId": "age-verification",
  "webhook": { "url": "https://tu-app.com/webhook", "auth": { "type": "none" } }
}

origin is required and must be the browser origin that will call navigator.credentials.get(). It is bound into the ISO 18013-7 SessionTranscript, so a wrong value makes the wallet response impossible to decrypt.

requestId is optional (defaults to your default verifier config). Its trusted_authorities decides which EU AV Trusted List the issuer is checked against — the same field as in classic verification.

Response:

{
  "session": "zk_9f2c...",
  "expiresAt": "2026-08-15T12:10:00.000Z",
  "digitalRequest": {
    "protocol": "org-iso-mdoc",
    "data": { "deviceRequest": "...", "encryptionInfo": "..." }
  }
}

5b.2. Ask the wallet for the proof

With the browser SDK (@espuni/browser 0.2+) it is a few lines. The SDK never sees your credentials: it calls your backend, which is what talks to espuni.

import { verifyZk, hasZk } from '@espuni/browser';

if (!hasZk()) {
  // Sin DC API no hay ZK: usa la verificación clásica (QR / deep link).
}

verifyZk({
  createSession: async () => {
    const r = await fetch('/api/zk/start', { method: 'POST' });
    const { session, digitalRequest } = await r.json();
    return { session, digitalRequest, submitUrl: '/api/zk/submit' };
  },
  onVerdict: (v) => {
    // Ojo: un rechazo también llega por aquí. Comprueba v.verified.
    if (v.verified) grantAccess();
    else showRejected(v.zk.detail);
  },
  onFailure: (e) => console.warn('ZK no completado:', e.error),
});

On a computer, Chrome resolves the DC API through the cross-device flow (QR + CTAP tunnel to the phone). Measured on 2026-08-30 with a real phone: the presentation gets as far as the user approving it and then fails on the way back — a ZK proof is ~360 KB and that tunnel is sized for FIDO-assertion payloads. Selective disclosure, a few KB, does go through. Offer ZK on mobile and keep the classic OID4VP+QR flow for desktop.

5b.3. Submit the response and get the verdict

POST /api/zk/response
Authorization: Bearer <token>

{ "session": "zk_9f2c...", "response": "<data de la DC API en base64url>" }
{
  "session": "zk_9f2c...",
  "verified": true,
  "claims": { "age_over_18": true },
  "zk": {
    "cryptoVerified": true,
    "fresh": true,
    "issuerTrusted": true,
    "issuerSubject": "CN=...",
    "verifyMs": 317,
    "detail": "verification successful; issuer trusted (fingerprint)",
    "trustList": { "url": "https://ec.europa.eu/.../av-trust-list.xml", "env": "production" }
  }
}

The verdict has three layers, and verified is true only if all three hold: the proof verifies (cryptoVerified), its signed timestamp is recent (fresh, ±5 min) and the issuer is on the EU AV Trusted List (issuerTrusted). A cryptographically valid proof from an issuer that is not on the list is rejected.

trustList says which list of issuers judged the session — the one your verifier config declared, frozen when the request was created, so editing the config between /request and /response does not change the verdict. It travels with the evidence because it is what makes a "yes" auditable.

A rejection also carries a stable failureCode on the session (GET /api/session/{id}) so you can group by layer: zk_proof_invalid · zk_proof_stale · trust_chain_not_trusted · trust_list_unavailable. detail is the human-readable message; the code is the part that does not change.

The session is single use: replaying the same response returns 404. If you registered a webhook, the same verdict is delivered there too (with the usual retry queue), which is handy if you prefer to handle it in your backend.

5b.4. Quota and billing — read this

ZK verification is metered on its own counter, with its own monthly limit, independent of the classic verification one. See it in the dashboard: Verification tab, second bar of the Overview card ("ZK verification"). Sessions and quality metrics are merged with the classic ones; the counter is not.

Every verdict is billed, rejections included. This is the important difference from the classic flow, where only completed sessions are charged: verifying an invalid proof costs the same CPU as verifying a valid one. What is not counted are transport errors — unknown or expired session, a response that cannot be decrypted — because the verifier never ran.

To test without spending quota, use requestId: "age-verification-sandbox": it validates against the acceptance Trusted List, consumes no quota, still delivers the webhook, and is rate limited to 10/min · 100/day.

Specific errors: 429 with { "error": "busy" } means the verifier is saturated — retry in a few seconds. 503 means the Trusted List could not be loaded and your account requires issuer checking; neither consumes quota.


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

espuni-av.js is the @espuni/browser package served as a window.espuni global. There are three ways to load it, depending on your project:

1. First-party (latest)served from espuni's own domain, always the latest version. No build step, and easy to allow under a strict CSP (same origin). Best for prototypes and integrations without a toolchain.

<!-- First-party, always latest. No build step. Easiest to allow in a strict CSP. -->
<script src="https://app.espuni.com/espuni-av.js"></script>

2. Public CDN (version-pinned)jsDelivr/unpkg serve any published version of @espuni/browser. Pin an exact version so an SDK change never reaches your production site on its own. Requires allowing cdn.jsdelivr.net in your CSP.

<!-- Public CDN, version-pinned. Recommended for production. -->
<script src="https://cdn.jsdelivr.net/npm/@espuni/browser@0.2.0/dist/index.global.js"></script>

3. npm (bundled projects)if you use a bundler (React, Vue, Vite…), install the package and import verify. The signature is identical to espuni.verify(opts).

npm install @espuni/browser
# then, in a bundled app:
# import { verify } from '@espuni/browser'

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 });

// One session per protocol the browser SDK asks for ('dc-api-iso' | 'oid4vp')
app.post('/start-verification', async (req, res) => {
  const isDcApi = req.body.protocol === 'dc-api-iso';
  const session = await client.createSession({
    responseType: isDcApi ? 'iso-18013-7' : 'uri',
    webhookUrl: 'https://your-server.com/webhooks/av',
  });
  res.json({
    sessionId: session.sessionId,
    uri: session.uri,                       // OID4VP same-device deeplink
    crossDeviceUri: session.crossDeviceUri, // OID4VP QR
    orgIsoMdoc: session.orgIsoMdoc,         // present for iso-18013-7
  });
});

// Forward the 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 });
});

// Session status the browser SDK polls (returns { status, claims, ageOver18, ... })
app.get('/verify/session/:id', async (req, res) => {
  res.json(await client.getSession(req.params.id));
});

Frontend:

<div id="qr-container"></div>

<script src="https://app.espuni.com/espuni-av.js"></script>
<script>
function startVerification() {
  // espuni (@espuni/browser) drives the whole flow: it picks DC API when the
  // browser supports it, falls back to OID4VP/QR, renders the QR, and resolves
  // via your poll URL. You only map your backend's response to a SessionBundle.
  espuni.verify({
    // Called per protocol: 'dc-api-iso' first when supported, then 'oid4vp'.
    createSession: async (protocol) => {
      const s = await fetch('/start-verification', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ protocol }),
      }).then(r => r.json());
      return {
        session: {
          sessionId: s.sessionId,
          uri: s.uri,                     // OID4VP same-device deeplink
          crossDeviceUri: s.crossDeviceUri, // OID4VP QR
          orgIsoMdoc: s.orgIsoMdoc,       // DC API (ISO 18013-7)
        },
        dcApiSubmitUrl: `/verify/dc-response/${s.sessionId}`,
        pollUrl: `/verify/session/${s.sessionId}`,
      };
    },
    container: document.getElementById('qr-container'),
    onSuccess: (result) => {
      // result.ageOver18 === true; result.claims = { age_over_18: true }
      showVerifiedUI();
    },
    onFailure: (err) => console.error('Failed:', err),
  });
}
</script>

espuni.verify(opts) reference

FieldTypeRequiredDescription
createSession(protocol) => Promise<SessionBundle>yesFactory called per protocol; returns { session, dcApiSubmitUrl?, pollUrl?, eventsUrl? }
containerElement | () => ElementOID4VPElement to render the QR into (may be a lazy getter)
protocolstringnoForce dc-api-iso or oid4vp (default auto)
deeplinkSchemestringnoQR/deeplink scheme (default av; openid4vp disables the rewrite)
onLinksfunctionnoCallback with { qrUri, deepLinkUri, avLinkUri } (OID4VP)
onSuccessfunctionnoCallback with { claims, ageOver18 } on completion
onFailurefunctionnoCallback with { error, status? } on error/cancellation

7. Limits and billing

espuni uses a usage-based pricing model. The free tier includes 50 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 (50/50). Upgrade your plan to continue."
}

ZK verification is separate: its own counter, its own monthly limit and its own price — and there, rejections are billed. See 5b.4.


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.