Skip to content

Verdict and webhooks

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.

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

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.

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 - the vs_* verification id. Use it to fetch full detail from the REST API.
  • status - the terminal session status at emit time (for example completed, cancelled, expired).
  • outcome - the assigned verification outcome (approved, declined, or review), or null for 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.

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 of HMAC-SHA-256(secret, "${t}.${rawBody}") where rawBody is 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.

Key requirements:

  1. Read the raw body before parsing JSON. Computing HMAC on re-serialized JSON produces a different hash.
  2. Use a constant-time compare (crypto.timingSafeEqual, hmac.compare_digest, subtle.ConstantTimeCompare). A naive === is a timing oracle.
  3. Reject signatures where |now - t| > 300 seconds (the platform enforces the same window symmetrically).
  4. 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 same X-Webhook-Id but a new X-Webhook-Delivery-Id.
  5. 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/checktiv
export 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, time
from 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 "", 200
  1. Open the console: Developers -> Webhooks -> New endpoint.
  2. Enter your HTTPS URL and select the kyc.session.* events.
  3. Copy the signing secret immediately - it is shown once.
  4. Deploy your receiver before enabling the endpoint in production.

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.

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.

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