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 a reviewer. Carries review with the codes for why, and can arrive more than once.
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 JSON body with reference fields only, no applicant personal data. The park event also carries a review object of closed codes, described below. 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.
  • review - on kyc.session.manual_review_requested only: why this park needs a reviewer, as closed codes. review.causes lists each session-level cause code once. review.checks lists each check that has reasons, with its type, its outcome and its reason codes, in the same order as check_results on the session resource. Every code is listed at Review reasons.

mode, type, decisionSource and review 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 four as optional in your receiver.

review is optional and stays optional. It is absent on a park event emitted before this release, and on a park event whose snapshot could not be read, because the park never waits on it. It carries codes only: no sentence, no rule name, no count and no personal data. It is a snapshot of that park, taken when the verification was parked, so fetch the verification for its current state and for the sentence that goes with each code. Webhook subscriptions cover your whole organization, and so does the snapshot: a read of the same verification scoped to one of your sub-accounts (tenant_ref) can leave out a cause the webhook carries.

{
"sessionId": "vs_01J9X2Y3Z4A5B6C7D8E9F0G1H2",
"status": "awaiting_review",
"outcome": "review",
"occurredAt": "2026-06-16T12:34:56.000Z",
"mode": "test",
"type": "kyc.session.manual_review_requested",
"review": {
"causes": ["check_needs_review", "rule_triggered"],
"checks": [
{ "type": "watchlist", "outcome": "review", "reasons": ["record_match_needs_confirmation"] },
{ "type": "id_verification", "outcome": "review", "reasons": ["expiry_date_passed"] }
]
}
}

A verification can be parked more than once, for example when a reviewer asks the applicant to capture their document again. Every park sends its own kyc.session.manual_review_requested event, with its own X-Webhook-Id and its own X-Webhook-Delivery-Id, so deduplicating on X-Webhook-Id keeps both.

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:

status outcome What it means
completed approved The verification passed. Grant access.
completed declined The verification failed. Do not grant access.
awaiting_review review The verification is parked for a reviewer. Hold, do not grant. review says why.
cancelled null An operator cancelled the verification.
expired null The link expired before the applicant finished.

review is not a final verdict. When your reviewer decides the verification, a kyc.session.completed event follows with approved or declined. If it lapses while it waits, a kyc.session.expired event follows instead.

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.

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.
  • Only the park event carries per-check detail, and only as codes. When a credit check sends the verification to review, kyc.session.manual_review_requested lists that check in review.checks with its reason codes, for example credit_score_below_minimum. No other event carries per-check detail. The credit summary itself, the file status and the counts, is published on the session read only, which the next section covers. Branch on status and outcome, exactly as the table above describes.

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. A check that needs a person, was only partly performed or could not produce a result also carries reasons, and a credit_history entry also carries a credit summary. The credit summary is published here only: 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",
"outcome": "review",
"checks": ["credit_history"],
"check_results": [
{
"type": "credit_history",
"outcome": "review",
"reasons": [
{
"code": "credit_score_below_minimum",
"category": "needs_judgment",
"message": "The score is below the minimum this check requires."
}
],
"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.

A check that was re-verified appears once per run in both checks and check_results, latest run first. For a re-verified ID verification step only the latest run carries reasons; an earlier run of any other check type keeps its own. The order of those runs was not defined before this release.

outcome on an entry 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. The verdict is the outcome field on the session itself. The terminal webhook carries it too, except kyc.session.expired, which always reports null.

For a credit check, every code in credit.review_reasons also appears in reasons, with a category and a message, so one array covers every check type. See Review reasons for every code.

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. Two parks of one verification are two events with two different X-Webhook-Id values, so deduplicating keeps both.
  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 carries sessionId, status, outcome and occurredAt, plus the optional
// fields described above. The park event can also carry `review`.
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;
}
} else if (payload.status === 'awaiting_review') {
// Parked for a reviewer: hold, do not grant. `review` is optional and lists
// the codes for why. A verification can be parked more than once, and each
// park is a new event, so queueing must accept one that is already queued.
await queueForReview(payload.sessionId, payload.review);
}
// `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. If you are expecting a verdict for a verification and its webhook has not arrived, fetch the verification directly. GET /v1/sessions/{id} with your secret key returns the current status for the vs_* id, the verdict in outcome, and check_results with each check’s own outcome and reasons once the workflow has run any. Poll it on a bounded backoff for any verification whose webhook is overdue. Do not treat the absence of a webhook as a pass.

The session resource carries both the lifecycle and the verdict. status says where the verification is: awaiting_review means it is parked for a person, and completed, expired and cancelled are terminal. outcome is the verdict, approved, declined or review, and null until one exists. Once the outcome is approved or declined, decided_at says when the decision was recorded and decision_source says what produced it. decision_source can be absent on a decided verification, for example when the applicant refused to sign, so an absent decision_source does not mean no decision was made: read outcome for that. When the verification was sent to review, review.causes says why. See Review reasons.

To find verifications you hold no id for, list them. GET /v1/sessions?status=awaiting_review returns every verification that is waiting for a reviewer. A small number of verifications land in review after an internal processing error, and those send no webhook, so this list is the only way to find them: their review.causes contains processing_error. A verification held at payment_required sends no webhook either. One held before a paid check resumes on its own once funds are added or the wallet is unfrozen. One held when it reached its document-signing step, before the document was prepared, does not resume: fix the wallet first (add funds, re-subscribe if it was frozen after a cancellation, or contact support to release a locked wallet), then start a new verification for the applicant. See Why is my account in payment_required? for that case and Track verification status for both filters.

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.