Authentication and rate limits

Every verification route requires the Authorization: Bearer <token> header. A missing, invalid, revoked, or expired token returns 401. Beyond your plan's limit (30 requests/second on Starter, 60 on Standard), a request returns 429.

When your token is within 7 days of expiring, every authenticated response also carries an X-License-Warning header (e.g. license expires in 3 day(s), renew soon): just a heads-up, the current request still succeeds normally.

Who decides what gets disclosed

Each attribute of an SD-JWT VC presentation (age, a degree, a status) is protected by its own salted digest in the signed JWT. The wallet only attaches to its presentation the value and salt for the attributes the user agreed to reveal: everything else stays as an irreversible hash. Todis technically only has access to what's actually disclosed in the presentation it receives, never more.

With POST /verify/sessions, the claims field of your request becomes a DCQL query embedded in the authorization request sent to the wallet: the user sees and approves exactly that list before their wallet builds the presentation. With POST /verify/sd-jwt-vc, that step has already happened before you obtained the presentation: Todis verifies it and returns its content as is.

POST /verify/sd-jwt-vc

Verifies an SD-JWT VC presentation. Two modes in the request body: issuer_public_key_pem (the issuer's key supplied directly) or country_code (automatic resolution via the EU's official registry) ; issuer_public_key_pem takes priority if both are present.

  • 200: presentation verified, claims populated.
  • 400: neither issuer_public_key_pem nor country_code supplied.
  • 401: missing, invalid, revoked, or expired license token.
  • 422: malformed presentation or signature not verified (verified: false, detail in error).
  • 429: plan request quota exceeded.
  • 502: issuer trust registry resolution failed (country_code mode only, e.g. the European registry is temporarily unavailable).

POST /verify/mdoc

Verifies an mdoc presentation (ISO/IEC 18013-5, notably mobile driving licenses). The trust anchor (issuer_trust_anchor_pem) must be supplied explicitly in the request body. It can be the IACA that issued the document signer, which is the recommended choice: the signer's chain, embedded in the presentation, is validated up to it, signature and validity window included, so you do not have to follow signer rotations. It can also be the signer's own certificate, if you prefer to pin it. Only one level is followed, as ISO/IEC 18013-5 intends. There is no automatic per-country resolution for this format (contact us if a specific mdoc issuer matters to you).

  • 200: presentation verified, doc_type and namespaces populated.
  • 401: missing, invalid, revoked, or expired license token.
  • 422: malformed presentation, wrong trust anchor, or signature not verified.
  • 429: plan request quota exceeded.

POST /hosted/sessions

The hosted verification journey: this service serves the page shown to the user itself (QR code, wallet link, copy in French, English or Spanish). It is the recommended integration when you would rather not build a verification screen, and it is detailed on the Integrations page. You redirect the user to the returned URL, they come back on your success_url, and you always confirm the result server-side with GET /verify/sessions/{id}. success_url means the verification succeeded, never that the user is of age: a proof of age that is false lands there too, like any verified result. Read the result server-side (claims.age_over_18 with the age_over_18 shortcut) and apply your own rule to it.

This is the only endpoint that accepts a webhook_url (a JSON POST signed with HMAC-SHA256 when the journey ends, even if the user closes the page) and the check shortcuts ("age_over_18", "identity"), available on every plan. It requires a deployment with a request signing identity, which the hosted service has.

The dc_api option. With "dc_api": true, the hosted page offers the visitor to open their wallet through the W3C Digital Credentials API, when their browser supports it; the QR code stays on the page. The protocol is ISO/IEC 18013-7 Annex C (org-iso-mdoc): only mdoc credentials (ISO/IEC 18013-5) go through this path, and a PID in SD-JWT VC format goes through the QR code. With check: "age_over_18", this path asks for the proof of age attestation (eu.europa.ec.av.1), with no fallback to the PID. The wallet's response is encrypted for Todis and bound to the origin https://verify.todis.eu: there is no origin for you to declare. The result, GET /verify/sessions/{id} and the webhook are unchanged. Outside a browser, for example in a native app, use the QR code or the deep link.

  • 201: journey created, with hosted_url, expires_at and the webhook secret.
  • 400: invalid return URL, unknown check, or inconsistent verification fields.
  • 401: missing, invalid, revoked, or expired license token.
  • 403: the token's plan does not include the full DCQL grammar (a dcql_query you supplied).
  • 429: plan request quota exceeded.
  • 501: deployment without a signing identity, hosted flow unavailable.

POST /verify/sessions

