The checktiv.idv.submitted SDK event means the applicant finished capture. It does not carry a verdict. The authoritative outcome arrives on your server as a signed kyc.session.* webhook.
The webhook signature check is your only server-side trust anchor. The client is distrusted. Never grant access based on an SDK event alone; always wait for the webhook and verify its signature.
Why the SDK event is not the verdict
Section titled “Why the SDK event is not the verdict”- The capture frame runs in a sandboxed iframe. Your page cannot inspect its contents, and the SDK cannot inspect the server’s decision.
- The verification decision is made server-side after capture completes and may involve additional checks that run asynchronously.
- The SDK event channel is browser-to-browser postMessage, which can be replayed or spoofed by malicious browser extensions or page scripts. The webhook is server-to-server and authenticated by HMAC.
Webhook events
Section titled “Webhook events”You subscribe to event types in the console. The delivered body does not carry the event type as a field: the event class is determined by the subscription that matched, and the verdict is read from the status and outcome fields in the payload. The real event types are:
| Event type | Meaning |
|---|---|
kyc.session.completed |
The verification reached a completed terminal state with an outcome. |
kyc.session.cancelled |
The verification was cancelled by an operator. |
kyc.session.manual_review_requested |
The verification was parked for human review. |
kyc.session.expired |
The verification link expired before the applicant finished. |
kyc.session.skipped_by_applicant |
The applicant skipped a step, ending the verification. |
The canonical event list, payload shape, and signature algorithm live at Create a webhook endpoint and Verify webhook signatures. This page covers only what is specific to the IDV SDK flow. Do not key your receiver on an event.type field in the body: there is no such field.
The payload body
Section titled “The payload body”Every kyc.session.* delivery carries a compact, flat JSON body with reference fields only, no applicant personal data:
{ "sessionId": "vs_01J9X2Y3Z4A5B6C7D8E9F0G1H2", "status": "completed", "outcome": "approved", "occurredAt": "2026-06-16T12:34:56.000Z"}sessionId- thevs_*verification id. Use it to fetch full detail from the REST API.status- the terminal session status at emit time (for examplecompleted,cancelled,expired).outcome- the assigned verification outcome (approved,declined, orreview), ornullfor a transition that carried no verdict (for example an expired or applicant-skipped session).occurredAt- UTC ISO-8601 timestamp of the transition.
Reading the verdict from status and outcome
Section titled “Reading the verdict from status and outcome”Branch on status first, then on outcome when the session completed:
status |
outcome |
What it means |
|---|---|---|
completed |
approved |
The verification passed. Grant access. |
completed |
declined |
The verification failed. Do not grant access. |
completed |
review |
A verdict is pending human review. Hold, do not grant. |
cancelled |
null |
An operator cancelled the verification. |
expired |
null |
The link expired before the applicant finished. |
Treat any unknown status or outcome value defensively: log it and take no irreversible action, so a new value can ship without breaking your receiver.
Signature scheme
Section titled “Signature scheme”Every delivery carries an X-Webhook-Signature header:
X-Webhook-Signature: t=1717000000,v1=abc123def456...t- UNIX epoch seconds at signing time.v1- lower-case hex ofHMAC-SHA-256(secret, "${t}.${rawBody}")whererawBodyis the exact bytes of the request body.
The full specification, receiver examples, rotation procedure, and troubleshooting guide live at Verify webhook signatures. This page covers only what is specific to the IDV SDK flow.
Implementing the receiver
Section titled “Implementing the receiver”Key requirements:
- Read the raw body before parsing JSON. Computing HMAC on re-serialized JSON produces a different hash.
- Use a constant-time compare (
crypto.timingSafeEqual,hmac.compare_digest,subtle.ConstantTimeCompare). A naive===is a timing oracle. - Reject signatures where
|now - t| > 300seconds (the platform enforces the same window symmetrically). - Deduplicate on
X-Webhook-Id. The same event may be delivered more than once (at-least-once delivery); a fresh delivery of the same event carries the sameX-Webhook-Idbut a newX-Webhook-Delivery-Id. - This route is server-to-server and authenticated by HMAC - do not apply CSRF protection to it.
Node.js (Web API / fetch-style):
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyChecktivSignature(sigHeader, rawBody, secret) { // Parse "t=...,v1=..." const parts = Object.fromEntries(sigHeader.split(',').map((p) => p.split('='))); if (!parts.t || !parts.v1) throw new Error('malformed_header');
const age = Math.abs(Date.now() / 1000 - Number(parts.t)); if (age > 300) throw new Error('expired_timestamp');
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const expectedBuf = Buffer.from(expected, 'utf8'); const receivedBuf = Buffer.from(parts.v1, 'utf8');
if (expectedBuf.length !== receivedBuf.length) throw new Error('signature_mismatch'); if (!timingSafeEqual(expectedBuf, receivedBuf)) throw new Error('signature_mismatch');}
// POST /webhooks/checktivexport async function POST(req) { const rawBody = await req.text(); // IMPORTANT: raw bytes, before JSON.parse const sigHeader = req.headers.get('x-webhook-signature') ?? '';
verifyChecktivSignature(sigHeader, rawBody, process.env.CHECKTIV_WEBHOOK_SECRET); // If the above throws, reject the request.
// The body is the flat payload { sessionId, status, outcome, occurredAt }. const payload = JSON.parse(rawBody); const webhookId = req.headers.get('x-webhook-id');
// Deduplicate using webhookId + your idempotency store if (await alreadyProcessed(webhookId)) { return new Response(null, { status: 200 }); }
// Branch on status, then outcome. There is no `event.type` in the body. if (payload.status === 'completed') { switch (payload.outcome) { case 'approved': await approveApplicant(payload.sessionId); break; case 'declined': await declineApplicant(payload.sessionId); break; case 'review': await queueForReview(payload.sessionId); break; } } // `cancelled` / `expired` carry a null outcome; handle them if you need to.
await markProcessed(webhookId); return new Response(null, { status: 200 });}Python:
import hashlib, hmac, timefrom flask import request, abort
def verify_checktiv_signature(sig_header: str, raw_body: bytes, secret: str) -> None: parts = dict(p.split("=", 1) for p in sig_header.split(",")) if "t" not in parts or "v1" not in parts: abort(400, "malformed_header")
if abs(time.time() - int(parts["t"])) > 300: abort(400, "expired_timestamp")
expected = hmac.new( secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256 ).hexdigest()
if not hmac.compare_digest(expected, parts["v1"]): abort(400, "signature_mismatch")
@app.post("/webhooks/checktiv")def checktiv_webhook(): raw_body = request.get_data() # raw bytes before any parsing verify_checktiv_signature( request.headers.get("x-webhook-signature", ""), raw_body, os.environ["CHECKTIV_WEBHOOK_SECRET"], ) payload = request.get_json() # Branch on payload["status"] / payload["outcome"]; there is no event type field. return "", 200Registering your endpoint
Section titled “Registering your endpoint”- Open the console: Developers -> Webhooks -> New endpoint.
- Enter your HTTPS URL and select the
kyc.session.*events. - Copy the signing secret immediately - it is shown once.
- Deploy your receiver before enabling the endpoint in production.
Reconcile with a poll fallback
Section titled “Reconcile with a poll fallback”The webhook is the primary signal: build your integration around it. A small number of sessions can land in review through an internal error or a payment-blocked path that does not emit a terminal webhook. If you are expecting a verdict for a session that has not arrived, reconcile by fetching the session directly: GET /v1/sessions/{id} with your secret key returns the current status and outcome for the vs_* id. Poll it on a bounded backoff for any session whose webhook is overdue, and read the verdict from the same status + outcome fields. Do not treat the absence of a webhook as a pass.
Secret rotation
Section titled “Secret rotation”The signing secret can be rotated under Developers -> Webhooks -> [endpoint] -> Rotate secret. The old secret remains valid for seven days after rotation, so you have time to deploy your updated receiver. See Verify webhook signatures for the full rotation procedure.
Troubleshooting
Section titled “Troubleshooting”signature_mismatch: The most common cause is JSON re-serialization before HMAC. Compute the HMAC on the raw request body string, then parse.expired_timestamp: Your server clock is more than five minutes off. Run NTP.- Deliveries not arriving: Check Replay and troubleshoot webhook deliveries.
Related pages
Section titled “Related pages”- Create a webhook endpoint - canonical reference for event types and payload body
- Verify webhook signatures - canonical reference with full receiver examples and rotation
- Replay and troubleshoot webhook deliveries - diagnose missed or failed deliveries
- Quickstart - see how the webhook fits into the full integration