◆ Identity API reference

LiveVerify Identity API

Create a hosted identity verification, redirect your user to it, and read back the result — three endpoints, one credential, no SDK required.

◆ Try it

Run a sandbox verification

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.

live demo · sandboxDemo mode

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.

Simulate a result
what your server sees—
Session created
Document captured
Selfie & liveness
Decision
// Start the demo to see the API response
// POST /api/v1/verification_sessions

Overview

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.

Authentication

Every request carries an Identity API key, either as a bearer token or as an x-api-key header:

Authorization: Bearer lvfy_live_xxxxxxxxxxxxxxxxxxxxxxxx # or, equivalently x-api-key: lvfy_live_xxxxxxxxxxxxxxxxxxxxxxxx

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.

Scopes

A key is issued with one or both of these scopes:

ScopeGrants
identity:writeCreate and cancel verification sessions.
identity:basicRead 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.

Sandbox mode

Every org has two keys available: a live key (lvfy_live_...) and a sandbox key (lvfy_test_...). Sessions created with a sandbox key:

  • never count against your monthly verification limit
  • skip the "verify your own org's identity first" requirement
  • skip the account-balance requirement
  • resolve to whatever outcome you ask for, instead of running a real document/selfie check

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.

Create a session

POST/api/v1/verification_sessionsidentity:write

Every field is optional.

FieldTypeNotes
return_urlstringWhere to send the person back once their check resolves. See return_url rules.
localestringPins the capture page's language. See Locale.
reasonstringPlain-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_ageintegerMinimum 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_onlybooleantrue 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_minutesintegerHow 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_namestringFull 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_outcomestringSandbox keys only — see Sandbox mode.

Request

curl https://api.liveverify.me/api/v1/verification_sessions \ -X POST \ -H "Authorization: Bearer lvfy_test_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "return_url": "https://yourapp.com/kyc/callback", "locale": "en", "reason": "Opening a trading account", "expected_name": "Jan de Vries" }'

Response — 201 Created

{ "id": "b6e2a1d4-2f3a-4e5e-9c8a-6a1c2d3e4f5a", "status": "pending", "url": "https://app.liveverify.me/?session=b6e2a1d4-2f3a-4e5e-9c8a-6a1c2d3e4f5a", "return_url": "https://yourapp.com/kyc/callback", "reason": "Opening a trading account", "expires_at": "2026-09-27T10:30:00.000Z", "expected_name_set": true, "customer_message": { "language": "en", "subject": "Acme: please verify your identity", "body": "Hi, Acme has asked you to verify your identity for Opening a trading account. ..." }, "test_mode": true }

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.

Preconditions

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.

Accepted documents

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.

DocumentAcceptedHow it's checked
PassportAlwaysMachine-readable zone (MRZ) with check digits; optional chip read (in testing).
National ID card, residence cardAlwaysMRZ with check digits, front and back; optional chip read (in testing).
US and Canadian driving licenceAlwaysPDF417 barcode on the back.
EU and UK driving licenceYour 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.

Where EU and UK licences are accepted

SettingAccepted 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 nameEvery session.
OffNever: 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.

Retrieve a session

GET/api/v1/verification_sessions/:ididentity:basic

Call this once your user is back on your return_url to find out what happened.

Response — 200 OK

{ "id": "b6e2a1d4-2f3a-4e5e-9c8a-6a1c2d3e4f5a", "status": "passed", "test_mode": false, "verified_name": "Maren Holst", "failed_attempts": 0, "failure_code": null, "document_type": "passport", "document_type_hint": null, "reason": "Opening a trading account" }
FieldNotes
statuspending · passed · failed · cancelled
verified_nameSet 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_codeSet 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_typeOn 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_hintCoarse document category detected during a failed attempt, when recognizable (same values as document_type). Never OCR text.
reasonWhatever you passed at creation, echoed back — null if you didn't set one. Never changes after creation.
minimum_age / age_checkThe 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_onlytrue 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_tokenOn 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.
chipIn 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_atISO 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_matchOnly 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.

Cancel a session

POST/api/v1/verification_sessions/:id/cancelidentity:write

Cancels a still-open session — for example, if your own flow no longer needs it.

Response — 200 OK

{ "id": "b6e2a1d4-2f3a-4e5e-9c8a-6a1c2d3e4f5a", "status": "cancelled" }

Returns 409 if the session already resolved (passed, failed, or was already cancelled) — cancellation only ever applies to a still-pending session.

Age tokens

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.

{ "iss": "https://liveverify.me", "aud": "<your organisation id>", "sid": "b6e2a1d4-2f3a-4e5e-9c8a-6a1c2d3e4f5a", "age_over": 18, "verified_at": 1790776800, "checks": ["document", "liveness", "face_match"], "test": false, "iat": 1790777000, "nbf": 1790777000, "exp": 1790777600, "jti": "…" }