Creates an OpenID4VP verification session. The request body lists the requested credential type (vct) and the requested attributes (claims, simple paths, e.g. "age_over_18"). Trust in the issuer comes from country_code (SD-JWT VC, the EU's official list) or from this deployment's trust registries, listed by GET /trust/registries. The response contains an OpenID4VP authorization request to present to the user's wallet (QR code or deep link); how to build the exact QR code or deep link string is detailed in our integration file. The session expires 15 minutes after creation, whether it received a response or not. For advanced needs (several credentials per session, alternatives between credentials or between attributes, constrained values), the dcql_query field accepts a full DCQL query in place of vct/claims, and can mix SD-JWT VC and mdoc. The credential_trust field then gives an SD-JWT VC credential its own country_code, to verify credentials from issuers of different countries in a single session (each entry only ever applies to its own credential, never to another). See the details in the OpenAPI specification.

encrypted_response defaults to false on this endpoint, unlike the hosted flow, so this is where you have to act. Some national wallets accept nothing but an encrypted response (response_mode=direct_post.jwt) and, without one, return no error at all: they read the request then give up, and the session stays pending forever. If you target a production wallet, turn it on.

Without encryption, redirect_uri is optional (it serves the same-device flow); encryption makes it mandatory (HAIP 1.0 §5.1). That address must be reachable from the device running the wallet, not from your server: in a cross-device flow, the wallet receives it after posting its response and tries to open it on the phone. A loopback address (http://localhost:...) points at the phone itself there. The symptom is confusing: verification succeeded, the result reaches you, and it is the phone that shows an error.

curl -X POST https://verify.todis.eu/verify/sessions \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "vct": "https://example.eu/credentials/pid",
    "claims": ["age_over_18"],
    "country_code": "FR"
  }'
  • 201: session created, with session_id and authorization_request.
  • 400: claims empty, or no valid trust anchor covers the requested type.
  • 401: missing, invalid, revoked, or expired license token.
  • 403: the token's plan does not include the full DCQL grammar (dcql_query, Premium plan).
  • 429: plan request quota exceeded.
  • 502: trust registry resolution failed (country_code mode).
  • 503: the trust registry the session depends on has no base loaded.

GET /verify/sessions/{id}

Retrieves the result of a session created by POST /verify/sessions, to poll after the user has responded with their wallet (or periodically while waiting).

curl https://verify.todis.eu/verify/sessions/<session_id> \
  -H "Authorization: Bearer YOUR_TOKEN"
  • 200: three possible statuses, {"status": "pending"}, {"status": "verified", "claims": {...}}, or {"status": "failed", "error": "..."}. For an mdoc session (doctype/mdoc_claims), claims nests the elements under their namespace. A verified session also carries trust, where the anchor that validated it comes from.
  • 401: missing, invalid, revoked, or expired license token.
  • 404: unknown session (invalid identifier, or session expired after 15 minutes).
  • 429: plan request quota exceeded.

GET /trust/registries

Lists, with no token, the trust anchors this deployment accepts: for each registry (mdoc, sd_jwt_vc), its sources and anchors, with the credential types each one covers and its certificate's validity dates, and the countries a country_code can reach. A source read from a VICAL, the signed list of mdoc issuing authorities, says which VICAL it comes from, and discarded lists the entries of that VICAL that did not become anchors, with their reason: that is where a missing expected anchor is explained. To report a missing anchor: report_missing_anchor.

curl https://verify.todis.eu/trust/registries
  • 200: registry anchors and the countries of the EU's official list, cacheable response (Cache-Control, ETag).
  • 304: unchanged since the ETag sent in If-None-Match.

GET /usage

Check your request volume directly with your license token, without waiting for a dashboard or contacting support: handy for checking your consumption before a Starter block runs out, or just tracking your activity over time. If you've repurchased several Starter blocks, the total automatically adds up every token associated with your email address, no need to tally them yourself.

curl https://verify.todis.eu/usage \
  -H "Authorization: Bearer YOUR_TOKEN"
  • 200: {"client_ids": [...], "total_authenticated_requests": 42}, or total_authenticated_requests: null with an error field if metrics are temporarily unavailable (the rest of the response is still reliable).
  • 401: missing, invalid, revoked, or expired license token.
  • 429: plan request quota exceeded.

GET /usage/history

The daily history of your consumption, from the Standard plan up: daily totals over 30 days. The Premium plan adds the per-endpoint breakdown, latencies, a 400-day depth and CSV export (?days=30&format=csv). No raw verification data is retained, only aggregates.

  • 200: JSON history, or CSV when format=csv.
  • 401: missing, invalid, revoked, or expired license token.
  • 403: the token's plan does not include history (Standard and above).
  • 429: plan request quota exceeded.
  • 501: deployment without a database (self-hosted without DATABASE_URL).

Response format (direct verification)

For /verify/sd-jwt-vc and /verify/mdoc: a verified boolean and, depending on the outcome, either the disclosed information (claims for SD-JWT VC, doc_type/namespaces for mdoc) or a text error field. Never the full identity document, only the attributes actually disclosed (see above).

Going further

For the overview (authentication, two ways to integrate, a curl example), see How the API works. For the exact request/response schema, todis.eu/en/docs (Swagger). Have a question not covered here? contact@todis.eu.