Integration
API reference
Integration

API reference

A small REST API for creating verification sessions and reading their results. JSON in, JSON out.

Base URL and authentication#

https://lkp-verifier.onrender.com/v1

Authenticate with a secret key from the console as a bearer token. Keys belong to one app; sessions created with a key are visible only to that app. You can have up to 10 active keys per app and revoke any of them instantly.

Like Stripe, LKP separates Sandbox mode and Live mode using distinct key prefixes:

  • lkp_sk_test_...: Test keys create sandbox sessions (livemode: false). Sandbox verifications are free, unmetered, and provide a 1-click simulator to test instant pass or fail outcomes without hardware passkeys or real documents.
  • lkp_sk_live_...: Live keys create production sessions (livemode: true) requiring authentic WebAuthn passkey unlocking and Groth16 zero-knowledge proof generation.
curl https://lkp-verifier.onrender.com/v1/verification-sessions \
  -H "Authorization: Bearer lkp_sk_test_..."
Secret keys grant full access to your app's sessions. Use them only from your server.

Create a session#

POST/v1/verification-sessions
requirementsRequirement[]required
Between 1 and 40 requirements, all of which the user must prove. See below.
kycWorkflow"either" | "age_estimation" | "full_id"optional
Defaults to either. Preferred issuance flow when the user does not yet hold an age or identity credential. age_estimation initiates a fast 1 to 2 second selfie age check (wholesale first check rate, sub-cent reuse). full_id requires a passport or national ID card (wholesale first check rate, sub-cent reuse). either gives the user the choice between selfie or ID card.
ageEstimationFallbackToIdbooleanoptional
Defaults to false. When set to true, if a user fails the facial age estimation check for appearing too young, they are offered the option to verify their legal age with an official passport or national ID card instead.
mode"ANONYMOUS_LOGIN" | "ONE_TIME_ACTION"optional
Defaults to ANONYMOUS_LOGIN. In ONE_TIME_ACTION mode each person can complete a given actionId once.
actionIdstringoptional
Scope for one-time actions, e.g. poll-2026-q4. Up to 128 characters of letters, digits and . : @ / - _. Defaults to login or default.
redirectUrlstringoptional
Where to send the user afterwards. Must match one of the app's allowed redirect URLs (origin and path).
referencestringoptional
Your own identifier (up to 128 characters), returned on the session and in webhooks.

Requirements

