Verification API¶
An application can ask a citizen to prove something with their EU Digital Identity Wallet — "is over 18", "lives in Utrecht", "holds a master's degree" — without a sign-in. Helix runs the same OpenID4VP presentation as a wallet sign-in (request object, direct_post.jwt, the W3C Digital Credentials API, trust anchors, status lists, holder binding) and gives the application, once, a short-lived JWT signed with the realm key that carries only what was asked and allowed. No account, session or link is created or read. (Preview, not yet tested with a real national wallet.)
Status: Preview — not yet tested with a real national wallet
New in 1.1.0-rc.1. Tested with the project's own test wallet.
The flow is: request → citizen page → result.
- Your back end creates a verification (client-authenticated).
- You send the citizen to the Helix citizen page (or show your own QR code). The citizen presents with their wallet.
- You collect the result once: by polling, through a signed webhook, or with a one-time code on the redirect back.
Setting it up¶
- The application needs an OIDC client that is confidential (
client_secret_basicorprivate_key_jwt). - Its verified-claims policy must be enabled with
apiVerification: true.allowedClaimslists what it may receive (age_over_18,diploma.level, …);apiPredicateClaimslists details it may only use in yes/no checks (for exampleaddress.locality) and never receives. A BSN is never a yes/no source. - Its verification API settings: the wallet connection, an optional webhook and the cap on open verifications.
- Optional templates: stored requests you refer to by id (at most 50 per client).
Every write needs realm-admin plus the client guards of the verified-claims policy (the built-in console and CLI clients never get one). Console: Applications → the application → Verified data (wallet) → "Verification API (verify without a sign-in)"; then the Verification API section shows the connection, webhook, templates and recent verifications.
curl -s -b cookies.txt -c cookies.txt "$HELIX_URL/admin/realms/$REALM/verified-claims/shop" >/dev/null
CSRF=$(awk '($6=="__Host-XSRF-TOKEN"||$6=="XSRF-TOKEN")&&$7~/^r1\./{t=$7} END{print t}' cookies.txt)
# The policy: may receive age_over_18, may check the town, may use the API
curl -s -b cookies.txt -X PUT -H "Content-Type: application/json" -H "X-XSRF-TOKEN: $CSRF" \
-d '{"enabled": true, "allowedClaims": ["age_over_18"], "deliverUserinfo": false,
"apiVerification": true, "apiPredicateClaims": ["address.locality"]}' \
"$HELIX_URL/admin/realms/$REALM/verified-claims/shop"
# The API settings: which wallet connection, the webhook, the open cap
curl -s -b cookies.txt -X PUT -H "Content-Type: application/json" -H "X-XSRF-TOKEN: $CSRF" \
-d "{\"walletConnection\": \"eudi-wallet\", \"webhookUrl\": \"https://shop.example/hooks/wallet\",
\"webhookSecret\": \"$WEBHOOK_SECRET\", \"maxOpen\": 50}" \
"$HELIX_URL/admin/realms/$REALM/verification-api/shop"
# A template
curl -s -b cookies.txt -X PUT -H "Content-Type: application/json" -H "X-XSRF-TOKEN: $CSRF" \
-d '{"name": "Age check", "purpose": "To check you may rent a car",
"claims": ["age_over_18"],
"predicates": [{"id": "resident_utrecht", "claim": "address.locality", "values": ["Utrecht"]}]}' \
"$HELIX_URL/admin/realms/$REALM/verification-api/shop/templates/age-check"
The webhook secret is write-only (reads answer webhookSecretSet) and sealed at rest; "" removes it. DELETE …/verification-api/{clientId} removes the settings (console: Remove settings, confirmed and audited), DELETE …/templates/{templateId} a template, and GET …/verification-api/{clientId}/verifications lists recent verifications (status and names only, kept 7 days).
Terraform: helix_client_verified_claims (api_verification, api_predicate_claims), helix_client_verification_api and helix_verification_template. See Terraform provider.
Asking¶
REALM_URL="$HELIX_URL/realms/$REALM"
curl -s -u "shop:$SHOP_SECRET" -H 'Content-Type: application/json' "$REALM_URL/wallet/verifications" -d '{
"claims": ["age_over_18"],
"predicates": [{"id": "resident_utrecht", "claim": "address.locality", "values": ["Utrecht"]}],
"purpose": "To check you may rent a car here",
"state": "order-42"
}'
{
"verification_id": "vf_6Yb…",
"status": "pending",
"expires_at": 1760870400,
"citizen_url": "https://auth.example.com/realms/acme/wallet/verify/Hk2…",
"wallet": {
"client_id": "x509_hash:…",
"request_uri": "https://auth.example.com/realms/acme/oid4vp/eudi-wallet/request/q9…",
"cross_device_url": "openid4vp://?client_id=…&request_uri=…",
"same_device_url": "openid4vp://?client_id=…&request_uri=…",
"qr_svg": "data:image/svg+xml;base64,…"
},
"dc_api": false
}
| Member | Meaning |
|---|---|
claims |
0–20 names to receive: PID claims, age_over_NN, a credential id or <credential>.<claim>. Each must be within allowedClaims |
predicates |
0–10 yes/no checks {id, claim, values} (1–20 values of at most 256 characters) |
template |
a template id instead of claims, predicates and purpose |
purpose |
3–300 characters of one-line visible text, shown to the citizen |
state |
your reference (1–256 visible ASCII), echoed in the result |
redirect_uri |
optional; must be one of the client's registered redirect URIs exactly |
Predicates: one per claim¶
A predicate's source claim must be within allowedClaims or apiPredicateClaims, and there is at most one check per claim — also through an overlapping name such as address and address.locality — so several checks cannot narrow a value down. The answer is true when a verified value equals a listed one (Unicode NFC, trimmed, case-insensitive). Only membership is supported: no ranges or dates.
The citizen page¶
Send the citizen to citizen_url (recommended): a themed, accessible Helix page that says who asks (the client's name, never an id), what (one plain-language line per claim and check, with the values a check tests) and why, with a Continue button; then Open wallet app, the QR code and, where the browser has it, the Digital Credentials API.
- Opening the link claims nothing, so link scanners and previews are harmless. The first browser that presses Continue (a CSRF-protected POST) keeps it; another browser is told it is already open.
- An impersonation or administrator session cannot give a citizen's proof: the page asks to sign out of the administrator session first.
- Or show your own QR code from
wallet.cross_device_url; the wallet then shows Helix's verifier certificate.
The wallet is asked for exactly what is needed: the birth date for age_over_18, the town for the check, never the administrative number or any other PID claim. A request for a credential only asks for no PID.
Errors¶
| Answer | When |
|---|---|
401 invalid_client |
client authentication failed |
400 invalid_request |
malformed, or the wallet connection cannot ask for a claim (claims lists them) |
403 access_denied |
outside the policy, or no opt-in (claims lists what you asked that is not allowed) |
409 temporarily_unavailable |
no wallet connection configured |
429 slow_down or too_many_open_verifications |
rate limit, or the open cap reached |
503 |
the wallet transaction store is full |
private_key_jwt clients¶
Put client_assertion_type (urn:ietf:params:oauth:client-assertion-type:jwt-bearer) and client_assertion (audience: the realm issuer or its token endpoint) in the JSON body, or in the form of a POST. As at the token endpoint, an assertion must carry jti and exp, may live at most five minutes, and is accepted once. See Upgrading from rc.13 to 1.1.0-rc.1.
The result¶
Poll¶
curl -s -u "shop:$SHOP_SECRET" "$REALM_URL/wallet/verifications/$VERIFICATION_ID"
# {"verification_id":"vf_6Yb…","status":"pending","expires_at":1760870400,"state":"order-42"}
# … the citizen presents …
# {"verification_id":"vf_6Yb…","status":"completed","result":"eyJ0eXAiOiJ2ZXJpZmljYXRpb24tcmVzdWx0K2p3dCIs…","state":"order-42"}
curl -s -u "shop:$SHOP_SECRET" "$REALM_URL/wallet/verifications/$VERIFICATION_ID"
# {"verification_id":"vf_6Yb…","status":"delivered","state":"order-42"}
The result is handed out once; afterwards the status is delivered. While waiting the status is pending or wallet_opened; a failure is failed with reason (declined, not_accepted, expired, technical). Another client's id, an unknown and an expired one are all 404. private_key_jwt clients POST the same path with the assertion. Results wait at most helix.wallet.verification.result-ttl-seconds (300).
The result JWT¶
Header typ: verification-result+jwt, signed with the realm's active key: verify it with the realm JWKS ($REALM_URL/oauth2/jwks).
{
"iss": "https://auth.example.com/realms/acme", "aud": ["shop"], "iat": 1760870300, "exp": 1760870420,
"jti": "…", "verification_id": "vf_6Yb…", "state": "order-42", "verified_at": 1760870290,
"acr": "http://eidas.europa.eu/LoA/high",
"verified_claims": [{
"verification": {"trust_framework": "eidas", "assurance_level": "high", "time": "2026-10-19T09:18:10Z",
"evidence": [{"type": "electronic_record", "check_details": [{"check_method": "vcrypt"}],
"time": "2026-10-19T09:18:10Z",
"record": {"type": "eudi_wallet_pid", "source": {"name": "CN=PID Issuer,C=DE"}}}]},
"claims": {"age_over_18": true, "resident_utrecht": true}
}]
}
There is no sub: nobody is identified. Check typ, iss, aud, exp (120 s) and your state.
Webhook¶
With a webhook URL, Helix POSTs {"type": "wallet.verification.completed" | "wallet.verification.failed", "verification_id", "state", "status", "iat", "result"?} with X-Helix-Event: wallet.verification and X-Helix-Signature: sha256=<hex HMAC-SHA256 of the exact body under the webhook secret> (the same signature as the realm webhooks), through the outbound guard (no internal hosts, no redirects, 5 s).
The webhook takes the result first (a poll then answers delivered), mints one JWT and retries with that same JWT (three attempts). When none is answered 2xx, that very JWT can be polled until its own exp (the attempts take about 22 s of its 120 s), after which the verification reads as expired. There is never a second, different result.
Redirect code¶
With a redirect_uri, the citizen's browser comes back as redirect_uri?result_code=…&state=order-42 — or with error=access_denied|verification_failed|expired, or with status=delivered when the result was already collected by a poll or the webhook (no data, no error). Exchange the code once, with client authentication:
Limits¶
| Limit | Default |
|---|---|
| Verifications created per client | helix.ratelimit.wallet-verification-client.per-minute = 30 |
| Verifications created per realm | helix.ratelimit.wallet-verification-realm.per-minute = 300 |
| Open verifications per client | maxOpen (default 50, 1–1000) |
| Templates per client | 50 |
| Result lifetime before collection | helix.wallet.verification.result-ttl-seconds = 300 |
| Result JWT lifetime | 120 s |
| Recent verifications kept | 7 days (status and names only) |
/wallet/** also shares the per-IP wallet rate-limit budget. The API takes no cookies (it is CSRF-exempt, like the token endpoint); the citizen page's posts are CSRF-protected.
Safety and privacy¶
- Policy first: everything asked must be within the client's policy at request time, and the release applies the policy as it is when the wallet answers.
- BSN: collected from the wallet and released only when the client has the BSN grant and named that exact claim; never through a whole credential, never as a yes/no source. The audit names the legal basis.
- At rest: the waiting result and the webhook secret are sealed (AES-GCM, key derived from
DB_ENCRYPTION, bound to realm, client and verification); the page handle, browser binding, return token and result code are kept only as SHA-256. The data-retention job wipes uncollected results once their lifetime passes and deletes rows after 7 days. - The client, its policy, settings, templates and verifications are deleted with the client and the realm.
Audit¶
| Event | When | Detail (names, never values) |
|---|---|---|
WALLET_VERIFICATION_REQUESTED |
a verification is created (actor: the client) | clientId, verificationId, claims, template |
WALLET_VERIFICATION_REFUSED |
refused for policy, cap or rate | clientId, reason, claims |
WALLET_VERIFICATION_COMPLETED |
the wallet's answer was accepted | clientId, verificationId, claims, credentials, legalBasis (BSN) |
WALLET_VERIFICATION_FAILED |
declined, refused or expired | clientId, verificationId, reason |
WALLET_VERIFICATION_PRESENTATION_FAILED |
the presentation itself was refused | transactionId, alias, format, issuerDn, reason |
WALLET_VERIFICATION_DELIVERED |
the result was collected | clientId, verificationId, via (poll, exchange, webhook) |
WALLET_VERIFICATION_SETTINGS_UPDATED / _DELETED, WALLET_VERIFICATION_TEMPLATE_UPDATED / _DELETED |
admin changes | clientId, … |
Endpoints¶
Method & path (under /realms/{realm}) |
Called by |
|---|---|
POST /wallet/verifications |
your back end — create a verification |
GET · POST /wallet/verifications/{id} |
your back end — poll (POST with a client assertion) |
POST /wallet/verifications/result |
your back end — exchange a redirect result_code |
GET /wallet/verify/{handle} |
the citizen's browser — the citizen page |
POST /wallet/verify/{handle}/status |
the citizen page — polls the verification (CSRF-protected) |
Method & path (under /admin/realms/{realm}) |
Purpose |
|---|---|
GET · PUT · DELETE /verification-api/{clientId} |
API settings |
GET /verification-api/{clientId}/templates · PUT · DELETE /verification-api/{clientId}/templates/{templateId} |
Templates |
GET /verification-api/{clientId}/verifications |
Recent verifications |
Not in this release¶
Batch verifications and cancelling a pending one through the API; tls_client_auth; predicates other than membership (ranges, dates); an encrypted (JWE) result; templates in realm export / import.
See also¶
- Verified data for applications — the policy this API builds on
- EU Digital Identity Wallet (OpenID4VP) — the wallet connection
- Webhooks — the same signature scheme