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/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)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, org_iso_mdoc }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. 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 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
// 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:
| 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
},
"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
| Field | Type | Required | Description |
|---|---|---|---|
createSession | (protocol) => Promise<SessionBundle> | yes | Factory called per protocol; returns { session, dcApiSubmitUrl?, pollUrl?, eventsUrl? } |
container | Element | () => Element | OID4VP | Element to render the QR into (may be a lazy getter) |
protocol | string | no | Force dc-api-iso or oid4vp (default auto) |
deeplinkScheme | string | no | QR/deeplink scheme (default av; openid4vp disables the rewrite) |
onLinks | function | no | Callback with { qrUri, deepLinkUri, avLinkUri } (OID4VP) |
onSuccess | function | no | Callback with { claims, ageOver18 } 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 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
| 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.