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 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. Events emitted after this release carry the matching value in the body’s type field; events emitted before this release, or replayed from one that was, do not. Either way, the verdict is read from the status and outcome fields in the payload - that contract does not change. 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. Reserved: accepted on a subscription, no deliveries produced yet.
kyc.session.form_flagged A custom form step triggered a flag-for-review rule. Sent mid-journey, not terminal.
kyc.session.document_signed An e-signature step was signed and the sealed document is ready to verify.
kyc.session.document_declined An applicant declined to sign an e-signature step. A normal outcome, not an error.

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. Handle an unknown or absent type value defensively - log it and take no irreversible action - rather than requiring it to be present.

Most kyc.session.* deliveries carry a compact, flat JSON body with reference fields only, no applicant personal data. The two e-signature events carry document identifiers instead; their bodies are documented at Create a webhook endpoint.

{
"sessionId": "vs_01J9X2Y3Z4A5B6C7D8E9F0G1H2",
"status": "completed",
"outcome": "approved",
"occurredAt": "2026-06-16T12:34:56.000Z",
"mode": "test",
"type": "kyc.session.completed",
"decisionSource": "automatic"
}
  • 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.
  • mode - live or test, the data plane the event was produced on. Present only on events emitted after this release.
  • type - the event type, one of the kyc.session.* types listed above. Present only on events emitted after this release.
  • decisionSource - what produced the decision: automatic when our verification rules assigned the outcome with no human review, or reviewer when a person recorded it. Present only on events that carry a decision, and only on events emitted after this release.

mode, type and decisionSource are additive fields, not a new envelope. Retry and replay resend the exact body that was stored at emit time, so an event emitted before a field existed, or replayed from one that was, keeps its original shape permanently - there is no backfill. Treat all three as optional in your receiver.

decisionSource is absent whenever the transition carried no decision at all, for example a cancelled or expired session or a session parked for review, and on any event emitted before it existed. It is also absent in one case that IS a completed decision: the applicant ended the session themselves, which arrives as status: "completed" with outcome: "declined" and no decisionSource. So an absent decisionSource does not mean no decision was made. Read outcome for the verdict and decisionSource only for who or what produced it. Do not read an absent value as reviewer, and do not infer one from outcome. The field never carries a reviewer’s name, email or id, and never says why the outcome was assigned.

You do not have to infer any of that from a missing key. Every delivery of a registered event type also carries an X-Webhook-Event-Version header holding the payload contract version for that event type, which is 1 for all kyc.session.* events today. Payloads are additive within a version and a breaking change ships as a new version, so this header is what your receiver branches on to pick a parser.

Adding mode and type was additive within version 1, so the header does not by itself tell you whether a given body carries them. What it does tell you is which contract the delivery belongs to, and that resolves the three cases:

Header mode in body What it means
1 present An event emitted after this release. Read mode and type directly.
1 absent An event emitted before this release, or a replay of one. Handle it exactly as you did before.
absent absent Not a registered event type. Today that is only the console test ping, which also sends X-Webhook-Origin: test_ping.

X-Webhook-Event-Version is advisory metadata and is not covered by the signature. The signature is computed over the request body alone, so anyone who can reach your endpoint can send any value in this header. Verify the signature first, and never make a trust or authorization decision based on this header.

Do not treat an absent mode as live: a receiver that defaults it to live would process a synthetic session-completed event as though it were a real identity verification.

mode reports which data plane produced the event, not whether the event is a real verification. A console test ping sent on a live subscription carries mode: "live" and test: true in the body. Recognize a test ping by its X-Webhook-Origin: test_ping header (see Verify webhook signatures), not by mode.

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.

decisionSource is a separate question from the verdict and does not change how you branch above. A completed / approved session grants access whether a person or our rules assigned it.

A credit-history step puts no result on the webhook

Section titled “A credit-history step puts no result on the webhook”

checktiv.credit_history.submitted means the applicant finished the credit step, exactly as the IDV event above means capture finished. It carries no verdict and it never will: the applicant’s answers go to Checktiv under the hosted frame’s own credential and are never echoed to your page.

