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

- 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

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](/developers/webhooks) and [Verify webhook signatures](/developers/webhooks-verify-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

Every `kyc.session.*` delivery carries a compact, flat JSON body with reference fields only, no applicant personal data:

```json
{
  "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

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

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](/developers/webhooks-verify-signatures). This page covers only what is specific to the IDV SDK flow.

## Implementing the receiver

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):**

```js
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:**

```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
```

## Registering your endpoint

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.

## 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

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](/developers/webhooks-verify-signatures) for the full rotation procedure.

## 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](/developers/webhooks-replay-and-troubleshoot).

## Related pages

- [Create a webhook endpoint](/developers/webhooks) - canonical reference for event types and payload body
- [Verify webhook signatures](/developers/webhooks-verify-signatures) - canonical reference with full receiver examples and rotation
- [Replay and troubleshoot webhook deliveries](/developers/webhooks-replay-and-troubleshoot) - diagnose missed or failed deliveries
- [Quickstart](/developers/sdks/quickstart) - see how the webhook fits into the full integration