KYC Verification
Verify customer identities with document, liveness, and face match checks. Server-to-server, enterprise-only, with signed webhooks and a hosted applicant flow.
What is KYC Verification?
KYC Verification runs real identity checks on your applicants — a government ID document scan, a liveness check, and a face match between the selfie and the document photo — and returns one clear, auditable decision. It is a server-to-server API: your backend creates a verification, hands the applicant a hosted flow to complete on their device, and you receive the outcome by webhook or polling.
- One clear decision — every verification resolves to
approved,declined, orreview, with machine-readable reason codes. - Custom flows — choose exactly which checks each applicant runs, in the dashboard, and reference them by
flowId. - Fraud signals — non-blocking
riskFlagssurface potential fraud so you can apply your own risk rules. - Built for compliance — every applicant grants explicit, recorded consent, and documents and selfies are purged after their retention window.
Authentication
All KYC API calls use a KYC API key (prefix nextcap_kk_) in the X-Api-Key header. Mint one under Settings → API keys → KYC keys. When you create a key you pick the flow it runs, the same way a widget key loads one widget, so you never pass a flow id at request time. You are shown the key and a signing secret exactly once, the secret is what verifies your webhooks.
Origin are rejected. Never embed a KYC key in front-end code.Step 1 — Create a verification
From your backend, create a verification when a user reaches the point where you need to verify them. The response includes a short-lived verifyUrl you send the applicant to.
curl -X POST https://api.nextcap.ai/api/v1/external/kyc/verifications \-H "X-Api-Key: nextcap_kk_your_key" \-H "Content-Type: application/json" \-d '{"applicantEmail": "user@example.com","metadata": { "orderId": "A-1234" }}'
{"data": {"id": "vrf_9a2b7c1d4e5f","status": "pending","clientToken": "a1b2c3…","verifyUrl": "https://verify.nextcap.ai/v/vrf_9a2b7c1d4e5f?token=a1b2c3…","expiresAt": "2026-06-25T10:30:00.000Z"}}
applicantEmailstringOptional. Applicant email, used for the flow and dashboard display.
localestringOptional. BCP-47 locale for the hosted flow, e.g. en, fr, ar.
returnUrlstringOptional. Where to send the applicant after they finish the hosted flow.
metadataobjectOptional. Arbitrary key/values echoed back to you, e.g. your own order or user id.
flowId to send. To run a different flow, mint a second key bound to it and swap the key. (Legacy keys with no bound flow fall back to your org's default flow and still accept a flowId in the body.)Step 2 — Hand off the applicant
Redirect the applicant's browser (or open in a new tab / in-app webview) to the verifyUrl. It is of the form /v/<id>?token=<clientToken> and walks them through each step on their own device, where the camera lives. The link expires with the session, so create it close to when the user will use it.
clientToken authenticates the applicant's browser to that one session only. It is safe to put in the URL you hand the user; your secret key never leaves your server.Step 3 — Receive the result
Verifications complete asynchronously. Learn the outcome two ways — use webhooks for push, and polling as a fallback or reconciliation.
Option A — Webhooks (recommended)
Register an endpoint with the key whose secret should sign deliveries. You can also manage endpoints under Dashboard → Identity (KYC) → Webhooks.
curl -X POST https://api.nextcap.ai/api/v1/external/kyc/webhooks \-H "X-Api-Key: nextcap_kk_your_key" \-H "Content-Type: application/json" \-d '{"url": "https://crm.example.com/hooks/kyc","events": ["verification.completed", "fraud.flagged"]}'
verification.completedeventFires on every terminal outcome (always).
verification.approvedeventThe applicant passed.
verification.rejectedeventThe applicant was declined.
verification.manual_revieweventLanded in manual review (a person should decide).
fraud.flaggedeventOne or more risk flags were raised.
Each delivery is a signed JSON envelope:
{"event": "verification.completed","timestamp": "2026-06-25T10:00:00.000Z","id": "whd_3f1c…","data": {"verificationId": "vrf_9a2b7c1d4e5f","status": "approved","decision": "approved","declineType": null,"reasonCodes": [],"riskFlags": [],"mode": "live","createdAt": "2026-06-25T09:58:00.000Z","completedAt": "2026-06-25T10:00:00.000Z"}}
X-Nextcap-SignaturestringHMAC-SHA256 of the raw request body, hex-encoded, using your key's secret.
X-Nextcap-EventstringThe event name.
X-Nextcap-DeliverystringA stable delivery id (whd_…); dedupe on it if you process asynchronously.
Verify the signature
Recompute the HMAC over the raw request body and compare in constant time. Reject anything that does not match.
const crypto = require("crypto");// The secret shown once when you minted your KYC API key.const SECRET = process.env.NEXTCAP_KYC_SECRET;function verify(rawBody, signatureHeader) {const expected = 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);}// Express: mount express.raw({ type: "application/json" }) so req.body is the// EXACT bytes we signed.app.post("/hooks/kyc", (req, res) => {if (!verify(req.body, req.header("X-Nextcap-Signature"))) {return res.status(401).end();}const event = JSON.parse(req.body.toString("utf8"));// event.data.verificationId, event.data.decision …res.status(200).end();});
2xx within ~8 seconds to acknowledge; anything else counts as a failure.Option B — Polling
Read a verification's current status on demand. Returns the same data shape as the webhook. While still pending, decision is null.
curl https://api.nextcap.ai/api/v1/external/kyc/verifications/vrf_9a2b7c1d4e5f \-H "X-Api-Key: nextcap_kk_your_key"
{"data": {"verificationId": "vrf_9a2b7c1d4e5f","status": "approved","decision": "approved","declineType": null,"reasonCodes": [],"riskFlags": [],"mode": "live","createdAt": "2026-06-25T09:58:00.000Z","completedAt": "2026-06-25T10:00:00.000Z"}}
The decision
approved— all required checks passed.declined— a required check failed.declineTypeisretry(the applicant can try again) orfinal.review— inconclusive; a human should decide.reasonCodesexplains why.
reasonCodes are machine-friendly labels (for example BAD_FACE_MATCHING, EXPIRATION_DATE). riskFlags are non-blocking fraud signals you can act on in your own risk engine.