Two consequences worth designing for:

  • The webhook usually arrives later than it does for a submission-terminal check. A credit report resolves after the applicant has finished, so a workflow that ends on a credit step commonly shows the applicant the completion screen well before kyc.session.completed reaches your server. Do not treat the gap as a failure and do not poll for it: wait for the delivery.
  • The body carries no per-check detail, for credit or for anything else. Branch on status and outcome, exactly as the table above describes. A credit-specific result is not part of the webhook contract, so a receiver that reaches into the body for one finds nothing regardless of how the check resolved. The result is published on the session read instead, which the next section covers.

A declined authorization is not a decline verdict. When the applicant does not authorize the report, the step is never submitted, so the verification does not complete on its own: it stays resumable and the applicant can return and authorize. Your receiver sees no kyc.session.completed for it until something resolves the step, and may see kyc.session.expired instead if the link runs out first. See Credit history for that state and the two others the module treats as normal.

GET /v1/sessions/{id} carries a check_results array once the workflow has run any checks: one entry per check, in the same order as checks on the same response, so check_results[i] describes checks[i]. Each entry carries that check’s own outcome, and a credit_history entry also carries a credit summary. This is where a credit result is published to an integration. It is not on the webhook body, not on the create response, and not on the list endpoint.

{
"data": {
"id": "vs_01H...",
"status": "awaiting_review",
"checks": ["credit_history"],
"check_results": [
{
"type": "credit_history",
"outcome": "review",
"credit": {
"file_status": "hit",
"review_reasons": ["credit_score_below_minimum"],
"tradeline_count": 12,
"open_tradeline_count": 7,
"closed_tradeline_count": 5,
"collection_count": 1,
"legal_item_count": 0,
"inquiry_count": 3,
"bankruptcy_present": false
}
}
]
}
}

check_results is additive and optional. It is omitted entirely until the workflow has run a check, which is the same verification for which checks still reflects the list you asked for at create time, so read it defensively rather than assuming it is there. checks itself is unchanged, so an integration that reads only that field sees exactly what it saw before this array existed.

outcome is what that one check concluded: pass, fail, review, error, or null while it has not concluded. It is not the verdict for the verification, which still arrives only on the terminal webhook.

The credit object is present only on a credit_history entry that has produced a result, and it carries these nine fields and nothing else:

Field What it carries
file_status What happened when the file was requested: hit, no_hit, locked, frozen, or undetermined. Read every field below through it, and see the section on a check that never resolved for the one value that reports nothing.
review_reasons Why the check needs a human when nothing itemized on the file explains it: bureau_flagged_for_review (the file itself came back flagged for review), no_credit_file_returned, or credit_score_below_minimum. Empty means nothing beyond the contents of the file routed the check.
tradeline_count How many credit accounts are on the file. Always open_tradeline_count plus closed_tradeline_count. null when nothing was reported.
open_tradeline_count How many of those were reported with no closed date. null when nothing was reported.
closed_tradeline_count How many of those were reported with a closed date. null when nothing was reported.
collection_count How many debts were referred to collections. A count, never an amount. null when nothing was reported.
legal_item_count How many judgments, liens and other legal items are on the file. A count, never a court or party name. null when nothing was reported.
inquiry_count How many inquiries have been made against the file. An inquiry records that someone looked, so it is context rather than an adverse signal. null when nothing was reported.
bankruptcy_present Whether the file carries any bankruptcy filing at all. null when nothing was reported, which is never the same as false.

Build against what is there, not against what might arrive later. None of the following is on this response, and none of it is coming:

  • The credit score, and anything derived from it. No score, no score band, and no score threshold. Your reviewers see the score in the console, beside the minimum your template set. It is on no other surface: it is not on the report PDF, and it is not here.
  • The minimum score your own template sets. credit_score_below_minimum in review_reasons names that outcome, and you authored the number, so the response does not hand it back to you.
  • The per-item warning flags, and every itemized record on the file. Those are findings, and findings are a reviewer surface rather than an API one.
  • The date the file was produced, the credit utilization percentage, and the on-time payment percentage.
  • Every identifying value: creditor, agency, court, plaintiff and inquirer names, account numbers, balances, and every other amount. These are not filtered out of this response. They are not in the data it reads at all, because they stay inside the encrypted report.

