# CLAUDE.md — Integrating Todis

This file is self-contained: it assumes no access to Kohex's
repository, source code, or internal documentation. Everything it
describes is the public API of **Todis**, at
`https://verify.todis.eu`. Copy it into your own project if you want a
Claude Code instance to help you integrate Todis.

Living source of truth (always up to date, generated from the real
code): https://todis.eu/openapi.json — full OpenAPI 3.1 spec, every
example below is taken from it. If in doubt, or if something here
diverges, that URL is right. Full flow explained in prose:
https://todis.eu/workflow.html.

## Start here: make one real call before writing any code

You can get a working token yourself, right now, with no sign-up, no
card and no email:

```bash
TODIS_TOKEN=$(curl -sX POST https://todis.eu/trial \
  -H "Content-Type: application/json" \
  -d '{"project":"what you are building","tool":"claude-code"}' \
  | jq -r .token)
```

The token is free, capped at 50 requests, and works on every route
below. Then run the shortest complete flow, an age check:

```bash
curl -X POST https://verify.todis.eu/hosted/sessions \
  -H "Authorization: Bearer $TODIS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"check": "age_over_18", "country_code": "FR", "locale": "en"}'
```

You get back a URL. Open it and you see exactly what your users will
see.

**Do this before writing the integration, not after.** Reading this
file tells you what the API is supposed to return; running those two
commands tells you what it does return, on your machine, today. Write
your integration against the response you actually received, and you
will not spend an afternoon debugging a field that was never there.

If the trial route answers `429`, a token was already issued to your
address today: ask the person you are working for to get one at
https://todis.eu/en/pricing.html, or write to integration@todis.eu.

## What Todis does

Todis verifies the authenticity of a proof presented by a European
digital identity wallet (EUDI Wallet, eIDAS 2 regulation): age,
identity, driving licence. With selective disclosure: your application
only ever receives the attributes you asked for, never the rest of the
user's identity. Both credential formats of the EUDI ecosystem are
supported: **SD-JWT VC** and **mdoc** (ISO/IEC 18013-5, the mobile
driving licence format).

## Authentication

Every protected route expects:

```
Authorization: Bearer <license token>
```

Two ways to get one, both free to start. `POST https://todis.eu/trial`
issues one immediately, no sign-up (see "Start here" above, and
`POST /trial` in the OpenAPI spec). Or sign up and receive it by
email: https://todis.eu/en/#pricing, no card required. Both give the
same 50-request trial plan. A missing, invalid, revoked or expired
token always returns:

```json
{ "error": "missing license (Authorization: Bearer <token> header)" }
```
with HTTP status `401`.

The API's error messages are **always in English**, regardless of the
language of this documentation.

## Three ways to integrate, from simplest to most control

1. **Hosted flow** (`POST /hosted/sessions`) — the recommended
   default. Todis serves the verification page itself (QR code,
   fr/en/es copy): no frontend work needed. Redirect your user to the
   returned URL, get them back on your `success_url`/`cancel_url`.
2. **Raw session flow** (`POST /verify/sessions` +
   `GET /verify/sessions/{id}`) — you display the QR code or wallet
   link yourself (from `authorization_request`), and poll the result.
   Useful if you already have your own verification UI.
3. **Direct verification** (`POST /verify/sd-jwt-vc`,
   `POST /verify/mdoc`) — you already obtained a presentation some
   other way (you run your own OpenID4VP dialogue) and just want Todis
   to check its signature and trust chain.

## Endpoint reference

