Developers
Three calls from sign-up to a signed decision.
Plain HTTPS and JSON, an x-api-key header, one error envelope, and a webhook you can verify in ten lines. No SDK to install, nothing to keep in sync.
- calls from sign-up to a signed decision
- 3
- public endpoints, one error envelope
- 12
- persisted webhook attempts, then redeliver
- 8
- SDKs to install or keep in sync
- 0
Step 01
Create a session
Your backend posts to /v1/sessions with an API key and gets back the hosted link and a session id.
POST /v1/sessions
curl -X POST https://kycverify.me/api/v1/sessions \
-H "x-api-key: $KYC_API_KEY" \
-H "content-type: application/json" \
-d '{
"vendor_data": "user-42",
"contact": { "email": "anna@example.com" }
}'201 Created
{
"session_id": "ses_0k3v9x2m4a7qhd8f1rtb",
"status": "not_started",
"url": "https://kycverify.me/verify/q3Xf…",
"session_token": "q3Xf…",
"workflow_id": "wf_0k3t1c8n5e2wpzr6g4ya",
"vendor_data": "user-42",
"expires_at": "2026-10-10T09:12:44Z"
}Step 02
Verify the webhook
Each status change arrives as session.status_updated, signed with HMAC-SHA256 over the timestamp and the raw body. Ten lines in any language.
x-kyc-signature
import { createHmac, timingSafeEqual } from "node:crypto";
// x-kyc-signature: v1=<hex(HMAC-SHA256(secret, "{timestamp}.{raw_body}"))>
export function verifyKycWebhook(rawBody, headers, secret) {
const ts = headers["x-kyc-timestamp"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${ts}.`)
.update(rawBody) // the raw Buffer, before JSON.parse
.digest("hex");
const given = String(headers["x-kyc-signature"] ?? "").replace(/^v1=/, "");
return given.length === expected.length &&
timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}Step 03
Use the decision
The webhook carries the decision when the session is final; fetch it any time with GET /v1/sessions/{id}/decision.
GET /v1/sessions/{id}/decision
curl https://kycverify.me/api/v1/sessions/ses_0k3v9x2m4a7qhd8f1rtb/decision \
-H "x-api-key: $KYC_API_KEY"200 OK
{
"session_id": "ses_0k3v9x2m4a7qhd8f1rtb",
"status": "approved",
"decision_reason": null,
"workflow_id": "wf_0k3t1c8n5e2wpzr6g4ya",
"vendor_data": "user-42",
"metadata": {},
"identity": {
"full_name": "ANNA MARIA ERIKSSON",
"date_of_birth": "1974-08-12",
"nationality": "UTO",
"document_type": "passport",
"document_number_masked": "L8989****",
"expiry_date": "2032-04-15",
"sources": { "full_name": "mrz", "date_of_birth": "mrz" }
},
"checks": [
{ "kind": "document", "status": "passed", "score": 0.97 },
{ "kind": "liveness", "status": "passed", "score": 0.91 },
{ "kind": "face_match", "status": "passed", "score": 0.612 },
{ "kind": "aml", "status": "passed", "score": 0 }
],
"review": null,
"created_at": "2026-10-03T09:02:11Z",
"submitted_at": "2026-10-03T09:11:58Z",
"completed_at": "2026-10-03T09:12:44Z",
"expires_at": "2026-10-10T09:02:11Z"
}The whole loop
What happens between your two calls.
The person completes the hosted flow; the engine runs the background checks; your endpoint hears about every transition.
Your backend
KYCVerify API
Hosted flow
Your webhook
- 1POST /v1/sessionsYour backend to KYCVerify API: x-api-key; returns url, session_id
- 2Send the urlYour backend to Hosted flow: redirect, email or QR code
- 3Consent, document, livenessHosted flow to KYCVerify API: /flow/{token}/… per enabled step
- 4POST /flow/{token}/submitHosted flow to KYCVerify API: status becomes processing
- 5Background checksKYCVerify API to KYCVerify API: face match, AML, age, duplicate, IP
- 6session.status_updatedKYCVerify API to Your webhook: HMAC-SHA256 signed, decision included
- 7Return to callback_urlKYCVerify API to Hosted flow: ?session_id=…&status=…
- 8GET /v1/sessions/{id}/decisionYour backend to KYCVerify API: any time, optional
- 1Your backend → KYCVerify APIPOST /v1/sessionsx-api-key; returns url, session_id
- 2Your backend → Hosted flowSend the urlredirect, email or QR code
- 3Hosted flow → KYCVerify APIConsent, document, liveness/flow/{token}/… per enabled step
- 4Hosted flow → KYCVerify APIPOST /flow/{token}/submitstatus becomes processing
- 5KYCVerify APIBackground checksface match, AML, age, duplicate, IP
- 6KYCVerify API → Your webhooksession.status_updatedHMAC-SHA256 signed, decision included
- 7KYCVerify API → Hosted flowReturn to callback_url?session_id=…&status=…
- 8Your backend → KYCVerify APIGET /v1/sessions/{id}/decisionany time, optional
The API surface
Small enough to read in one sitting.
Every path, header, field and limit on this site comes from the same contract the code is built against.
Sessions
x-api-keyYour backend creates, follows, decides and erases verifications.
- POST
/v1/sessionsCreate a session - GET
/v1/sessionsList sessions - GET
/v1/sessions/{id}Get a session - GET
/v1/sessions/{id}/decisionGet the decision - PATCH
/v1/sessions/{id}/statusDecide a session in review - DELETE
/v1/sessions/{id}Purge a session - GET
/v1/workflowsList workflows
Standalone checks
x-api-keyOne check, no hosted flow, nothing stored.
- POST
/v1/checks/amlScreen a name - POST
/v1/checks/panValidate a PAN - POST
/v1/checks/mrzParse an MRZ - POST
/v1/checks/face-matchCompare two faces - POST
/v1/checks/aadhaar/secure-qrVerify an Aadhaar Secure QR
Hosted flow
link tokenCalled by the verification page, scoped to one session's token.
- GET
/flow/{token}Flow state - POST
/flow/{token}/documentDocument capture - POST
/flow/{token}/aadhaar/secure-qrAadhaar Secure QR - POST
/flow/{token}/livenessLiveness frames - POST
/flow/{token}/submitSubmit for decision
Webhooks
Signed, retried, redeliverable.
Deliveries live in the database, so a restart loses nothing. Each attempt is signed when it is sent, with a fresh timestamp.
- Any
2xxwithin 10 seconds counts as delivered; redirects are never followed. - Private, loopback and link-local addresses are refused, and the connection is pinned to the vetted address.
delivery_idmakes your handler idempotent; the console redelivers any event.- Test vector:
whsec_test,1700000000,{"a":1}→v1=3887…3789.
Attempt 1
0
on the status change
Attempt 2
+30 s
30 s after the last
Attempt 3
+2 m 30 s
2 min after the last
Attempt 4
+12 m 30 s
10 min after the last
Attempt 5
+42 m 30 s
30 min after the last
Attempt 6
+1 h 42 m
1 h after the last
Attempt 7
+4 h 42 m
3 h after the last
Attempt 8
+10 h 42 m
6 h after the last
Any 2xx within 10 s
delivery succeeded; no more attempts
Anything else
non-2xx, timeout, refused: next attempt is scheduled
After attempt 8 fails
marked failed; redeliver from the console
Errors
One envelope, eight codes.
Every error from every endpoint is { error: { code, message } }. Branch on code; show message.
400
bad_requestA field failed validation. The message names it.
401
unauthorizedMissing, unknown or revoked key.
403
forbiddenYour console role may not do this.
404
not_foundNot in your app. Never leaks other apps' ids.
409
conflictWrong state, e.g. deciding a session not in review.
422
unprocessableUnderstood but refused, e.g. no face in the image.
429
rate_limitedSlow down; the window is one minute.
500
internalLogged on our side, safe to retry.
The service
One base URL: kycverify.me/api.
Your backend calls the hosted API with a key, people verify at kycverify.me/verify/{token}, and decisions come back to you as signed webhooks. Uploads are encrypted at rest and purged on your retention period.
First call
# your backend's environment
KYC_API_BASE=https://kycverify.me/api
KYC_API_KEY=kyc_test_… # Console → Developers → API keys, shown once
KYC_WEBHOOK_SECRET=whsec_… # Console → Webhooks → Rotate secret, shown once
# then
curl -X POST "$KYC_API_BASE/v1/sessions" \
-H "x-api-key: $KYC_API_KEY" -H "content-type: application/json" \
-d '{"vendor_data": "user-42"}'End user's browser
hosted flow at kycverify.me/verify/{token}
Console users
reviewers, admins, owners
Your backend
x-api-key calls to kycverify.me/api/v1/…
KYCVerify · kycverify.me
HTTPS front
terminates TLS; /api → Rust API, everything else → Next.js
Next.js app
console, hosted flow; proxies /api/* to the API
kyc-api
axum; engine, webhook, AML and retention workers
SQLite
data/kyc.db
Uploads
AES-256-GCM, data/files
Models
YuNet, SFace, ocrs
UIDAI certs
certs/uidai
Only outbound calls
Email codes
from no-reply@kycverify.me, if the email step is on
Your webhook URL
signed; private addresses refused
OFAC and UN list URLs
public sanctions files; checked every 6 h, re-downloaded when older than 7 days
Conventions
Boring in the right places.
Test and live keys
kyc_test_andkyc_live_keys belong to separate apps with separate data.One error shape
{ error: { code, message } }with eight documented codes.Idempotent receivers
Every webhook carries a
delivery_idand can be redelivered from the console.Evidence in the payload
Decisions include identity, sources per field and every check with its warnings.
MRZ check digits, explained
How the 7-3-1 algorithm in ICAO Doc 9303 works, worked through on the specimen passport, and what it can and cannot prove.
DocumentsAadhaar Secure QR and Offline e-KYC, explained
What the two UIDAI-signed offline artefacts contain, how their signatures are verified, and how to handle them without storing Aadhaar numbers.
IndiaActive versus passive liveness
What challenge-response liveness checks, what passive and certified PAD add, and how to choose honestly for your risk.
BiometricsFace match: embeddings, similarity and thresholds
How a selfie is compared with a document portrait, what the similarity score means, and how to set the pass and review thresholds.
BiometricsSanctions screening: lists, fuzzy names and false positives
Where the OFAC and UN lists come from, how names are normalised and scored, why a hit never declines a session on its own, and what screening does not cover.
RiskVerifying webhook signatures
Why KYCVerify signs a timestamp with the body, how to verify it correctly in Node.js, Python, Go, Rust and PHP, and the mistakes that break it.
Developers
Get a test key in a minute.
Sign up, create a key under Developers, and run the quickstart against the sandbox.

