Guía de integración — espuni
Esta guía es para desarrolladores que integran verificación de edad EU (OID4VP / DC API) en sus aplicaciones usando espuni como capa de servicio.
Base URL: https://api.espuni.com
Referencia interactiva: api.espuni.com/api-docs — Swagger UI con todos los endpoints públicos.
0. SDK Node.js (@espuni/node)
Si tu backend es Node.js o TypeScript, el SDK oficial reduce la integración a unas pocas líneas. Gestiona el token OAuth2 automáticamente y expone métodos tipados para los tres endpoints públicos.
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, 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)El SDK está disponible en npmjs.com/@espuni/node. El resto de esta guía documenta la API HTTP subyacente — útil tanto para otros lenguajes como para entender qué hace el SDK internamente.
1. Registro y credenciales
El registro es un proceso en dos pasos:
Paso 1 — Crear cuenta: Ve a app.espuni.com/register, rellena el email, contraseña y nombre de organización, y acepta los Términos del Servicio. Recibirás un email de verificación.
Paso 2 — Verificar email: Haz clic en el enlace del email. Esto aprovisiona tu tenant aislado y tu cliente OAuth2, y devuelve tus credenciales de API:
{
"credentials": {
"clientId": "cp-a1b2c3d4",
"clientSecret": "...",
"tokenEndpoint": "https://api.espuni.com/api/oauth2/token",
"apiUrl": "https://api.espuni.com"
}
}El
clientSecretse muestra solo una vez. Guárdalo en un gestor de secretos.
2. Obtener un token de acceso
Los tokens tienen vida corta. Obtén uno nuevo antes de cada lote de llamadas a la API (o implementa una caché con un margen de 30s antes de la expiración):
POST /api/oauth2/token
Authorization: Basic base64(clientId:clientSecret)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentialsTambién se acepta cuerpo JSON:
POST /api/oauth2/token
Content-Type: application/json
{ "client_id": "cp-a1b2c3d4", "client_secret": "..." }Respuesta:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 86400
}Usa el access_token en la cabecera Authorization: Bearer <token> para todas las llamadas posteriores.
2b. Sandbox / Entorno de pruebas
espuni incluye un entorno sandbox para testear tu integración sin consumir cuota del plan.
Usa requestId: "age-verification-sandbox" en lugar de "age-verification". El resto de la integración es idéntico.
POST /api/verifier/offer
Authorization: Bearer <token>
{
"requestId": "age-verification-sandbox",
"response_type": "uri",
"webhook": { "url": "https://tu-app.com/webhook", "auth": { "type": "none" } }
}Diferencias del sandbox:
- Las sesiones no cuentan contra tu cuota mensual del plan.
- Se aceptan credenciales emitidas por issuers del entorno de acceptance (no del entorno de producción).
- El webhook se entrega igualmente — puedes testear tu handler de extremo a extremo.
- Las sesiones aparecen en tu dashboard con el badge "Sandbox" para distinguirlas.
- Límite de uso: {100 sesiones/día, 10 sesiones/min} por tenant.
Cómo obtener una credencial de prueba:
- Instala la EU AV Reference App en Android.
- Obtén una credencial Proof-of-Age en la propia app siguiendo las instrucciones oficiales del EU AV Blueprint.
- Usa la app para escanear el QR de tu integración en sandbox.
Cuando estés listo para producción, cambia requestId a "age-verification". El resto de la integración no cambia.
3. Crear una oferta de verificación
3a. OID4VP (QR / deep link)
El flujo universal: genera un código QR que el usuario escanea con su EUDI Wallet.
- 1
POST /api/oauth2/tokenBasic Auth (clientId:clientSecret) → token Bearer, cachéalo hasta que expire - 2
POST /api/verifier/offer{ requestId, response_type: "uri", webhook } - 3
{ session, uri, crossDeviceUri } - 4Muestra el QR (crossDeviceUri) o el botón same-device (uri, esquema av://)
- 5El usuario escanea/abre y apruebala app descarga el request object y responde con el vp_token vía direct_post
- 6Valida la firma mdoc y el emisor contra la AV Trusted List+ metering y SessionLog (evidencia de sesión)
- 7POST a tu webhook{ age_over_18 } · reintentos 10s → 12h · alternativa: 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" }
}
}Respuesta (201):
{
"session": "abc123",
"uri": "openid4vp://...",
"crossDeviceUri": "https://eudiplo.espuni.com/vp/..."
}| Campo | Uso |
|---|---|
session | ID de sesión para polling de estado |
crossDeviceUri | URL para generar el QR (cross-device) |
uri | Deep link para abrir directamente en la wallet en el mismo dispositivo |
El uri usa el esquema av:// definido por el EU AV Blueprint para abrir la wallet en el mismo dispositivo. La wallet de referencia AV requiere este esquema; otras wallets pueden aceptar también openid4vp://.
3b. DC API ISO 18013-7 (nativo en navegador, sin QR)
Flujo para wallets conformes con el EU AV Blueprint (ISO 18013-7 Annex C), como la EU AV Reference App. El wallet responde con un payload CBOR cifrado con HPKE que espuni descifra y verifica en el servidor — tu integración no toca criptografía.
- 1
POST /api/verifier/offer{ requestId, response_type: "iso-18013-7" } - 2
{ session, org_iso_mdoc }org_iso_mdoc: device_request + encryption_info - 3Pasa dc_api y session al frontend
- 4
navigator.credentials.get()espuni-av.js construye la request por ti - 5El SO muestra el selector; el usuario aprueba con PIN/biometríala app responde inline con CBOR cifrado (HPKE)
- 6Envía la respuesta a tu backend
{ protocol, data } - 7
POST /api/session/:id/presentationo SDK: submitDcResponse() - 8Descifra (HPKE) y valida mdoc + Trusted List+ metering y SessionLog
- 9POST a tu webhook{ age_over_18 } · o 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" }
}
}Respuesta (201):
{
"session": "abc123",
"uri": "av://...",
"org_iso_mdoc": {
"device_request": "o2d...",
"encryption_info": "g2R..."
}
}| Campo | Uso |
|---|---|
session | ID de sesión para polling y webhook |
org_iso_mdoc.device_request | ISO 18013-5 DeviceRequest en CBOR base64url — pásalo al frontend |
org_iso_mdoc.encryption_info | COSE_Key pública para cifrar la respuesta del wallet — pásalo al frontend |
Con el SDK (§0), el backend se reduce a:
// 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)
});Si llamas a la DC API directamente desde el frontend (sin 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 webhookNota:
espuni-av.jsgestiona automáticamente la llamada a la DC API, la normalización del payload binario a base64url y el reenvío a tu backend. Ver §6.
4. Recibir el resultado (webhook)
Cuando el usuario completa la verificación, espuni llama a la URL del webhook que especificaste en la oferta:
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."
}Tu endpoint debe responder con 2xx en 10 segundos.
Comportamiento de reintentos: si tu endpoint no está disponible o devuelve un estado no-2xx, espuni reintenta la entrega con backoff exponencial — hasta 7 intentos en total:
| Intento | Espera tras el anterior |
|---|---|
| 1 (inicial) | inmediato |
| 2 | 10 seconds |
| 3 | 60 seconds |
| 4 | 5 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 12 hours |
espuni tolera interrupciones de hasta ~15 horas en tu servidor webhook. Haz que tu handler de webhook sea idempotente — el mismo ID de session puede entregarse más de una vez.
Autenticación de webhook
Protege tu endpoint con una 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. Consultar el estado de la sesión
Como alternativa o complemento a los webhooks, consulta el estado de la sesión:
GET /api/session/{sessionId}
Authorization: Bearer <access_token>Respuesta:
{
"sessionId": "abc123",
"status": "completed",
"verified": true,
"claims": {
"age_over_18": true
},
"failureCode": null,
"errorReason": null
}Estados posibles: pending · completed · failed · expired
Las sesiones expiran tras 300 segundos. Una vez en estado terminal (completed, failed, expired) el estado ya no cambia.
Las sesiones de verificación ZK añaden un bloque zk con el mismo veredicto que devolvió POST /api/zk/response — así el sondeo te dice qué capa falló, no solo que se rechazó:
{
"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. Verificación Zero-Knowledge (ZK)
En el flujo Zero-Knowledge la wallet no envía la credencial: envía una prueba criptográfica de que su titular es mayor de 18. Es el perfil ZKP del EU AV Blueprint (Annex A §A.8), y espuni actúa como verificador — no hay un paso intermedio ni webhook necesario para el resultado: el veredicto vuelve de forma síncrona.
Requisito: la prueba viaja por la Digital Credentials API del navegador (Chrome M130+). A diferencia de la verificación clásica, no hay fallback a QR: un navegador sin DC API no puede hacer ZK.
Pruébalo antes de integrar: la demo ejecuta este mismo camino si eliges ZKP como tipo de prueba (va contra el sandbox, así que no consume cuota). Y si lo que quieres es destripar el verificador —testvectors oficiales, emisión al vuelo, evidencia descargable y reproducible fuera de nuestra infraestructura— está el laboratorio ZK.
5b.1. Crear la petición
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 es obligatorio y debe ser el origen del navegador que llamará a navigator.credentials.get(). Entra en el SessionTranscript de ISO 18013-7, así que un valor incorrecto hace que la respuesta de la wallet no se pueda descifrar.
requestId es opcional (por defecto, tu config de verificación predeterminada). Su trusted_authorities decide contra qué EU AV Trusted List se comprueba el emisor — el mismo campo que en la verificación clásica.
Respuesta:
{
"session": "zk_9f2c...",
"expiresAt": "2026-08-15T12:10:00.000Z",
"digitalRequest": {
"protocol": "org-iso-mdoc",
"data": { "deviceRequest": "...", "encryptionInfo": "..." }
}
}5b.2. Pedir la prueba a la wallet
Con el SDK de navegador (@espuni/browser 0.2+) son unas pocas líneas. El SDK nunca ve tus credenciales: llama a tu backend, que es quien habla con 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),
});ZK es, hoy, same-device. En un ordenador, Chrome resuelve la DC API por el flujo cross-device (QR + túnel CTAP hacia el móvil). Medido el 2026-08-30 con teléfono real: la presentación llega hasta la aprobación del usuario y falla al devolver la respuesta — la prueba ZK son ~360 KB y ese túnel está dimensionado para cargas del tamaño de una aserción FIDO. La divulgación selectiva, de unos pocos KB, sí pasa. Ofrece ZK en el móvil y deja el flujo clásico OID4VP+QR para escritorio.
5b.3. Enviar la respuesta y recibir el veredicto
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" }
}
}El veredicto tiene tres capas, y verified es true solo si se cumplen las tres: la prueba verifica (cryptoVerified), su marca de tiempo firmada es reciente (fresh, ±5 min) y el emisor está en la EU AV Trusted List (issuerTrusted). Una prueba criptográficamente válida de un emisor que no está en la lista se rechaza.
trustList dice qué lista de emisores juzgó la sesión — la que declaraba tu verifier config, congelada al crear la petición, así que editar el config entre /request y /response no cambia el veredicto. Viaja con la evidencia porque es lo que hace auditable un "sí".
Un rechazo trae además un failureCode estable en la sesión (GET /api/session/{id}) para que agrupes por capa: zk_proof_invalid · zk_proof_stale · trust_chain_not_trusted · trust_list_unavailable. detail es el mensaje legible; el código es el que no cambia.
La sesión es de un solo uso: reenviar la misma respuesta devuelve 404. Si registraste webhook, el mismo veredicto se entrega también ahí (con la cola de reintentos habitual), útil si prefieres tratarlo en tu backend.
5b.4. Cuota y facturación — léelo
La verificación ZK se mide en un contador propio, con su propio límite mensual, independiente del de la verificación clásica. Verlo en el dashboard: pestaña Verificación, segunda barra del Resumen ("Verificación ZK"). Las sesiones y las métricas de calidad sí van mezcladas con las clásicas; el contador no.
Se cuenta todo veredicto emitido, también los rechazos. Es la diferencia importante con el flujo clásico, donde solo se cobran las sesiones completadas: verificar una prueba inválida cuesta la misma CPU que verificar una válida. Lo que no se cuenta son los errores de transporte —sesión desconocida o expirada, respuesta que no se puede descifrar—, porque ahí el verificador no llegó a ejecutarse.
Para probar sin gastar cuota, usa requestId: "age-verification-sandbox": valida contra la Trusted List de acceptance, no consume cuota, entrega el webhook igual y está limitado a 10/min · 100/día.
Errores específicos: 429 con { "error": "busy" } significa que el verificador está saturado — reintenta en unos segundos. 503 significa que no se pudo cargar la Trusted List y tu cuenta exige comprobación de emisor; ninguno de los dos consume cuota.
6. Snippet listo para usar: espuni-av.js
Si integras directamente en una página web, el snippet espuni-av.js gestiona automáticamente la detección de DC API, el flujo OID4VP QR y los eventos SSE — sin dependencias externas.
Cargar
espuni-av.js es el paquete @espuni/browser servido como un global window.espuni. Tienes tres formas de cargarlo según tu proyecto:
1. Primera parte (latest) — servido desde el dominio de espuni, siempre la última versión. Sin paso de build y fácil de permitir en una CSP estricta (mismo origen). Ideal para prototipos e integraciones sin 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. CDN pública (versión pinada) — jsDelivr/unpkg sirven cualquier versión publicada de @espuni/browser. Fija una versión exacta para que un cambio del SDK nunca llegue solo a tu sitio en producción. Requiere permitir cdn.jsdelivr.net en tu 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 (proyectos con bundler) — si usas un bundler (React, Vue, Vite…), instala el paquete e importa verify. La firma es idéntica a espuni.verify(opts).
npm install @espuni/browser
# then, in a bundled app:
# import { verify } from '@espuni/browser'Uso
Tu backend crea la sesión con POST /api/verifier/offer y devuelve el resultado al frontend. Luego llama a espuni.verify():
Backend (ejemplo Node.js):
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>Referencia de espuni.verify(opts)
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
createSession | (protocol) => Promise<SessionBundle> | sí | Factory llamado por protocolo; devuelve { session, dcApiSubmitUrl?, pollUrl?, eventsUrl? } |
container | Element | () => Element | OID4VP | Elemento donde renderizar el QR (puede ser un getter perezoso) |
protocol | string | no | Forzar dc-api-iso u oid4vp (default auto) |
deeplinkScheme | string | no | Esquema del QR/deeplink (default av; openid4vp desactiva la reescritura) |
onLinks | function | no | Callback con { qrUri, deepLinkUri, avLinkUri } (OID4VP) |
onSuccess | function | no | Callback con { claims, ageOver18 } al completar |
onFailure | function | no | Callback con { error, status? } en error/cancelación |
7. Límites y facturación
espuni usa un modelo de precios basado en uso. El plan gratuito incluye 50 verificaciones al mes, sin tarjeta de crédito ni contrato. Para producción, el precio se ajusta a tu volumen — escríbenos para un presupuesto.
Qué cuenta como verificación facturable: se registra un evento verification.completed solo cuando espuni recibe el resultado de una presentación de credencial. Las sesiones que expiran antes de que el usuario responda, o los errores que ocurren antes de la presentación de la credencial, no se contabilizan ni se facturan.
Cuando se alcanza el límite máximo, POST /api/verifier/offer devuelve:
403 Forbidden
{
"message": "Monthly verification limit reached (50/50). Upgrade your plan to continue."
}La verificación ZK va aparte: contador propio, límite mensual propio y precio propio, y ahí sí se cobran los rechazos. Ver 5b.4.
8. Limitaciones conocidas
DC API: protocolo org-iso-mdoc
El flujo DC API documentado en §3b usa el protocolo org-iso-mdoc (ISO 18013-7 Annex C), que es el definido por el EU AV Blueprint para wallets AV-profile. La wallet cifra su respuesta con HPKE (RFC 9180) usando la clave pública en encryption_info — espuni posee la clave privada correspondiente y descifra el mDoc en el servidor. Tu integración recibe el claim verificado (age_over_18: true) vía webhook, igual que en el flujo QR.
La disponibilidad del flujo DC API depende del soporte de la wallet: la EU AV Reference App lo implementa; las EUDI wallets estatales están añadiéndolo progresivamente. El flujo QR (§3a) es el fallback universal compatible con todas las wallets.
9. Errores comunes
| Código | Causa | Solución |
|---|---|---|
401 en /api/oauth2/token | Credenciales incorrectas | Verifica clientId y clientSecret |
403 Account suspended | Cuenta suspendida | Contacta con soporte |
403 Monthly limit reached | Se ha alcanzado el límite del plan | Actualiza el plan desde el dashboard |
503 en tu proxy de inicio | Faltan variables de entorno | Verifica CLIENT_ID / CLIENT_SECRET en tu proxy |
DC API: 'Failed to convert value' | data enviado como string JWT | Pasa el objeto JSON decodificado, no el JWT |
DC API: NotAllowedError | El usuario cerró el prompt | Esperado; trátalo como cancelación, no como error |
Webhook no recibido | URL no accesible públicamente | Usa un túnel (ngrok, Cloudflare Tunnel) en desarrollo |
10. Ejemplo mínimo con 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. Implementaciones de referencia
espuni implementa el EU Age Verification Blueprint. Puedes comparar nuestra implementación con el código de referencia oficial:
- Especificación técnica
- App de referencia Android
- App de referencia iOS
- Emisor de referencia (OID4VCI)
- Interfaz de verificador de referencia
12. Soporte
| Canal | Dirección | Idioma |
|---|---|---|
| Soporte técnico | support@espuni.com | English |
| Soporte técnico | soporte@espuni.com | Español |
| Seguridad | security@espuni.com | English |
| Legal / compliance | legal@espuni.com | EN / ES |
Los informes de seguridad siguen el estándar RFC 9116 — ver /.well-known/security.txt.