{ kind: "AGE_OVER", minAge }1 to 150
The holder is at least this many years old today. Government ID.
{ kind: "COUNTRY_EQUALS", countryIsoNumeric }ISO 3166-1 numeric, or an array of up to 16
The nationality (citizenship) printed on the document, not current physical location. Pass several countries to accept any of them (France or the UK). Government ID.
{ kind: "AGE_AT_MOST", maxAge }0 to 150
The holder is at most this many years old today. Combine with a minimum age for a range. Government ID.
{ kind: "SEX_EQUALS", sex }"F" | "M" | "X", or an array of up to 16
The sex printed on the document. Government ID.
{ kind: "DOCUMENT_VALID_FOR", minDays }0 to 3650
The identity document stays valid for at least this many more days. Government ID.
{ kind: "DOCUMENT_TYPE_EQUALS", documentType }"passport" | "id_card" | "residence_permit" | "drivers_license", or an array of up to 16
The kind of document the holder verified with. Government ID.
{ kind: "ISSUING_COUNTRY_EQUALS", countryIsoNumeric }ISO 3166-1 numeric, or an array of up to 16
The country that issued the document. Government ID.
{ kind: "FULL_NAME_EQUALS", fullName }string, up to 100 characters, or an array of up to 16
The holder's given names and surname as printed on the document. Case and accents are ignored. Government ID.
{ kind: "DOCUMENT_NUMBER_EQUALS", documentNumber }string, up to 100 characters, or an array of up to 16
The number of the document the holder verified with. Government ID.
{ kind: "RESIDENCE_COUNTRY_EQUALS", countryIsoNumeric }ISO 3166-1 numeric, or an array of up to 16
The country of the address printed on the document, when it has one. This is not proof that the holder currently lives there. Government ID.
{ kind: "PHONE_VERIFIED", lineType }"any" | "not_virtual" | "mobile"
The holder owns a phone number. Each number backs only one wallet. Phone number.
{ kind: "PHONE_COUNTRY_EQUALS", countryIsoNumeric }ISO 3166-1 numeric, or an array of up to 16
The country the phone number belongs to. Phone number.
{ kind: "PHONE_NUMBER_EQUALS", phoneNumber }E.164 string, e.g. +31612345678, or an array of up to 16
The holder owns exactly this number, in international format. Phone number.
{ kind: "EMAIL_VERIFIED", type }"any" | "work_or_school" | "school"
The holder owns an email address of this kind. Work means an organisation's own domain, not a free mailbox. Work or school email.
{ kind: "EMAIL_DOMAIN_EQUALS", domain }domain string, e.g. company.com, or an array of up to 16
The address belongs to this organisation. Subdomains count: student.uva.nl matches uva.nl. Work or school email.
{ kind: "EMAIL_ADDRESS_EQUALS", email }email address string, or an array of up to 16
The holder owns exactly this email address. Work or school email.
{ kind: "FOLLOWERS_AT_LEAST", minFollowers }integer ≥ 0
The holder's Instagram account has at least this many followers. Instagram.
{ kind: "INSTAGRAM_USERNAME_EQUALS", username }string, up to 64 characters, or an array of up to 16
The holder owns this Instagram account. Instagram.
{ kind: "INSTAGRAM_POSTS_AT_LEAST", minPosts }integer ≥ 0
The Instagram account has published at least this many posts. Instagram.
{ kind: "INSTAGRAM_VERIFIED" }no value
The Instagram account has Instagram's verified badge. Instagram.
{ kind: "X_FOLLOWERS_AT_LEAST", minFollowers }integer ≥ 0
The holder's X account has at least this many followers. X.
{ kind: "X_USERNAME_EQUALS", username }string, up to 64 characters, or an array of up to 16
The holder owns this X account. X.
{ kind: "X_ACCOUNT_AGE_AT_LEAST", minDays }0 to 36500
The X account was created at least this many days ago. X.
{ kind: "X_VERIFIED" }no value
The X account is verified. X.
{ kind: "X_POSTS_AT_LEAST", minPosts }integer ≥ 0
The X account has published at least this many posts. X.
{ kind: "TIKTOK_FOLLOWERS_AT_LEAST", minFollowers }integer ≥ 0
The holder's TikTok account has at least this many followers. TikTok.
{ kind: "TIKTOK_USERNAME_EQUALS", username }string, up to 64 characters, or an array of up to 16
The holder owns this TikTok account. TikTok.
{ kind: "TIKTOK_LIKES_AT_LEAST", minLikes }integer ≥ 0
The TikTok account's videos have at least this many likes in total. TikTok.
{ kind: "TIKTOK_VERIFIED" }no value
The TikTok account is verified. TikTok.
{ kind: "TIKTOK_VIDEOS_AT_LEAST", minVideos }integer ≥ 0
The TikTok account has published at least this many videos. TikTok.
{ kind: "GITHUB_FOLLOWERS_AT_LEAST", minFollowers }integer ≥ 0
The holder's GitHub account has at least this many followers. GitHub.
{ kind: "GITHUB_USERNAME_EQUALS", username }string, up to 64 characters, or an array of up to 16
The holder owns this GitHub account. GitHub.
{ kind: "GITHUB_ACCOUNT_AGE_AT_LEAST", minDays }0 to 36500
The GitHub account was created at least this many days ago. GitHub.
{ kind: "GITHUB_TWO_FACTOR" }no value
The GitHub account has two-factor authentication turned on. GitHub.
{ kind: "GITHUB_REPOS_AT_LEAST", minRepos }integer ≥ 0
The GitHub account owns at least this many public repositories. GitHub.
{ kind: "GITHUB_CONTRIBUTIONS_AT_LEAST", minContributions }integer ≥ 0
The GitHub account made at least this many contributions in the last year. GitHub.
{ kind: "GITHUB_ORG_MEMBER", organisation }string, up to 100 characters, or an array of up to 16
The holder is a member of this GitHub organisation. GitHub.
{ kind: "BALANCE_AT_LEAST", minBalanceCents }integer ≥ 0
The holder's account balance is at least this amount, in euros. Bank account.
{ kind: "BANK_ACCOUNT_EQUALS", iban }string, up to 100 characters, or an array of up to 16
The holder controls exactly this bank account. Bank account.
{ kind: "BANK_HOLDER_EQUALS", holderName }string, up to 100 characters, or an array of up to 16
The name the bank has on the account. Case and accents are ignored. Bank account.

