Technical reference
The contractual detail of the endpoints your application calls directly: response codes, error cases, rate limits. For the exact schema, queryable and always up to date, the Swagger documentation is available at todis.eu/en/docs, no token required.
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,claimsandtrustpopulated.400: the request carriesissuer_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 inerror). 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, withcountry_code, a credential whosevctis not classifiedqeaa, which the error names with its category, or any presentation when this deployment classifies no typeqeaa, which the error says: in both cases, without any list being read. Also a credential whosetypis neitherdc+sd-jwtnorvc+sd-jwt, and a credential that carriesaud, whatever its value: without a session, Todis is the recipient of no credential. In a session,audis accepted when it names the same audience as the key binding JWT of the request (theclient_idfor a redirect).429: plan request quota exceeded.502: issuer trust registry resolution failed (country_codemode only, for a type classifiedqeaa, 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 withoutcountry_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,namespacesandtrustpopulated.400: the request carriesissuer_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 onPOST /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, withhosted_url,expires_atand the webhook secret.400: invalid return URL, unknowncheck, inconsistent verification fields,purposeempty or longer than 300 characters, or a supplied trust anchor,transaction_dataor malformed DCQL query (same rules asPOST /verify/sessions).401: missing, invalid, revoked, or expired license token.403: the token's plan does not include the full DCQL grammar (adcql_queryyou 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, withsession_idandauthorization_request.400:claimsempty, no valid trust anchor covers the requested type, aredirect_urithat is not an absolute URI, a malformed DCQL query,transaction_datasupplied,intent_to_retainoutside an mdoc claim ofdcql_query, or the request carriesissuer_public_key_pem, or acredential_trustentry 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_codemode).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),claimsnests the elements under their namespace. A verified session also carriestrust, where the anchor that validated it comes from. A session concluded through the Digital Credentials API carriesdc_api_protocol,openid4vp-v1-signedororg-iso-mdoc, the protocol the wallet answered with. A same-device session (created withredirect_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 anothernonceor 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 withthe response is rejected(OpenID4VP 1.0 §14.1.2). A verified session also carriesrevocation: the policy applied (policy) and what the check established,statusamongnot_provided(no status declared),valid(withmechanism,list_issued_atand, when the list was admitted under a transitional registry declaration,signer_exception) andnot_checked(withreason);validandnot_providedalso carrycertificates, the revocation of the issuer's certificate chain by its issuer's CRL,not_providedorchecked; A concluded session also carrieschecks, the list of the checks performed on the presented credential, in the order they succeeded, each with its outcome (passed,not_checkedorfailed):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 underreported_in:trust,revocationorrevocation.certificates. A failed session carries it too: the checks that succeeded before the one that failed, then that last one withfailed; a failure that had no presentation judged, a holder refusal for instance, carries nochecks. A failed session also carriescode, the cause in a closed vocabulary next toerror:wallet_declined,wallet_error,credential_missing,presentation_invalid,requirement_not_met,trust_resolution_unavailable,credential_revoked,revocation_not_checkedorunspecified.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 asGET /verify/sessions/{id}, with the released result, whichGET /verify/sessions/{id}then returns without a code.400: a session with no same-device return (created withoutredirect_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 theETagsent inIf-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}, ortotal_authenticated_requests: nullwith anerrorfield 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 whenformat=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 withoutDATABASE_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.