Switch exhaustively on outcome if you want to. pass, fail, review and error are the only values that can be stored against a check, and that is enforced by the database rather than by convention.

Do not do the same with type. It is a plain string on the wire, and nothing pins the stored value to the check types this version of the API describes, so a switch that assumes otherwise is built on a guarantee that does not exist. Treat an unrecognized type as a check type you do not know about yet, log it, and keep a default branch. Handle review_reasons the same way: the list is closed today, and a value you do not recognize should fall back to the entry’s outcome rather than render as a blank.

A check that never resolved still gets an entry

Section titled “A check that never resolved still gets an entry”

If the report was never obtained, the entry carries outcome: "error" with file_status: "undetermined", review_reasons empty, and every count and bankruptcy_present set to null:

{
"type": "credit_history",
"outcome": "error",
"credit": {
"file_status": "undetermined",
"review_reasons": [],
"tradeline_count": null,
"open_tradeline_count": null,
"closed_tradeline_count": null,
"collection_count": null,
"legal_item_count": null,
"inquiry_count": null,
"bankruptcy_present": null
}
}

undetermined is the one file status that describes our own path rather than the applicant’s file: no file was read, so there is nothing to count. null there means unknown. It is not a zero and it is not a false, and the distinction is the whole reason those fields are nullable: publishing bankruptcy_present: false for a report nobody obtained would state something about a person that nothing supports. no_hit is a different fact, and so is a hit with no accounts on it.

Treat null on any of these fields as “not reported” wherever it appears, rather than testing them for truthiness. if (credit.bankruptcy_present) and if (credit.collection_count > 0) both read a null the same way they read a genuine clean file, which is the one mistake this shape exists to make visible.

file_status What it means
hit A file was matched for this person.
no_hit No file matched this person. Not a restricted file, and not proof they have no credit history.
locked / frozen A file exists and the person has restricted access to it. A choice they made.
undetermined The check never resolved, so nothing is known about the file either way.

Read the counts only when file_status is hit. On no_hit, locked or frozen a 0 means no file was read, not that the person has no accounts, and reading it the other way states something the check never established. On undetermined there is no count to read at all: every one of them is null.

A verification whose credit_history check never resolved goes to a human rather than being decided automatically: a check that did not finish has not passed, and reporting an adverse credit outcome for it would be a claim about the applicant that nothing supports.

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. Apply the window symmetrically so clock drift in either direction does not break verification. The platform signs the delivery and does not verify it, so this freshness check exists only in your receiver: skip it and a captured delivery can be replayed against your endpoint indefinitely.
  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. `type` may be absent on older or replayed events.
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"]; payload["type"] may be absent on older or replayed events.
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 for the vs_* id, plus check_results with each check’s own outcome once the workflow has run any. Poll it on a bounded backoff for any session whose webhook is overdue. Do not treat the absence of a webhook as a pass.

The session resource carries the lifecycle, not the verdict. There is no outcome field on it and there never has been: approved, declined or review for the verification as a whole is delivered on the terminal webhook and nowhere else. So read status to learn whether the verification finished (awaiting_review means it is parked for a person; completed, expired and cancelled are terminal), and read check_results to learn what each check concluded. When a terminal status says the verification finished and no verdict ever reached you, the delivery is what to chase: open the subscription in the console, find the delivery, and replay it. See Replay and troubleshoot webhook deliveries.

The signing secret can be rotated under Developers -> Webhooks -> [endpoint] -> Rotate secret. Rotation is immediate and single-secret: from the moment you rotate, every delivery is signed with the new secret only, including a retry or a replay of an event emitted before the rotation. There is no platform-side grace period, so your receiver must be able to pick up a second secret from configuration without a code deploy. 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.