| Method | Route | Auth | Role |
|---|---|---|---|
| POST | `/hosted/sessions` | token | Creates a hosted verification journey (page + QR served by Todis) |
| POST | `/verify/sessions` | token | Creates an OpenID4VP session (you handle the display) |
| GET | `/verify/sessions/{id}` | token | Result of a session: `pending`/`verified`/`failed` |
| POST | `/verify/sd-jwt-vc` | token | Verifies a standalone SD-JWT VC presentation |
| POST | `/verify/mdoc` | token | Verifies a standalone mdoc presentation |
| GET | `/usage` | token | Total authenticated request count for your account |
| GET | `/usage/history` | token | Daily history (Standard+), CSV export and per-endpoint breakdown (Premium) |
| POST | `https://todis.eu/trial` | none | Issues a free 50-request trial token, no sign-up. The only route that needs no token, and the only one served from `todis.eu` rather than `verify.todis.eu` |

## Hosted flow — the shortest path

```bash
curl -X POST https://verify.todis.eu/hosted/sessions \
  -H "Authorization: Bearer $TODIS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "check": "age_over_18",
    "country_code": "FR",
    "locale": "en",
    "reference": "order-1042",
    "success_url": "https://shop.example/order/1042/verified",
    "cancel_url": "https://shop.example/order/1042/failed",
    "webhook_url": "https://shop.example/webhooks/todis"
  }'
```

Response (`201`):

```json
{
  "session_id": "<session id>",
  "hosted_url": "https://verify.todis.eu/v/<page token>",
  "expires_at": "2026-08-19T12:34:56Z",
  "webhook_secret": "<webhook secret>"
}
```

**The hosted flow asks for an encrypted wallet response by default**
(`response_mode=direct_post.jwt`), unlike `POST /verify/sessions` where
the default is plain. Some national wallets accept nothing else and give
up silently when the session is plain, which is exactly the kind of
failure this flow exists to spare you. Pass `encrypted_response: false`
if the wallet you target cannot encrypt.

Redirect the user's browser to `hosted_url`. Two shortcuts for `check`
(instead of the detailed fields below), available on **every plan**:

- `"age_over_18"` — asks for the least intrusive proof of age the
  wallet can provide, in order: the EU Proof of Age attestation, then
  the PID's optional age attribute, then the PID's date of birth as a
  last resort (see the PID section below). The result is always a plain
  `{"age_over_18": true|false}`, whichever route the wallet took.
- `"identity"` — family name, given name, date of birth, nationality,
  returned under stable keys (`family_name`, `given_name`,
  `birth_date`, `nationality`) whatever the credential format.
    Stable **shapes** too: `nationality` is always a list, even when the
    wallet discloses a single value, so that `nationality[0]` never
    depends on which wallet your user carries.

Both accept the PID in **either format** (SD-JWT VC or mdoc), each with
its own trust anchor. Which routes are offered depends on the
deployment: an mdoc route is only proposed when its issuer certificate
is configured, since mdoc has no automatic `country_code` resolution.
On `verify.todis.eu`, `country_code` alone is enough for the SD-JWT VC
route.

`check` is only a shortcut: the same fields as `POST /verify/sessions`
(`vct`+`claims`, `doctype`+`mdoc_claims`, or `dcql_query`) work here
too. Note that these shortcuts build a `dcql_query` internally, which
does **not** make them Premium-only: the plan restriction applies to a
query *you* supply, never to one the service builds for you.

**Opening the wallet from the browser (`dc_api`).** With
`"dc_api": true`, the hosted page offers the visitor a button that
opens 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 EU 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. A deployment where the feature is not open answers `400`
with `` `dc_api` is not enabled on this deployment ``. This is a
browser feature: outside a browser, for example in a native app, use
the QR code or the deep link.

**Important — always confirm server-side.** The parameters
(`session_id`, `status`, `reference`) appended to `success_url`/
`cancel_url` can be forged by the user: never trust a browser redirect
alone. Call `GET /verify/sessions/{session_id}` with your token to
read the actual result.

**`success_url` means the verification succeeded, never that the user
is of age.** A verified session always returns to `success_url`,
including when the proof of age is `false`: with `check:
"age_over_18"`, a minor lands there too. Read `claims.age_over_18`
server-side and apply your own rule to it (your threshold, what you do
when it is not met). `cancel_url` only means the journey failed.

