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, and the session result returns only those attributes to you, even if the wallet discloses more. 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. With country_code, the issuer is resolved through that country's trust list, via the EU's official registry, among its qualified attestation providers, and for only the vct values the SD-JWT VC registry classifies qeaa, never a PID; without it, through this deployment's SD-JWT VC registry (GET /trust/registries), whose anchor must cover the credential's vct. The response says where the anchor came from (trust). Trust anchors are held by Todis: no key is supplied in the request, and a request that still carries issuer_public_key_pem is refused.

A PID is no longer verified by giving a country. Up to version 4.5.0, an SD-JWT VC PID could be verified with country_code. Since version 5.0.0, a PID sent with country_code always fails, whatever the country: a country's trust list only verifies qualified attestations (vct classified qeaa). Verify a PID without country_code, against this deployment's registries (GET /trust/registries).

On verify.todis.eu, no type is classified qeaa today: country_code therefore verifies no credential there. Classifying a qualified type is registry data, not a service version.

  • 200: presentation verified, claims and trust populated.
  • 400: the request carries issuer_public_key_pem, whatever its value; the error names the field.
  • 401: missing, invalid, revoked, or expired license token.
  • 422: malformed presentation, signature not verified, or an issuer no anchor accepts (verified: false, detail in error). A refused issuer is named: the detail carries the subject, the SHA-256 fingerprint, the issuing authority and that authority's key identifier of the presented certificate, enough to have it declared without a round trip. And if that certificate chains to an anchor the registry already holds, but which is not declared for the presented type, the detail says so and names that anchor and its types: the remedy is then to declare a type, not to obtain a certificate. This is certificate information, which is public; no claim of the holder appears there. Also, with country_code, a credential whose vct is not classified qeaa, which the error names with its category, or any presentation when this deployment classifies no type qeaa, which the error says: in both cases, without any list being read. Also a credential whose typ is neither dc+sd-jwt nor vc+sd-jwt, and a credential that carries aud, whatever its value: without a session, Todis is the recipient of no credential. In a session, aud is accepted when it names the same audience as the key binding JWT of the request (the client_id for a redirect).
  • 429: plan request quota exceeded.
  • 502: issuer trust registry resolution failed (country_code mode only, for a type classified qeaa, e.g. the European registry is temporarily unavailable, or the national list is closed or past its next update, or a list could not be reread successfully for 24 h, which the error names).
  • 503: the SD-JWT VC registry has no base loaded, with or without country_code, which only verifies the types that registry classifies.

POST /verify/mdoc

Verifies an mdoc presentation (ISO/IEC 18013-5, notably mobile driving licenses), against this deployment's mdoc registry (GET /trust/registries), which holds the IACAs of the accepted issuers: the signer's chain, embedded in the presentation, is validated up to one of them, signature and validity window included, over a single level as ISO/IEC 18013-5 intends, and the retained anchor must cover the signed doctype. The response says which anchor validated the issuer (trust). Trust anchors are held by Todis: no certificate is supplied in the request, and a request that still carries issuer_trust_anchor_pem is refused. Report an mdoc issuer missing from the registry to integration@todis.eu.

  • 200: presentation verified, doc_type, namespaces and trust populated.
  • 400: the request carries issuer_trust_anchor_pem, whatever its value; the error names the field.
  • 401: missing, invalid, revoked, or expired license token.
  • 422: malformed presentation, signature not verified, or an issuer no anchor of the registry accepts, named as on POST /verify/sd-jwt-vc.
  • 429: plan request quota exceeded.
  • 503: the mdoc registry has no base loaded.

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.

Two requests on the page. The "Open my wallet" button carries the same-device request: after its response, the wallet sends the browser back to the page with a fresh code (the Response Code, OpenID4VP 1.0 §14.2), and the page then releases the result. It releases it only in the browser that opened it first, which it recognises by a todis_parcours cookie; a return that arrives in another browser fails the session. The QR code carries a separate cross-device request, for a wallet on another device, with no return, served three times at most. Inside an iframe, the button opens the page in a new tab. Two consequences for you: do not open hosted_url in a browser before redirecting the user to it, or the journey would be bound to your browser rather than theirs; and the session.verified webhook of a same-device return is sent when the page releases the result, not when the wallet posts its response.

