Create a hosted identity verification, redirect your user to it, and read back the result — three endpoints, one credential, no SDK required.
Scan the code and go through the capture flow your users would see. The result arrives here as your server would receive it. This uses sandbox mode: no real check runs and no images are kept.
You're signing up to Hoppa, a made-up e-scooter app that needs riders to be 18+. Run the real capture flow on your phone with a passport or ID card, or tap “Use a sample document” if you don't have one handy. In demo mode nothing is checked and nothing is stored.
// Start the demo to see the API response // POST /api/v1/verification_sessions
The Identity API follows the same shape as Stripe Identity's redirect integration: your server creates a verification session, gets back a hosted URL, and sends your user there. Once they finish (or abandon) the check, they land back on your return_url and your server reads the result with a second call. No JavaScript, iframe, or camera-permission handling on your side — the whole capture flow runs on LiveVerify's own hosted page. Prefer a popover instead of a redirect? See Embed the modal.
Every request is scoped to your organization by the API key you authenticate with. A session created with your key is only ever readable, cancellable, or visible in webhooks to that same key's org — a session ID belonging to someone else's account returns a plain 404, indistinguishable from an unknown ID.
Base URL: https://api.liveverify.me, path-prefixed with /api/v1.
Every request carries an Identity API key, either as a bearer token or as an x-api-key header:
Create a key from your dashboard — Settings → LiveVerify API keys. Only an org owner or admin can create or revoke keys. The raw key is shown exactly once, at creation; only its prefix is ever shown again, so store it somewhere safe immediately.
A key is issued with one or both of these scopes:
| Scope | Grants |
|---|---|
| identity:write | Create and cancel verification sessions. |
| identity:basic | Read a session's status and result. |
A key without the required scope for an endpoint gets a 403, not a401 — the key itself is valid, it just isn't allowed to do that.
Every org has two keys available: a live key (lvfy_live_...) and a sandbox key (lvfy_test_...). Sessions created with a sandbox key:
Pass test_outcome when creating a sandbox session to control exactly what it resolves to — "verified", "failed", or "expired". Sending test_outcome on a live key is rejected with a 400 rather than ignored — it's almost always a copy-pasted test hitting production by mistake, and a silent no-op would be a worse failure mode than an obvious error.
Every field is optional.
| Field | Type | Notes |
|---|---|---|
| return_url | string | Where to send the person back once their check resolves. See return_url rules. |
| locale | string | Pins the capture page's language. See Locale. |
| reason | string | Plain-language reason shown to the person, e.g. "opening a trading account". Up to 280 characters. Without one, the person just sees your organization's name with no context for why they're being asked — strongly recommended, especially for an agent-triggered check. |
| minimum_age | integer | Minimum age, 18 to 99 (default 18 — LiveVerify never verifies under-18s). If your organisation sets a minimum in Settings → Verification, that is a floor: this can raise it, never lower it. A passed session means the holder is at least the age applied (returned as minimum_age); someone younger fails with document_underage. The date of birth is never returned or stored. |
| age_only | boolean | true for an age check only: the result answers whether the person is old enough (age_check) and verified_name is always null. The name is never stored either. For adult content, alcohol, gambling and other age-gated services. Can't be combined with expected_name. Always on when your organisation has Age-only mode switched on in Settings → Verification. |
| expires_in_minutes | integer | How long the link works, 10 to 2880 (48 hours). Default 30 — right when the person is in front of you. Use 1440 (24 hours) when you send the link by email or message, together with expected_name. |
| expected_name | string | Full name of the person you expect, e.g. "Jan de Vries". Recommended whenever you send the link by email or message, since links can be forwarded. At resolution the document's name is compared and returned as name_match. Held only until the session resolves, then deleted. |
| test_outcome | string | Sandbox keys only — see Sandbox mode. |
Redirect your user to url. They land on a hosted capture page — no embedding required.
Links work for 30 minutes by default (set expires_in_minutes for longer) and only once. LiveVerify never emails or texts the link itself: if the person isn't in front of you, send customer_message (English, Dutch, German or French, following locale) from your own address, and set expected_name.
On a live key, session creation also checks (in order): your org is on the LiveVerify beta allowlist, your org's own identity has been verified once (Settings → Verify your own identity), and your org has an account balance. All three are skipped entirely for sandbox keys. If you've resolved your monthly verification limit for the month, creation is blocked with a 402 until the next month or a limit increase — this doesn't apply to sandbox sessions either.
The person chooses which document to show. Which ones a session accepts depends on your settings (Settings → Verification → Accepted documents), not on anything in the API request.
| Document | Accepted | How it's checked |
|---|---|---|
| Passport | Always | Machine-readable zone (MRZ) with check digits; optional chip read (in testing). |
| National ID card, residence card | Always | MRZ with check digits, front and back; optional chip read (in testing). |
| US and Canadian driving licence | Always | PDF417 barcode on the back. |
| EU and UK driving licence | Your setting (default: age checks only) | UK, Netherlands, Belgium, France, Germany, Spain. No MRZ, so the printed details are read and checked for consistency: licence-number rules (the UK number encodes surname and date of birth; NL and FR carry a check digit), valid dates, and the national validity period. |
Every document also goes through the face match against the selfie and the liveness check, and must not be expired. A driving licence is weaker evidence than a passport or ID card, because there are no check digits or chip behind the printed text. That's why EU and UK licences are off for checks that return a name unless you turn them on.
| Setting | Accepted on |
|---|---|
| Only on age-only checks and kiosks (default) | Sessions created with age_only: true, every session when your organisation is set to Age only, and kiosks that hide the name from staff. |
| Also on checks that return a name | Every session. |
| Off | Never: passport or ID card only. |
To see which document a passed check used, read document_type on Retrieve a session or in the webhook. If you need passport-grade evidence for a decision, treat drivers_license accordingly, or turn EU and UK licences off for those checks.
Call this once your user is back on your return_url to find out what happened.
| Field | Notes |
|---|---|
| status | pending · passed · failed · cancelled |
| verified_name | Set only when status is passed; null otherwise. This is the only verified field this API returns — no date of birth, document number, or expiry is retained or exposed. |
| failure_code | Set only on a failed check, drawn from a small fixed set (e.g. a document/selfie mismatch) — enough to tell a genuine failure apart from a retry-worthy one. |
| document_type | On a pass, the document it was verified with: passport, id_card (national ID or residence card) or drivers_license. null otherwise. Also returned for age-only checks (it isn't personal data). See Accepted documents. Also in webhooks. |
| document_type_hint | Coarse document category detected during a failed attempt, when recognizable (same values as document_type). Never OCR text. |
| reason | Whatever you passed at creation, echoed back — null if you didn't set one. Never changes after creation. |
| minimum_age / age_check | The age threshold applied (18 unless you set one) and met (passed — the holder is at least that age), not_met (failed on age) or null. Also in webhooks. |
| age_only | true when this was an age-only check: verified_name is null by design, not because the name couldn't be read. Also in webhooks. |
| age_token | On a pass: signed proof that the person is at least minimum_age, with no personal data in it. Valid for 10 minutes; retrieve the session again for a fresh one. See Age tokens. null otherwise. Also in webhooks. |
| chip | In testing. Only when the person's passport or ID card chip was read (Passport chip check, with the LiveVerify Android app), otherwise null: verdict (original_chip = data signed by the issuing country and not a copy; genuine_data_chip_not_checked; clone_suspected; not_verified), active_auth, issuing_country and face_match (selfie vs the chip's photo). Extra evidence: it never changes status. Also in webhooks. |
| consent_given_at | ISO 8601 time the person pressed “Agree and continue” on the consent screen, or null if they never reached it. Use it to evidence your end user's consent to the face comparison. Also in webhooks. |
| name_match | Only when you set expected_name: match, partial (e.g. a given name differs — worth a human look), mismatch (the document is someone else's), not_checked (the session didn't pass) or pending. Extra evidence: it never changes status. Also included in webhooks. |
This call is also what triggers your webhook. See Webhooks — a session your server never retrieves won't fire one.
Cancels a still-open session — for example, if your own flow no longer needs it.
Returns 409 if the session already resolved (passed, failed, or was already cancelled) — cancellation only ever applies to a still-pending session.
Every passed session comes with an age_token: a signed JSON Web Token (ES256) that proves a LiveVerify check found the person to be at least a given age. It holds no name, date of birth or document details, so you can keep it as evidence of the check, or pass it to an auditor, without holding personal data. It is bound to your organisation (aud) and expires 10 minutes after it was issued; verified_at is when the check itself passed.
Verify the signature with any standard JWT library against our public keys at https://liveverify.me/.well-known/jwks.json. Check iss, aud and the header typ of lv-age+jwt, and reject test: true in production.
No API key needed. Send {"token": "…", "audience": "<your organisation id>"}; audience is optional. The reply is always 200 with valid: either the age, times and checks, or an error of malformed, unknown_key, bad_signature, wrong_audience, expired or not_yet_valid.
https — no http, no relative paths.user:pass@host).return_url's host must be one of them, or a subdomain of one. Registering example.com also covers app.example.com.Create a session exactly as above, then instead of redirecting to url, open it in a popover on your own page with the LiveVerify snippet:
onComplete fires once, with { status: "passed" | "failed" }, as soon as the person reaches a final result. It does not close the modal: the person sees LiveVerify's result screen, and the modal stays open until they dismiss it with the button on that screen, the X, or Escape. You don't need to close anything yourself. onClose fires only when the modal is dismissed with no result — the X button, Escape, or declining consent before finishing — so a person closing the result screen never triggers it. Either way, your server should still confirm the outcome with Retrieve a session or a webhook rather than trusting the callback alone — the modal posts a message from the client, the same trust level as a redirect query param.
Requires a registered domain. The framed page refuses to render inside an iframe on any origin except one your org has registered — see return_url rules and Settings → Registered domains. There's no unrestricted fallback here the way there is for return_url: an org with zero registered domains can't embed at all, only redirect.
You can try this from your own dashboard first — Settings → Embed a verification (modal) opens the same modal against a real session of your own, no code required.
Pin the capture page's language with a supported tag. An unrecognized or omitted tag is silently ignored — the page falls back to browser detection — never rejected, since a session must never fail to open over a language tag.
Every error response is a single JSON object:
| Status | Meaning |
|---|---|
| 400 | Bad request — an invalid return_url, or an invalid/misused test_outcome. |
| 401 | Missing, malformed, invalid, or revoked API key. |
| 402 | Your org has reached its monthly verification limit. |
| 403 | Valid key, but missing the required scope — or a live-key precondition isn't met yet (beta access, self-identity verification, or account balance). |
| 404 | Unknown session ID, or a session belonging to a different org — the two are indistinguishable on purpose. |
| 409 | Cancelling a session that already resolved. |
| 429 | Rate limit exceeded — see Rate limits. |
| 500 | Unexpected server error. Safe to retry. |
Enforced per organization, per rolling hour:
| Endpoint | Limit |
|---|---|
| Create a session | 60 / hour |
| Retrieve a session | 240 / hour |
| Cancel a session | 60 / hour |
Register an endpoint from your dashboard — Settings → Webhooks — and LiveVerify will POST a signed JSON event to it when a session you created resolves.
A webhook fires the first time LiveVerify observes a session's outcome — in practice, the first time your server calls GET /verification_sessions/:id for it (or immediately, for a cancellation). A session your server never retrieves — someone who starts a check and never comes back, and whose result you never fetch — currently won't fire a webhook at all. Polling or checking on return_url is still the reliable way to learn a result; webhooks save you from polling sessions you've already seen resolve.
| Event | Fires when |
|---|---|
| identity.verification_session.verified | A session passes. |
| identity.verification_session.failed | A session fails. |
| identity.verification_session.canceled | A session is cancelled via the cancel endpoint. |
verified and failed events:
test_mode is true on every event for a session created with a sandbox key, so your receiver can ignore sandbox outcomes in production. Settings → Identity webhooks has a Send test event button that delivers a signed sample to one endpoint, with test_mode: true and an id starting test_.
canceled events carry a smaller payload — no verified_name, failure_code, or document_type_hint — but still carry reason, since that's set at creation, not at resolution:
Every request carries an X-SignedBy-Identity-Signature header: sha256=<hex-encoded HMAC-SHA256 of the raw request body>, signed with your endpoint's own secret (shown in the dashboard next to it, prefixed whsec_id_). Verify it against the exact raw bytes you received, before parsing:
Each endpoint is tried independently, so one endpoint being down never affects delivery to your others. A delivery attempt times out after 5 seconds; on a timeout or a non-2xx response, it's retried exactly once, 1 second later. There's no delivery log or further retry beyond that — treat webhooks as a convenience on top of retrieving sessions directly, not a guaranteed-delivery queue.
LiveVerify is also reachable as a Model Context Protocol server — one URL an MCP-speaking agent (Claude, Le Chat, or any future one) can connect to directly, with no API key to generate or paste in:
Paste this URL into the agent's connector settings — no API key to generate or copy in first:
https://api.liveverify.me/api/mcpIt registers itself automatically (no manual client setup), then redirects you to LiveVerify to sign in and approve a consent screen — same account, same organization as your dashboard. Approving grants that agent an OAuth connection you can see and revoke anytime from Settings → Connected apps, right alongside your Identity API keys.
An agent connection can only ever create and check verification sessions for your organization's own customers — exactly the same purpose: "api" access an Identity API key already has (see Create a session). It can never verify or change your own organization's account, and it can never see your Identity API keys or webhook secrets.
The consent screen grants the same two scopes your Identity API keys already use — no separate vocabulary to learn:
| Scope | Grants |
|---|---|
| identity:write | Create and cancel verification sessions. |
| identity:basic | Check a session's status. |
| Tool | Scope | Mirrors |
|---|---|---|
| create_verification_session | identity:write | Create a session |
| get_verification_session | identity:basic | Retrieve a session |
| cancel_verification_session | identity:write | Cancel a session |
Each tool applies the exact same preconditions as its REST counterpart (beta allowlist, your own organization's identity verification, account balance, monthly limit) — an agent connection never bypasses any of them.
Set reason on agent-triggered sessions. An agent decides on its own when to ask for a verification, often with no human on your side reviewing the request first — the person being asked deserves to know why. See Create a session.
Every session an agent creates is clearly marked as such — never mixed in indistinguishably with your own API or dashboard sessions. In your Sessions list it carries an AGENT chip naming the connected app (its OAuth-registered name, or the raw client identity for an API-key-authenticated MCP call), independent of the existing TEST chip.
Beyond the scopes above, you can set a daily cap on how many verification sessions any connected agent may create, on top of your overall monthly limit — from Settings → Connected apps. Leave it blank for no extra limit. This bound is checked before a session is ever created, the same way every other precondition above is.
Have questions or need a higher monthly limit? Reach out from your dashboard, or to the email on your account.