Requirements of the same kind are merged: two AGE_OVER entries keep the stricter one. Exact-match lists are any-of: { "kind": "COUNTRY_EQUALS", "countryIsoNumeric": [250, 826] } accepts France or the UK, and the result does not say which. See Verification types for every option, or list them with GET /v1/verification-types. The response is 201 Created with the session object, in status pending.

Retrieve a session#

GET/v1/verification-sessions/:id

Returns the session object. Sessions that belong to another app return 404.

List sessions#

GET/v1/verification-sessions
limit1 to 100optional
Defaults to 20. Newest first.
status"pending" | "verified" | "failed" | "expired"optional
Only sessions in this status.
{ "object": "list", "data": [ { "id": "vs_…", "object": "verification_session", … } ] }

The session object#

{
  "id": "vs_3f9c2a7d0e5b41c8a6f2d9e1b7c4a053",
  "object": "verification_session",
  "livemode": true,
  "appId": "app_8e1f0c2b9d4a7e63",
  "status": "verified",
  "mode": "ANONYMOUS_LOGIN",
  "actionId": "login",
  "kycWorkflow": "age_estimation",
  "claims": ["Age >= 18 AND Country == 528"],
  "requirements": [
    { "kind": "AGE_OVER", "minAge": 18 },
    { "kind": "COUNTRY_EQUALS", "countryIsoNumeric": 528 }
  ],
  "reference": "order_1042",
  "redirectUrl": null,
  "url": "https://lkp-three.vercel.app/verify/vs_3f9c2a7d0e5b41c8a6f2d9e1b7c4a053",
  "result": {
    "nullifier": "14898262410530296126834957014925436093817722310495102368117423604186552044231",
    "verifiedClaims": ["Age >= 18 AND Country == 528"],
    "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ik…"
  },
  "failureReason": null,
  "attempts": 1,
  "verificationMs": 41,
  "createdAt": "2026-11-06T15:00:00.000Z",
  "expiresAt": "2026-11-06T15:30:00.000Z",
  "completedAt": "2026-11-06T15:00:19.000Z"
}
livemodebooleanrequired
false while LKP runs in test mode, where identity checks use sandbox data. Treat such results as test data, never as proof about a real person.
statusstringrequired
pending (waiting for the user), verified, failed, or expired (still pending 30 minutes after creation).
kycWorkflow"either" | "age_estimation" | "full_id"required
The issuance workflow configured for this session.
requirementsRequirement[]required
The exact requirements evaluated for this session.
claimsstring[]required
Human-readable summary of what will be proven, one entry per credential type.
resultobject | nullrequired
Set when verified. nullifier is a decimal string: the anonymous user ID, stable per person and app. idToken is a signed JWT (see ID tokens).
failureReasonstring | nullrequired
Why the last attempt failed, e.g. the action was already completed.
attemptsnumberrequired
Proofs submitted on this session, including the successful one.
verificationMsnumber | nullrequired
Server-side proof verification time.

Funnel and verification stats#

Track conversion and verification performance across your integration. Analytics endpoints aggregate metrics on the server with zero personal data, zero holder identifiers, and zero IP addresses.

GET/api/business/apps/:appId/funnel
daysnumberoptional
Number of days to inspect (1 to 90, defaults to 30).

Returns aggregate counts for each step of the user journey:

{
  "totals": {
    "gate_shown": 1420,
    "wallet_created_or_unlocked": 1380,
    "age_check_passed": 1290,
    "reuse_proof": 1150,
    "estimation_vs_id_path": 140
  },
  "daily": [
    { "date": "2026-11-06", "gate_shown": 95, "wallet_created_or_unlocked": 92, "age_check_passed": 88, "reuse_proof": 80, "estimation_vs_id_path": 8 }
  ]
}

Errors#

Errors use standard HTTP status codes and a JSON body:

{ "error": "Bad Request", "message": "requirements.0.minAge: Number must be greater than or equal to 1" }
  • 400: invalid body, conflicting requirements, or a redirect URL that isn't allowed.
  • 401: missing, malformed or revoked API key.
  • 404: no such session for this app.
  • 410: the session is no longer pending (seen by the hosted page, not by your server).
  • 429: rate limited. Back off and retry.

Rate limits#

The /v1 endpoints allow 120 requests per minute per client IP. The hosted page has its own, separate limits. If you need more, contact us.