The purpose and cross_device fields. purpose (optional, a non-empty text of at most 300 characters) is the purpose of the verification, in your words and in the page's language: the page displays it before the visitor opens their wallet, preceded by "Purpose stated by the site:" (OpenID4VP 1.0 §6.2). Without it, the page displays the purpose of the check shortcut (age or identity), otherwise the list of the requested information. cross_device (boolean, true by default) set to false serves a same-device-only page, with no QR code: HAIP 1.0 §5.1 recommends it when you rely on the binding to the user's browser against phishing.

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 page hands the browser, in a single call, one request per protocol the verification allows, and the wallet answers the one it supports. The first is OpenID4VP 1.0 (openid4vp-v1-signed), signed with the same certificate and carrying the same query as the QR code: a PID in SD-JWT VC format and mdoc credentials (ISO/IEC 18013-5) both go through it. With check, the SD-JWT VC PID is only requested when this deployment's SD-JWT VC registry covers it; the one of verify.todis.eu does not, so the journey asks for the mdoc routes only. The second, for mdoc credentials only, is ISO/IEC 18013-7 Annex C (org-iso-mdoc). With check: "age_over_18", the OpenID4VP request offers the same age sources as the QR code, the proof of age attestation (eu.europa.ec.av.1) first; the Annex C request asks for a single document, with no fallback: the age_over_18 attribute of that attestation, which the mdoc registry of verify.todis.eu covers, as GET /trust/registries shows. A wallet that answers through Annex C alone and does not hold that attestation cannot answer through the button; the QR code stays on the page. 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 of GET /verify/sessions/{id} and the webhook gain dc_api_protocol, the protocol the wallet answered with. Outside a browser, for example in a native app, use the QR code or the deep link. A deployment in HAIP locked mode (the HAIP_VERROUILLE option, off by default) locks what Todis issues: a request signed under x509_hash and an encrypted response. It does not restrict the protocol the wallet picks: the page still offers Annex C next to OpenID4VP, so that a wallet that can only answer through it can answer. To offer OpenID4VP alone, on the integration platform only, create the session with the header todis-essai-openid4vp: seul.

The revocation option. As on POST /verify/sessions, report by default or require: how the journey behaves when a status could not be established. The session result and the session.verified webhook carry revocation, the policy applied and what the check established.