**Webhook** (optional, recommended alongside the redirect — it fires
even if the user closes the page before returning): a JSON POST
`{"event": "session.verified" | "session.failed", "session_id",
"reference", "timestamp"}` (`session.verified` also carries `trust`,
where the anchor that validated the result came from), signed with
HMAC-SHA256 in the
`X-Todis-Signature: t=<unix timestamp>,v1=<hex>` header — the HMAC
covers `"<t>.<raw body>"` using the `webhook_secret` received at
creation. Retried after 5 s, 1 min, then 10 min as long as your
endpoint doesn't answer `2xx`. The webhook never carries the verified
claims: read those via `GET /verify/sessions/{id}`.

The journey expires with its underlying session (`expires_at`, 15
minutes after creation).

## Raw session flow — when you handle your own UI

```bash
curl -X POST https://verify.todis.eu/verify/sessions \
  -H "Authorization: Bearer $TODIS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "vct": "https://example.eu/credentials/pid",
    "claims": ["age_over_18"],
    "country_code": "FR"
  }'
```

Response (`201`) — `authorization_request` is what to encode as a QR
code (cross-device) or a deep link (`openid4vp://...`, same-device)
for the user's wallet:

```json
{
  "session_id": "<session id>",
  "authorization_request": {
    "response_type": "vp_token",
    "response_mode": "direct_post",
    "client_id": "redirect_uri:https://verify.todis.eu/verify/sessions/<id>/response",
    "response_uri": "https://verify.todis.eu/verify/sessions/<id>/response",
    "nonce": "GNKfJzbzzXk12MRzopa-yovIXpDzjtYXf7Po8mAmSlQ",
    "state": "k5FphDmkbkk3BvLEdBtCzt7ksw6htE8H2yGHkZ0zqAs",
    "aud": "https://self-issued.me/v2",
    "dcql_query": {
      "credentials": [
        {
          "id": "cred1",
          "format": "dc+sd-jwt",
          "meta": { "vct_values": ["https://example.eu/credentials/pid"] },
          "claims": [{ "path": ["age_over_18"] }]
        }
      ]
    }
  }
}
```

**What exactly goes into the QR code or the deep link.**
`authorization_request` is a JSON object, and a QR code carries a
string. Build that string the way this service's own hosted page does:

- **If the response carries `request_uri`** (the service has a signing
  identity configured, which is the case on `verify.todis.eu`), that is
  all a wallet needs — two parameters, nothing else, both URL-encoded:

  ```
  openid4vp://?client_id=<authorization_request.client_id>&request_uri=<request_uri>
  ```

  Add `&request_uri_method=post` if you want the wallet to fetch it by
  POST (OpenID4VP §5.10); a wallet that ignores the parameter still
  works.

- **Without `request_uri`** (self-hosted deployment with no signing
  identity), carry the request parameters themselves:
  `response_type`, `response_mode`, `client_id`, `response_uri`,
  `nonce`, `state`, `dcql_query` and `client_metadata` (both JSON
  objects, serialized then URL-encoded). Do not drop
  `client_metadata`: it carries the formats you accept and, on an
  encrypted session, the key the wallet needs to answer at all. A
  wallet that does not find it cannot reply.

Do **not** copy `aud` into the link: it is a claim of the signed request
object, not an authorization request parameter. `openid4vp://` is the
protocol's default scheme; some wallets register their own, so make the
scheme configurable in your integration.

Then poll the result (`pending` while waiting, either client-side
polling or a server-side wait):

```bash
curl https://verify.todis.eu/verify/sessions/<id> \
  -H "Authorization: Bearer $TODIS_TOKEN"
```

```json
{
  "status": "verified",
  "claims": { "age_over_18": true },
  "trust": { "source": "lotl", "country_code": "FR", "certificate_sha256": "..." }
}
```

