Docs · API reference
Verifications API
The merchant surface: create a verification, read it, redeem a same-device response code, erase it, and see where the day's quota stands.
Create a verification
POST/v1/verificationsverifications:create
Creates a verification session and mints the wallet-facing
wallet_uri.
Body
| Field | Type | Notes |
|---|---|---|
template | string, 1–64 chars | A built-in (age_over_18, basic_identity) or one of your own template keys. Exactly one of template/query. |
query | object | An explicit DCQL query — { credentials: [...], credential_sets?: [...] } — instead of a template. |
webhook_url | string (URI) | An http(s) endpoint that receives the signed result. Must resolve to a public address; redirects are not followed. |
redirect_url | string (URI) | Where the wallet sends the user after responding, with #response_code=… appended. |
ttl_seconds | integer, 60–3600 | Session lifetime. Default 300. |
require_response_code | boolean | Same-device response-code binding (ADR-0049): a completed result parks at awaiting_redeem — no claims, credentials or assurance anywhere — until you redeem the code. Valid only alongside redirect_url (422 response_code_requires_redirect_url otherwise). Default false, and existing integrations that never set it see byte-identical behaviour. |
metadata | object, string → string | Opaque key/values of your own, echoed back on the result and on the webhook. |
{
"id": "vrf_G3mgaU2NnqZU-vNBd_jFAw",
"status": "pending",
"template": "age_over_18",
"wallet_uri": "openid4vp://?client_id=valitel-dev-verifier&request_uri=...",
"request_uri": "https://api.valitel.eu/wallet/request/vrf_G3mgaU2NnqZU-vNBd_jFAw",
"created_at": "2026-08-14T16:20:43.170Z",
"expires_at": "2026-08-14T16:25:43.170Z"
}wallet_uri and request_uri are present only while
status is pending. A completed session carries
claims (merged across credentials) and credentials
(per-credential format, trust findings and proof validity) with the disclosed values
populated; a failed or expired session returns the same
shape with every claim value withheld (fail-closed) — only the
non-claim evidence (trust findings, proof validity) and, for a failed
session, error are populated.
Errors400 invalid_request 400 invalid_idempotency_key 401 invalid_api_key 401 api_key_expired 403 insufficient_scope 409 idempotency_key_in_flight 422 unknown_template 422 invalid_webhook_url 422 response_code_requires_redirect_url 422 idempotency_key_reused 429 rate_limit_exceeded 429 quota_exceeded 500 internal_error
Retrying safely
Send an Idempotency-Key header (1–255 printable ASCII characters, no
spaces; a UUID is recommended) to make retries of this endpoint safe. Within 24 hours,
resending the identical key and body replays the original 201 — with an
Idempotent-Replayed: true response header — instead of creating a second
session, and spends no quota. Only a successful 201 is ever cached, so a
request that errored can be retried immediately with the same key.
| Situation | Response |
|---|---|
| first use | normal |
| same key, same body, done | original 201 + Idempotent-Replayed |
| same key, same body, still running | 409 idempotency_key_in_flight + Retry-After |
| same key, different body | 422 idempotency_key_reused |
| malformed key | 400 invalid_idempotency_key |
| after 24 h | treated as new |
List verifications
GET/v1/verificationsverifications:read
Lists your own sessions, newest first.
| Query parameter | Type | Notes |
|---|---|---|
limit | integer, 1–100 | Default 20. |
before | ISO 8601 datetime | Sessions created strictly before this cursor — page by passing the oldest created_at you have seen. |
{ "data": [ /* same shape as the create response */ ] }The list never carries claims and never consumes the one-time hand-over.
Errors400 invalid_request 401 invalid_api_key 403 insufficient_scope 429 rate_limit_exceeded 500 internal_error
Retrieve a verification
GET/v1/verifications/:idverifications:read
Fetches one session, scoped to your organisation.
{
"id": "vrf_G3mgaU2NnqZU-vNBd_jFAw",
"status": "completed",
"template": "age_over_18",
"claims": { "age_over_18": true },
"credentials": [
{
"format": "mso_mdoc",
"doctype_or_vct": "eu.europa.ec.av.1",
"claims": { "age_over_18": true },
"issuer": { "common_name": "Test Document Signer", "country": "UT" },
"trusted": true,
"signature_valid": true,
"holder_binding_valid": true,
"findings": [
{
"check": "san_match",
"status": "skipped",
"detail": "SAN matching applies to the sdjwt-issuer profile only"
}
]
}
],
"assurance": { "revocation": "checked", "credential_status": "absent" },
"created_at": "2026-08-14T16:20:43.170Z",
"expires_at": "2026-08-14T16:25:43.170Z",
"responded_at": "2026-08-14T16:20:47.117Z"
}Errors401 invalid_api_key 403 insufficient_scope 404 session_not_found 429 rate_limit_exceeded 500 internal_error
Result error codes
These are distinct from the HTTP-level errors above: a failed session
still returns 200 OK, with the reason in the result's own
error.code.
| Code | Meaning |
|---|---|
missing_credentials | The wallet presented no credentials at all. |
unsupported_credential_format | Every presented credential used a format this platform accepts structurally but does not yet verify. |
credential_verification_failed | A presented credential failed signature, trust-chain or holder-binding verification. |
credential_query_mismatch | A presented credential did not satisfy the DCQL query — error.message names the query id and reason (there is no separate mismatches field). |
response_code_not_redeemed | ADR-0049: require_response_code was set and the code was never redeemed within the window — see status: "awaiting_redeem" and Redeem a response code. |
internal_error | Ours. Retry, and tell us if it persists. |
Poll a status (no key)
GET/v1/verifications/:id/statusunauthenticated
The poll the widget makes from your own page. No bearer token, and deliberately limited to two fields:
{ "id": "vrf_G3mgaU2NnqZU-vNBd_jFAw", "status": "completed" }A browser holding only the session id must never be able to read verified claims
through this route, so it does not return them. It sets
access-control-allow-origin: *; a credential-less simple
GET never preflights, so that one header is the entire CORS story, and
no other route is cross-origin readable.
Errors404 session_not_found 429 rate_limit_exceeded 500 internal_error
Redeem a response code
POST/v1/verifications/:id/redeemverifications:read
The same-device analogue of polling. When a session was created with
redirect_url, the wallet returns the user to it with
#response_code=… appended; redeem that code for the result.
Single-use.
{ "response_code": "<string, min 16 chars>" }Errors400 invalid_request 401 invalid_api_key 403 insufficient_scope 404 invalid_response_code 429 rate_limit_exceeded 500 internal_error
Erase a verification
DELETE/v1/verifications/:idverifications:delete
Erases one verification and everything derived from it: the session, its result and its webhook delivery log — the three places a disclosed claim can be.
Valitel does not store the content of a verification, so on a normal account erasure removes the outcome metadata (status, error code, timestamps, delivery log) and, if the content has not yet been handed over, the sealed copy still held for it, which is made unreadable at the same moment.
{
"id": "vrf_G3mgaU2NnqZU-vNBd_jFAw",
"deleted": {
"verification_sessions": 1,
"verification_results": 1,
"webhook_deliveries": 2
},
"erased_at": "2026-08-15T10:00:00.000Z"
}The counts are measured, not inferred, so an Article 17 erasure request can be
answered with numbers rather than an assurance. A 0 means there was
genuinely nothing there — a pending session has no result, and a verification with no
webhook_url has no deliveries.
The operation is deliberately not idempotent: a second DELETE of the
same id is a 404, because a 200 would confirm the id had
once existed. An erasure appends an audit event carrying the counts and the
timestamp; audit rows never hold claim values, which is what makes keeping that
record compatible with the erasure it describes.
Errors401 invalid_api_key 403 insufficient_scope 404 session_not_found 429 rate_limit_exceeded 500 internal_error
Usage and quota
GET/v1/usageverifications:read
Every account has a daily budget of verification creations — 250/day on a test key and 10,000/day on a live one by default — which resets at midnight UTC. Creating a verification spends one unit; nothing else on the API spends anything, this endpoint included.
{
"day": "2026-08-17",
"verifications_created": 42,
"daily_quota": 250,
"remaining": 208,
"recent": [
{ "day": "2026-08-17", "verifications_created": 42 },
{ "day": "2026-08-16", "verifications_created": 191 }
]
}remaining is floored at zero, so it reads 0 rather than a
negative number if the budget is lowered below what has already gone out today.
recent lists the days with any usage at all, newest first, up to 30 of
them — a day with no creations has no row.
Once the budget is spent, creating answers 429 with code
quota_exceeded and a Retry-After header carrying the
seconds until the reset. Branch on the code rather than the status:
rate_limit_exceeded means slow down and retry the same request, while
quota_exceeded means retrying before midnight UTC cannot succeed.
Polling this endpoint is how you stay ahead of that rather than discovering the
ceiling by hitting it.
Errors401 invalid_api_key 403 insufficient_scope 429 rate_limit_exceeded 500 internal_error