The profil_av option. With "profil_av": true, the hosted page also offers, in a separate tab, the age verification app of the EU profile: a QR code and an av:// link that hand it the request by value, unsigned, for the EU Proof of Age attestation (eu.europa.ec.av.1) alone, with no fallback to the PID. This is the form those apps require, and the only one they accept: they refuse any signed request. The first tab stays the page as before. What you give up with this option: the app cannot authenticate Todis, there being no signature; the response stays bound to this request and is verified like any other. The result, GET /verify/sessions/{id} and the webhook are unchanged. The option requires a route of the journey to offer that attestation: with check: "age_over_18", that is the case when the deployment's mdoc trust registry covers it, which the one of verify.todis.eu does (see GET /trust/registries). On a deployment whose registry does not cover it, creation answers 400.

  • 201: journey created, with hosted_url, expires_at and the webhook secret.
  • 400: invalid return URL, unknown check, inconsistent verification fields, purpose empty or longer than 300 characters, or a supplied trust anchor, transaction_data or malformed DCQL query (same rules as POST /verify/sessions).
  • 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 (an SD-JWT VC whose vct is classified qeaa, the EU's official list; any other type fails at verification, with a message that names it) 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.

A PID is no longer verified by giving a country. Up to version 4.5.0, an SD-JWT VC PID could be verified with country_code. Since version 5.0.0, a PID sent with country_code always fails, whatever the country: a country's trust list only verifies qualified attestations (vct classified qeaa). Verify a PID without country_code, against this deployment's registries (GET /trust/registries).

On verify.todis.eu, no type is classified qeaa today: country_code therefore verifies no credential there. Classifying a qualified type is registry data, not a service version.

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.

revocation (optional, report by default) sets how the session behaves when a revocation status could not be checked: with report, the session is verified and its result says the status was not established, and why; with require, it fails. Todis checks the status of every credential that declares one (the status list of an SD-JWT VC, the status list or identifier list of an mdoc), before concluding the session and on every transport. A revoked or suspended credential, or one whose index falls outside its list, fails the session whatever the policy; a credential that provides no status passes under both. The status claim of an SD-JWT VC is never returned to you. Open in every plan.

profil_av (optional, false by default) also asks for the OpenID4VP transport of the EU age verification profile: the response then carries profil_av, with wallet_uri, an av:// link that opens the age verification app and hands it the request by value, and authorization_request, that request, unsigned, under redirect_uri:, in plain direct_post, for the EU Proof of Age attestation (eu.europa.ec.av.1) alone. This is the only form those apps accept; in exchange, the app cannot authenticate Todis. The redirect request is unchanged. The session must request that attestation as an mdoc (400 otherwise), and request only age_over_NN elements of its namespace: any other path is refused at creation, by a 400 that names it. This transport carries that attestation and nothing else: a response posted to its response_uri that carries another credential is rejected, while the redirect request and the DC API keep accepting the other age sources of the session.

redirect_uri: same device or QR code. With redirect_uri (optional, an absolute URI, 400 otherwise), the session is a same-device flow: after its response, the wallet sends the browser back to redirect_uri, with an added response_code parameter, a fresh secret. The verified result is then held back: GET /verify/sessions/{id} returns {"status": "pending", "response_code_required": true} until you have presented that code to POST /verify/sessions/{id}/response-code (see below). Without redirect_uri, the session is cross-device (QR code): no redirect, and the result can be read directly. So do not pass redirect_uri for a QR code, the result would stay held back. encrypted_response: true does not require redirect_uri. That address must be reachable from the device running the wallet, not from your server: a loopback address (http://localhost:...) points at the phone itself there.

DCQL query. dcql_query is checked at creation (400): arrays, when present, are not empty (credentials, credential_sets, claims, claim_sets, options, values), credential and claim identifiers are made of letters, digits, _ and -, no claim is requested twice (a rule that also applies to claims and mdoc_claims), values is limited to strings, integers and booleans, an mdoc path is exactly the namespace then the element, and claim_sets only comes with claims. A claim path follows the Claims Path Pointer of OpenID4VP 1.0 §7: a name selects a key, null all the elements of an array, an integer the element at that index; a values constraint is met as soon as one selected value is in it. With "multiple": true on a credential, every presentation received under its identifier is verified, and credentials.<id> as well as trust.<id> become arrays, one entry per presentation kept; without it, a second presentation under the identifier, or an empty array, fails the session.

Authorities named to the wallet. For each credential that a registry of this deployment will judge, Todis itself adds to the request a trusted_authorities entry of type aki: the key identifier of each authority (IACA) the registry accepts for its type. A wallet that applies this filter then only offers the credentials that fall under it. Nothing is added for a credential judged through the EU list (country_code), nor when a candidate anchor cannot be designated that way (an anchor with the signer role). A trusted_authorities you supply in dcql_query is passed on as is; an aki value that is not base64url is refused (400). This is a help for the wallet: verification judges trust as before. Finally, transaction_data is refused (400): Todis sends no transaction data to the wallet and does not verify their hashes.

Intent to retain. On a claim of an mso_mdoc credential of dcql_query, "intent_to_retain": true declares that you intend to retain that element beyond the transaction (OpenID4VP 1.0 §B.2.4). Todis passes it on to the wallet on every transport: as is in the OpenID4VP request, and as IntentToRetain in the ISO/IEC 18013-7 Annex C request; the wallet may show it to the user. When absent, it is false: Todis then tells the wallet that the element will not be retained, which commits you not to store it longer than needed to conduct the transaction (ISO/IEC 18013-5 §8.3.2.1.2.1). This is what the simplified fields and the check shortcuts always announce. The field is never ignored: in mdoc_claims, on an SD-JWT VC claim or with a value that is not a boolean, it is refused with an error that names it.

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"]
  }'
  • 201: session created, with session_id and authorization_request.
  • 400: claims empty, no valid trust anchor covers the requested type, a redirect_uri that is not an absolute URI, a malformed DCQL query, transaction_data supplied, intent_to_retain outside an mdoc claim of dcql_query, or the request carries issuer_public_key_pem, or a credential_trust entry that carries one; the error names the field, whatever transport is requested.
  • 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. A session concluded through the Digital Credentials API carries dc_api_protocol, openid4vp-v1-signed or org-iso-mdoc, the protocol the wallet answered with. A same-device session (created with redirect_uri) whose result is waiting for its code returns {"status": "pending", "response_code_required": true}. A presentation bound to another request (a KB-JWT for another nonce or another audience, an mdoc whose device signature does not verify against the request) rejects the whole response, even next to a valid credential: the error then starts with the response is rejected (OpenID4VP 1.0 §14.1.2). A verified session also carries revocation: the policy applied (policy) and what the check established, status among not_provided (no status declared), valid (with mechanism, list_issued_at and, when the list was admitted under a transitional registry declaration, signer_exception) and not_checked (with reason); valid and not_provided also carry certificates, the revocation of the issuer's certificate chain by its issuer's CRL, not_provided or checked; A concluded session also carries checks, the list of the checks performed on the presented credential, in the order they succeeded, each with its outcome (passed, not_checked or failed): issuer_signature, issuer_trust_anchor, credential_integrity, credential_validity, holder_binding, credential_type, requested_claims, credential_status, certificate_revocation. A check that did not take place is not listed, and one whose detail is elsewhere names the path where to read it under reported_in: trust, revocation or revocation.certificates. A failed session carries it too: the checks that succeeded before the one that failed, then that last one with failed; a failure that had no presentation judged, a holder refusal for instance, carries no checks. A failed session also carries code, the cause in a closed vocabulary next to error: wallet_declined, wallet_error, credential_missing, presentation_invalid, requirement_not_met, trust_resolution_unavailable, credential_revoked, revocation_not_checked or unspecified.
  • 401: missing, invalid, revoked, or expired license token.
  • 404: unknown session (invalid identifier, session expired after 15 minutes, or session created under another client's license).
  • 429: plan request quota exceeded.

POST /verify/sessions/{id}/response-code

Releases the result of a same-device session, created with redirect_uri. Your return page receives response_code as a parameter: present it from your server, for the session that your user started, whose identifier you kept on your side (in their server session, for example), never for a session inferred from the return address. This is what prevents a request relayed to someone else from handing you that person's identity (OpenID4VP 1.0 §14.2, HAIP 1.0 §5.1). A wallet that does not follow the redirect never releases the result: it stays held back until the session expires.

curl -X POST https://verify.todis.eu/verify/sessions/<session_id>/response-code \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"response_code": "<value received on redirect_uri>"}'
  • 200: the same body as GET /verify/sessions/{id}, with the released result, which GET /verify/sessions/{id} then returns without a code.
  • 400: a session with no same-device return (created without redirect_uri), or a hosted page session, where the page presents the code.
  • 401: missing, invalid, revoked, or expired license token.
  • 403: this code is not the session's; nothing is released.
  • 404: unknown session, expired, or created under another client's license.
  • 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, the official sources this deployment is to query, with the date and result of their last read (official_sources), and the countries a country_code can reach. trust_list_cache shows the trust lists this deployment keeps, verified, for country_code: the LOTL and each country already resolved, with their sequence number, the fingerprint of the file read, their last successful read, the date until which the copy is served, and whether it is served now, and if not why. A list that could not be reread successfully for 24 h is no longer served, until the next reread that succeeds. A source the Commission has not published yet says what is missing to read it, and a count of zero is a read, dated, not a missing read. Each anchor is one role of a certificate, as one source declares it: the same certificate appears once per source that gives it a role. Its usage is issuance when it validates credentials, status when it signs their status lists, which never validates a credential. type_categories gives the category of each type this deployment classifies, whether an anchor covers it or not (pid, qeaa, pub_eaa, eaa), which the response of a verification repeats in trust.category; only the qeaa types of the SD-JWT VC registry are verified through country_code. A source read from a VICAL, the signed list of mdoc issuing authorities, says which VICAL it comes from. A source read from a list of trusted entities (LoTE, ETSI TS 119 602) carries list, with the list's next update: once that date has passed, its anchors are no longer accepted. discarded lists the entries of these lists 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. The Trust anchors page presents these anchors by country, read live from the same route.

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.