or, on failure: `{ "status": "failed", "error": "..." }`. Unknown or
expired session (15 minutes): `404`.

`trust` says where the anchor that validated the result came from:
`{"source": "lotl", "country_code", "certificate_sha256"}` for a
country's trust list, `{"source": "registry", "registry", "anchor",
"certificate_sha256", "sources_commit"}` for an anchor of this
deployment's registries, `{"source": "request"}` otherwise. It is one
object next to `claims`, and one entry per credential identifier next
to `credentials`.

For a session created with `doctype`/`mdoc_claims`, `claims` nests the
elements under their namespace, the same way `credentials` does for an
mdoc credential below:

```json
{
  "status": "verified",
  "claims": {
    "eu.europa.ec.eudi.pid.1": { "given_name": "Erika", "family_name": "Mustermann", "birth_date": "1964-08-12" }
  }
}
```

For a session created with `dcql_query`, `verified` carries
`credentials` instead of `claims` — one claims object **per credential
identifier of your query**, with an mdoc credential nesting its
elements under their namespace:

```json
{
  "status": "verified",
  "credentials": {
    "pid": { "given_name": "Erika", "family_name": "Mustermann" },
    "licence": { "org.iso.18013.5.1": { "given_name": "Erika" } }
  }
}
```

A credential your query marked optional and that the wallet did not
present is simply absent from the object.

**Polling costs requests.** Every authenticated call is billed,
`GET /verify/sessions/{id}` included. A session lives 15 minutes, so
polling once a second would cost up to 900 requests for a single
verification. Poll every 2 to 5 seconds from **your server** (not from
each visitor's browser), or use the hosted flow's webhook and stop
polling altogether.

**Fields of `POST /verify/sessions`**, three mutually exclusive ways
to describe what's being requested:

| Field(s) | Target format | Notes |
|---|---|---|
| `vct` + `claims` | SD-JWT VC (default) | `claims`: simple paths, e.g. `"age_over_18"` |
| `doctype` + `mdoc_claims` | mdoc (ISO/IEC 18013-5) | `mdoc_claims`: `[{"namespace": "...", "element_identifier": "..."}]` ; verified against this deployment's mdoc trust registry (no `country_code` for mdoc) |
| `dcql_query` | either format, multiple credentials per request | Full DCQL grammar (OpenID4VP §6): `credential_sets`, `claim_sets`, constrained `values` — **Premium plan only** (`403` otherwise). At most 10 credentials, claim paths by name only (no array indices). Result appears under `credentials` rather than `claims` in `GET /verify/sessions/{id}` |

**Issuer trust resolution.** For an SD-JWT VC, `country_code` resolves
the issuer through that country's national trust list, via the
European Union's official list of trusted lists; without
`country_code`, the credential is verified against this deployment's
SD-JWT VC trust registry, which carries the authorities and the signing
certificates of the accepted issuers: against an authority, the signing
certificate in the credential's `x5c` header is verified up to it,
signature and validity window included, over a single level, and against
a signing certificate, its key must verify the credential's signature.
An mdoc credential is verified against this
deployment's mdoc trust registry, which carries the IACAs of the
accepted issuers: the signer chain embedded in the credential is
verified up to one of them, signature and validity window included,
over a single level as ISO/IEC 18013-5 intends. An anchor only ever
applies to the credential types its source declares.

List the accepted anchors and countries before you create a session.
The call is public (no token) and cacheable (`Cache-Control: public,
max-age=300`, `ETag`, `304` on `If-None-Match`):

```bash
curl https://verify.todis.eu/trust/registries
```

It returns, per registry (`mdoc`, `sd_jwt_vc`), its state, the sources
commit and assembly date of the loaded base, its sources, and each
anchor with its role (`authority` or `signer`), the types it covers,
its subject, its certificate's fingerprint and validity dates, and
whether it is valid now. A source read from a VICAL, the signed list of
mdoc issuing authorities, carries `vical` (provider, issue number, date,
next update, file fingerprint), and `discarded` lists the entries of
those VICALs that did not become anchors, with their `reason` and
`detail`: that is where a missing expected anchor is explained.
`lotl.countries` lists the countries a
`country_code` can reach. To report a missing anchor:
`report_missing_anchor`.

Session creation answers `400` when no valid anchor covers the
requested type, and `503` when the registry it depends on has no base
loaded. A verified session says where its anchor came from, in `trust`
(see the result shapes above).

**Proof of possession** (SD-JWT VC): the credential must declare its
holder's key in `cnf.jwk`, and the presentation must carry a Key
Binding JWT signed with that key and bound to the session's `nonce` and
`client_id`. A presentation without a Key Binding JWT fails the session
with `key binding JWT missing: ...`, and a credential without a usable
`cnf.jwk` fails it with `the credential declares no usable holder key
(cnf.jwk), ...`. This is the default of OpenID4VP 1.0
(`require_cryptographic_holder_binding`, §6.1), and HAIP 1.0 (§6.1.1.1)
makes the Key Binding JWT mandatory for a holder-bound credential. The
`vct` + `claims` fields always require it. In a `dcql_query`, a
credential query may set `"require_cryptographic_holder_binding":
false`: the wallet is told so in the authorization request, and a
presentation without that proof is then accepted. In a session, an
mdoc is always authenticated by its device signature, whatever this
field says.
`POST /verify/sd-jwt-vc` has no session to bind a proof to and does not
require one; a Key Binding JWT it receives is still verified.

**Per-credential trust** (`dcql_query` sessions only):
`credential_trust` gives an SD-JWT VC credential of the query its own
`country_code`, e.g. `{"pid": {"country_code": "FR"}, "por":
{"country_code": "DE"}}`, and still no `country_code` for an mdoc
credential. A credential without an entry uses the session's
`country_code`, otherwise its format's registry. Each entry only ever
applies to its own credential (one credential's entry never validates
another). SD-JWT VC and mdoc credentials can be mixed in a single
query.

**Advanced options**: `encrypted_response` (boolean, false by default)
requires an encrypted wallet response
(`response_mode=direct_post.jwt`). The default is `false` here, unlike
the hosted flow, so this is the endpoint where you have to act. **Some
national wallets accept nothing else** and, without encryption, 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.

Turning it on also makes `redirect_uri` mandatory (HAIP 1.0 §5.1), and
a mismatch shows up as an error from the *wallet*, not from this API.
**That address must be reachable from the device running the wallet**,
not from your server. This API accepts any value and echoes it back
without checking it, but 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 misleading, because it looks like a failure and
is not one: verification succeeded, the result reaches you on the
browser side, and it is the phone that shows an error. Read it as a
broken return path, not as a rejected credential.

**What this API does not have**, so you do not go looking:

- no webhook on `POST /verify/sessions` — `webhook_url` exists only on
  the hosted flow (`POST /hosted/sessions`); with the raw flow, poll;
- no way to cancel a session — an abandoned one simply expires after
  15 minutes;
- no `expires_at` on `POST /verify/sessions` (the hosted flow returns
  one): compute it as creation time plus 15 minutes;
- no `country_code` resolution for mdoc credentials: they are verified
  against this deployment's mdoc trust registry, and a `country_code`
  in the `credential_trust` entry of an mdoc credential is refused at
  creation with a `400`.

If the response carries a `request_uri` field, a strict (HAIP-profile)
wallet will fetch it by `GET` (or `POST` if you add
`request_uri_method=post` to the deep link) rather than reading
`authorization_request` directly — still provided in every case for
inspection/debugging.

## What to ask for: the European PID, and proof of age

The examples above use a placeholder `vct`. Here are the real
identifiers, taken from the ARF PID Rulebook and cross-checked against
the published metadata of the Commission's reference issuer
(`issuer.eudiw.dev`). National profiles may extend the base type, so
keep these configurable rather than hard-coded.

The type a credential carries is compared with the one you request as
an exact, case-sensitive string, and type inheritance is not resolved:
to accept a national PID, list its `vct` in `vct_values` next to
`urn:eudi:pid:1`. A credential of a type you did not request, or an
SD-JWT VC without a `vct`, fails the session, and the error names the
type received and the types requested. For SD-JWT VC, the `check`
shortcuts request `urn:eudi:pid:1`, `urn:eudi:pid:de:1` and
`urn:eudi:pid:fr:1`.

| | SD-JWT VC | mdoc (ISO/IEC 18013-5) |
|---|---|---|
| Identifier | `vct` = `urn:eudi:pid:1` | `doctype` = `eu.europa.ec.eudi.pid.1` (also the namespace) |
| Family name | `family_name` | `family_name` |
| Given name | `given_name` | `given_name` |
| Date of birth | `birthdate` | `birth_date` |
| Nationality | `nationalities` (list) | `nationality` |
| Age over 18 | `age_equal_or_over` → `18` | `age_over_18` |

**The two encodings do not spell attributes the same way.** That is the
single most common mistake: asking an SD-JWT VC PID for `birth_date`
(the mdoc spelling) returns nothing, because the claim simply does not
exist under that name. We made that exact mistake ourselves.

**Age needs care.** The date of birth is *mandatory* in every
conformant PID, but the age attributes are *optional* — a PID provider
may not include them, and the Commission's reference issuer does not.
There are three ways to establish majority, from least to most
intrusive:

1. the EU **Proof of Age attestation**, `doctype` and namespace
   `eu.europa.ec.av.1` (mdoc), which by specification carries no other
   attribute — no name, no document number, no date of birth;
2. the PID's optional age attribute, when the issuer included it: the
   boolean alone, the date stays hidden;
3. the PID's date of birth, always available: you receive the full date
   and compute the answer yourself.

A verifier can never derive majority without receiving one of these.
`check: "age_over_18"` asks for all three in that order, each only when
this deployment can verify it, and hands you a plain
`{"age_over_18": true}` whichever route the wallet took.

**A query accepting the PID in either format** (Premium plan, since it
uses `dcql_query`): the SD-JWT VC PID resolves through `country_code`,
the mdoc PID through this deployment's mdoc trust registry:

```json
{
  "dcql_query": {
    "credentials": [
      {
        "id": "pid_sdjwt",
        "format": "dc+sd-jwt",
        "meta": { "vct_values": ["urn:eudi:pid:1"] },
        "claims": [{ "path": ["given_name"] }, { "path": ["birthdate"] }]
      },
      {
        "id": "pid_mdoc",
        "format": "mso_mdoc",
        "meta": { "doctype_value": "eu.europa.ec.eudi.pid.1" },
        "claims": [
          { "path": ["eu.europa.ec.eudi.pid.1", "given_name"] },
          { "path": ["eu.europa.ec.eudi.pid.1", "birth_date"] }
        ]
      }
    ],
    "credential_sets": [{ "options": [["pid_sdjwt"], ["pid_mdoc"]] }]
  },
  "credential_trust": {
    "pid_sdjwt": { "country_code": "FR" }
  }
}
```

Note the shapes: an mdoc claim path is `[namespace, element]` (two
names, not an array index), an mdoc credential names its type in
`meta.doctype_value` while an SD-JWT VC one names it in `meta.vct_values`
(required: a credential query without its type is refused with `400`),
and `options` lists
alternatives in **decreasing order of preference**. `credential_sets`
also accepts an optional `purpose`, passed to the wallet untouched so it
can explain to the user why the data is requested.

## National wallets, verified end to end

What a given national wallet actually issues decides three things at
once: the request mode, the trust resolution, and the result shape.
The table above cannot tell you that, so here is what we have verified
against real wallets.

| Wallet | PID format | Ask with | Trust anchor | Also required |
|---|---|---|---|---|
| France Identité (the citizen app, production) | mdoc only | `doctype` + `mdoc_claims`, doctype and namespace both `eu.europa.ec.eudi.pid.1` (or a `dcql_query`) | this deployment's mdoc trust registry: `GET /trust/registries` must list a valid anchor covering `eu.europa.ec.eudi.pid.1` (the ANTS publishes the production PID IACA on the wallet's page of the France Identité playground, https://playground.france-identite.gouv.fr/marketplace/wallets/fin/ ; the pre-production chain is for the "Partenaires" build only) | `encrypted_response: true`, hence `redirect_uri` |

Verified on 2026-08-29 with a production France Identité wallet,
using this service's `client_id` exactly as it is issued
(`x509_hash:...`, HAIP profile). France Identité accepts that scheme
even though its own integration profile documents `x509_san_dns`:
nothing on your side can or needs to change it.

Since mdoc has no automatic `country_code` resolution, an SD-JWT VC
style session (`vct` + `claims` + `country_code`) aimed at France
Identité is accepted with a `201` and then stays `pending` forever:
the wallet simply holds no credential matching the request. That
silent outcome is the reason this table exists.

## Direct verification (no session)

If you already have a presentation in hand (obtained some other way):

**SD-JWT VC:**
```bash
curl -X POST https://verify.todis.eu/verify/sd-jwt-vc \
  -H "Authorization: Bearer $TODIS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "presentation": "<jwt>~<disclosure>~...~",
    "country_code": "FR"
  }'
