How a verification works
The session lifecycle from init to verdict, which calls are authenticated and which cannot be, and exactly what the merchant learns at the end.
Last updated: 2026-09-04
A verification is a short-lived session with one job: turn "is this shopper old enough?" into a boolean your checkout can act on, without your checkout ever learning anything else about them.
The shape of it
There are five moving parts, and only two of them involve your server.
- Your server starts a session.
POST /api/verification/initwith your API key. You get back a session id, an expiry, and a URL to send the shopper to. - The shopper's browser goes to that URL. They authenticate with an electronic identity method — a bank login, for example — on the identity provider's own pages, not on yours and not on ours.
- The provider redirects the browser back to us. The verdict is decided at that moment, server-side.
- Your waiting page follows along. It polls
GET /:sessionId/statusor holds openGET /:sessionId/events, so the shopper sees the outcome without refreshing. - Your server reads the verdict.
GET /:sessionId/resultwith your API key. This is the only read your business logic should trust.
Steps 1 and 5 carry your key. Steps 2, 3 and 4 cannot — they are entered by a shopper's browser or a third party's redirect, neither of which can hold a merchant secret. Authentication explains why that is a design decision rather than an omission.
Which methods are live
Read `supportedMethods` before you assume anything
GET /api/config is unauthenticated and returns a supportedMethods array for the deployment you are calling. An
empty array means no verification method is configured there, and every session start will fail closed. That is
the authoritative answer for a given deployment, and it is the first call to make — not the last.
The implemented verification path is bank-identity methods delivered through a broker integration. When a method's credentials are configured for a deployment, its key — idin, for example — appears in supportedMethods and sessions can be started against it. When they are not, POST /api/verification/init answers 503 with METHOD_UNAVAILABLE rather than guessing or falling back to a different method.
EUDI wallet verification is on the roadmap. It is not a current capability of this API and no configuration change makes it one; the eudi production path is disabled unconditionally. Treat it as a future addition rather than something to schedule a launch around.
Starting the session
The request body's only required field is verificationType. It validates against age, country and both, but /api/config's supportedVerificationTypes is what tells you which of these a given deployment can actually deliver — mirroring supportedMethods, it is empty while no method is configured and lists age once a broker method is, since that path serves age only. Sending country or both to it is rejected with a 400 rather than being quietly downgraded to an age check that could never satisfy what you asked for.
The age threshold on that path is fixed at 18 and there is no request field that changes it. A body carrying an unknown property is rejected outright, so inventing one does not degrade gracefully.
A successful call answers 201 with:
{
"sessionId": "0f3c1c2b-9a4d-4d1f-8c1a-2b6d5e4f7a90",
"expiresAt": "2026-09-04T12:15:00.000Z",
"authorizationUrl": "https://broker.example/authorize?..."
}
Sessions on the broker path live 15 minutes from creation. Store the sessionId against your order — every later call in the flow takes it. Full field reference: create a verification session.
The identity step and the return trip
Send the shopper's browser to authorizationUrl. What happens there belongs to the identity provider.
The provider then redirects the browser back to a single, fixed, pre-registered address — GET /api/verification/broker-callback. Because that address is fixed, the session id cannot live in its path; it is recovered from the OAuth state parameter, which is a random token bound to the session at creation and checked on return. A callback whose state does not match the session is rejected rather than trusted.
The verdict is decided during that callback, and it fails closed at every branch:
| What came back | Session becomes |
|---|---|
| A valid code, and the provider says the threshold is met | verified |
| A valid code, and the provider says it is not met | rejected — a real, determinate answer |
| The provider could not determine an answer | rejected |
| The shopper cancelled, or the provider returned an error | rejected |
A mismatched or missing state, or a missing code | rejected |
| The session's 15 minutes had already elapsed | expired |
Afterwards, if you supplied a redirectUrl at init and it passes the redirect policy, the browser is sent there with sessionId and status appended as query parameters. If you did not, the callback answers a small JSON body with the same two values.
Those query parameters are not proof of anything
?status=verified arrives on a URL that travelled through the shopper's own browser. Use it to decide which page to
render. Confirm the outcome server-side, from /result, before you let it unlock a purchase.
Watching from the waiting page
While the shopper is away, the page you left behind can follow the session two ways, and both take the session id rather than an API key:
- Stream events —
GET /:sessionId/eventsis Server-Sent Events. It sends the session's current state immediately on connect, then a message on every change, so a reconnect never needs replay. - Poll status —
GET /:sessionId/statusis the fallback for clients that cannot hold a connection open.
A session moves through pending → scanned and lands on one of verified, rejected or expired. Those three are terminal; stop watching when you see one.
Both routes are conveniences for the waiting UI. Neither carries proof of which merchant a session belongs to, which is precisely why neither is the read your checkout should gate on.
What the merchant learns
Your server calls GET /:sessionId/result with your key. It answers three fields:
{
"status": "verified",
"verified": true,
"thresholdMet": true
}
That is the whole payload, and there will never be a fourth field carrying personal data. No date of birth. No age value. No name, country, document number or address. Not withheld and available on request — simply not part of the shape.
The same restraint runs the whole way down. No date of birth is stored, logged or returned at any point in the flow; what is kept for audit correlation is a non-reversible hash of the subject identifier plus the booleans. If you are migrating from a provider that handed back a date of birth, there is no field to map it to, and the code that used to store one should stop.
One field that looks like an exception and is not
The status response contract declares a verifiedAttributes object, for verification paths that produce attributes.
The implemented age-only path produces none, so it is absent. Treat it as optional and never build against its
contents — and note that it would appear on the unauthenticated status route, which is another reason your verdict
comes from /result.
Reading the result correctly
Two things catch integrations out:
- A non-terminal
statusis not a failure.pendingwithverified: falsemeans the shopper has not finished, not that they failed. Wait forverified,rejectedorexpiredbefore the answer decides anything. - A
404on/resultis not always "no such session". The endpoint answers404for a session that belongs to a different merchant as well, deliberately, so that holding someone else's session id reveals nothing. Treat it as "this is over and you did not get a pass".
thresholdMet is derived from the same terminal state as verified today, so the two always agree. Gate on verified.