Checking a token offline

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.

import { jwtVerify, createRemoteJWKSet } from "jose"; const jwks = createRemoteJWKSet(new URL("https://liveverify.me/.well-known/jwks.json")); const { payload } = await jwtVerify(ageToken, jwks, { issuer: "https://liveverify.me", audience: YOUR_ORG_ID, typ: "lv-age+jwt", }); if (payload.test) throw new Error("sandbox token"); // payload.age_over >= 18

Or ask us

POST/api/v1/age_tokens/verify

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.

{ "valid": true, "age_over": 18, "verified_at": "2026-09-30T12:00:00.000Z", "expires_at": "2026-09-30T12:13:20.000Z", "audience": "<your organisation id>", "session_id": "b6e2a1d4-2f3a-4e5e-9c8a-6a1c2d3e4f5a", "checks": ["document", "liveness", "face_match"], "test": false, "key_id": "Q2l3yH0l1k1b0vKc" }

return_url rules

  • Must be an absolute URL using https — no http, no relative paths.
  • Must not contain embedded credentials (no user:pass@host).
  • If your org has registered at least one domain (dashboard — Settings → Registered domains), return_url's host must be one of them, or a subdomain of one. Registering example.com also covers app.example.com.
  • If you haven't registered any domains yet, any valid HTTPS URL is accepted — registering a domain is what opts you into the restriction.

Embed the modal

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:

<script src="https://api.liveverify.me/verify/snippet.js"></script> <script> LiveVerify.open({ session: "b3f2b6a0-...", // the id from your POST above onComplete: function (result) { /* result.status: "passed" | "failed" -- the modal stays open on the result screen until the person closes it */ }, onClose: function () { /* dismissed with no result -- X button, Esc, or declined consent */ }, }); </script>

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.

Locale

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.

en · nl · es · fr · de · it · hu · cs · pl · ro · da · sv · fi · bg · nb · sk · sl

Errors

Every error response is a single JSON object:

{ "error": "This key does not have the 'identity:write' scope." }
StatusMeaning
400Bad request — an invalid return_url, or an invalid/misused test_outcome.
401Missing, malformed, invalid, or revoked API key.
402Your org has reached its monthly verification limit.
403Valid key, but missing the required scope — or a live-key precondition isn't met yet (beta access, self-identity verification, or account balance).
404Unknown session ID, or a session belonging to a different org — the two are indistinguishable on purpose.
409Cancelling a session that already resolved.
429Rate limit exceeded — see Rate limits.
500Unexpected server error. Safe to retry.

Rate limits

Enforced per organization, per rolling hour:

EndpointLimit
Create a session60 / hour
Retrieve a session240 / hour
Cancel a session60 / hour

Webhooks

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 types

EventFires when
identity.verification_session.verifiedA session passes.
identity.verification_session.failedA session fails.
identity.verification_session.canceledA session is cancelled via the cancel endpoint.

Payload

verified and failed events:

{ "event": "identity.verification_session.verified", "occurred_at": "2026-09-23T14:02:11.483Z", "id": "b6e2a1d4-2f3a-4e5e-9c8a-6a1c2d3e4f5a", "status": "passed", "verified_name": "Maren Holst", "failed_attempts": 0, "failure_code": null, "document_type": "passport", "document_type_hint": null, "reason": "Opening a trading account", "test_mode": false }

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:

{ "event": "identity.verification_session.canceled", "occurred_at": "2026-09-23T14:02:11.483Z", "id": "b6e2a1d4-2f3a-4e5e-9c8a-6a1c2d3e4f5a", "status": "cancelled", "failed_attempts": 0, "reason": "Opening a trading account", "test_mode": false }

Verifying the signature

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:

const crypto = require("crypto"); function isValidSignature(rawBody, signatureHeader, secret) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(signatureHeader || ""); return a.length === b.length && crypto.timingSafeEqual(a, b); }

Delivery

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.

Connect an AI agent (MCP)

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:

POST/api/mcp

Connecting

Paste this URL into the agent's connector settings — no API key to generate or copy in first:

https://api.liveverify.me/api/mcp

It 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.

Scopes

The consent screen grants the same two scopes your Identity API keys already use — no separate vocabulary to learn:

ScopeGrants
identity:writeCreate and cancel verification sessions.
identity:basicCheck a session's status.

Tools

ToolScopeMirrors
create_verification_sessionidentity:writeCreate a session
get_verification_sessionidentity:basicRetrieve a session
cancel_verification_sessionidentity:writeCancel 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.

Agent provenance

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.

Limiting agent volume

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.