```
→ `{"verified": true, "claims": {"age_over_18": true}}`

**mdoc:**
```bash
curl -X POST https://verify.todis.eu/verify/mdoc \
  -H "Authorization: Bearer $TODIS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "device_response": "<DeviceResponse CBOR, base64url-encoded>",
    "issuer_trust_anchor_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
  }'
```
→ `{"verified": true, "doc_type": "org.iso.18013.5.1.mDL", "namespaces": {"org.iso.18013.5.1": {"age_over_18": true}}}`

Failed verification (invalid signature, expired JWT, wrong trust
anchor...): status `422`, `{"verified": false, "error": "..."}`.

## Error codes, to handle everywhere

| Status | Meaning |
|---|---|
| `400` | Malformed request (missing required field, inconsistency) |
| `401` | Missing, invalid, revoked or expired token |
| `403` | Feature not included in your plan (`dcql_query` = Premium, detailed `/usage/history` = Standard+) |
| `404` | Unknown or expired session (15 minutes) |
| `422` | Malformed presentation, or signature/trust not verified (`verified: false`) |
| `429` | Plan quota exceeded (the message gives the exact limit, e.g. `1800 requests/minute`) |
| `502` | European trust registry resolution failed (network, `country_code`) |
| `501` | Feature unavailable on this deployment (hosted flow without a signing identity, history without a database — does not concern `verify.todis.eu`) |

## Usage tracking

`GET /usage` — total authenticated requests for the account (across
all client IDs tied to your email). **Not instantaneous**: usage is
aggregated and flushed in one-minute cycles, so a request made just now
may not be counted yet. Wait a minute before concluding the figure is
wrong.
```json
{ "client_ids": ["cus_ABC123"], "total_authenticated_requests": 42 }
```

`GET /usage/history?days=30&format=csv` — daily history (Standard+;
per-endpoint breakdown and latencies, up to 400 days, CSV export:
Premium only).

## Further reading

- Full OpenAPI spec, always up to date: https://todis.eu/openapi.json
- The flow explained in prose, with a diagram: https://todis.eu/en/workflow.html
- Pricing and free trial: https://todis.eu/en/pricing.html
- Detailed use cases: https://todis.eu/en/use-cases.html
