# 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.)

!!! warning "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](verified-claims.md#per-client-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.

```bash
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](terraform.md).

## Asking

```bash
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"
}'
```

```json
{
  "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](../operations/upgrade-rc13-1.1.0-rc1.md#1-client-assertions-are-single-use).

## The result

### Poll

```bash
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`).

```json
{
  "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](webhooks.md)), 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:

```bash
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

- [Verified data for applications](verified-claims.md) — the policy this API builds on
- [EU Digital Identity Wallet (OpenID4VP)](../federation/eudi-wallet.md) — the wallet connection
- [Webhooks](webhooks.md) — the same signature scheme
