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 los tres 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 poll session status
const result = await client.getSession(sessionId);
// result.status → 'pending' | 'completed' | 'failed' | 'expired'
// result.claims → { age_over_18: true }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, dc_api }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. 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 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
{
"session": "abc123",
"verified": true,
"claims": {
"age_over_18": true
}
}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
},
"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.
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
<script src="https://app.espuni.com/espuni-av.js"></script>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 });
// 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>Referencia de espuni.verify(opts)
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
sessionId | string | sí | ID de sesión devuelto por tu backend |
presentationRequest | object | sí | Objeto con dc_api u oid4vp según el modo |
dcApiResponseUrl | string | DC API | URL de tu backend que reenvía el VP a espuni |
container | string | Element | OID4VP | Selector CSS o elemento donde renderizar el QR |
onSuccess | function | no | Callback con { claims } 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 100 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 (100/100). Upgrade your plan to continue."
}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.