Skip to content

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.

  1. Your back end creates a verification (client-authenticated).
  2. You send the citizen to the Helix citizen page (or show your own QR code). The citizen presents with their wallet.
  3. You collect the result once: by polling, through a signed webhook, or with a one-time code on the redirect back.

Setting it up

  1. The application needs an OIDC client that is confidential (client_secret_basic or private_key_jwt).
  2. Its verified-claims policy must be enabled with apiVerification: true. allowedClaims lists what it may receive (age_over_18, diploma.level, …); apiPredicateClaims lists details it may only use in yes/no checks (for example address.locality) and never receives. A BSN is never a yes/no source.
  3. Its verification API settings: the wallet connection, an optional webhook and the cap on open verifications.
  4. 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:

curl -s -u "shop:$SHOP_SECRET" "$REALM_URL/wallet/verifications/result" -d "result_code=$CODE"

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