> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.confirm.global/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.confirm.global/_mcp/server.

# Webhooks

Webhooks in the CONFIRM Partner API are **per applicant, not per endpoint**. You send a `webhook_url` when you create a candidate, and that applicant's lifecycle events are POSTed to that exact URL.

For applicants you send us, there is no endpoint to register, no endpoint ID to store, and no configuration to keep in sync between your environments. (Applicants CONFIRM finds on its own reach you through the optional [registry default endpoint](#the-registry-default-endpoint).) Append your own identifier to the URL and every delivery arrives already correlated to your record — you never need a CONFIRM ID to match an event.

> **Info**
>
> **Why per applicant?** Because it makes multi-environment routing free. Each developer sends their own tunnel subdomain, staging sends staging, production sends production, with no per-environment endpoint configuration to drift.

> **Note**
>
> **Availability:** everything on this page is live in both **sandbox** and **production**. Deliveries are only sent once your signing secret for that environment has been issued (see [Verifying signatures](#verifying-signatures)).

There is one case per-applicant URLs structurally cannot cover, and a [registry default endpoint](#the-registry-default-endpoint) exists for it.

## Sending a callback URL

Add `webhook_url` to any [candidate ingest](/partner-api/candidates/create-candidate) call:

```bash
curl https://api.confirm.global/partner/v1/candidates \
  -H "Authorization: Bearer cfk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "external_ref": "cl_app_84321",
    "location_ref": "cl_loc_88213",
    "first_name": "Maria",
    "last_name": "Alvarez",
    "phone": "+18135550172",
    "consent": { "contact": true },
    "webhook_url": "https://hooks.example.com/confirm/8f2c41aa-1d64-4f0b-9c33-2a7e5b104db8"
  }'
```

The trailing `8f2c41aa-…` is **yours** — a tracking ID you generated before the call. When a delivery lands on that path you already know which applicant it is about, without reading the body.

The response echoes back what we stored:

```json
{
  "candidate_id": "9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
  "application_id": "51c0a7de-2f18-4a3e-96b7-1d0c8b2e4a77",
  "external_ref": "cl_app_84321",
  "deduped": false,
  "screening": "enqueued",
  "portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
  "webhook_url": "https://hooks.example.com/confirm/8f2c41aa-1d64-4f0b-9c33-2a7e5b104db8"
}
```

### Rules for the URL

A URL that breaks a rule is refused with a `400` whose `error.code` says which rule. The same rules and codes apply to `webhook_url` on `POST /candidates` and to `url` on `PUT /organizations/{ref}/webhook`.

| Rule                            | Error code                   | Why                                                                                                                                                                                                                  |
| ------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **An absolute URL with a host** | `invalid_webhook_url`        | An empty value, a relative URL, or a URL with no host can't be delivered to.                                                                                                                                         |
| **HTTPS only**                  | `webhook_url_not_https`      | Payloads carry applicant PII.                                                                                                                                                                                        |
| **No credentials in the URL**   | `invalid_webhook_url`        | `https://user:pass@…` would end up in delivery logs.                                                                                                                                                                 |
| **A public host**               | `webhook_url_forbidden_host` | `localhost`, private and reserved IP ranges (RFC 1918, loopback, link-local), internal names (`*.internal`, `*.local`, `*.localhost`), cloud metadata endpoints, and names that resolve to any of these are refused. |
| **A host that exists in DNS**   | `webhook_url_unresolvable`   | A name that doesn't resolve (for example anything under `.invalid`) can never receive a delivery. Bring a developer tunnel up, so its name resolves, before you send its URL.                                        |
| **2048 characters max**         | `webhook_url_too_long`       | The longest URL we store.                                                                                                                                                                                            |

A value that isn't a string (or, on the `PUT`, a missing `url`) is the general `invalid_request`. `webhook_url: null` is not an error: it's treated as if you left the field out (see below).

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "webhook_url_not_https",
    "message": "webhook_url must use https. Payloads carry applicant PII and are never sent in the clear.",
    "request_id": "7aM4tQzK-2f8d-4b63-9c10-6e5a3d1b8f24"
  }
}
```

A URL that fails validation is rejected **before the applicant is created**, so you never end up with a half-wired applicant. Fix it and retry — retrying is always safe.

### Changing or omitting it

* **Omit `webhook_url`** on a new applicant and they emit no events at all. This is the default.
* **Re-send an applicant with a different `webhook_url`** and they are re-pointed, immediately. This is how you move an applicant from one developer's tunnel to another, or from staging to production.
* **Omit it on a re-send**, or send `webhook_url: null`, and the stored URL is left untouched — neither is read as "stop sending me events".

## The registry default endpoint

Some applicants never pass through `POST /candidates` at all — the ones **we** find for you on a job board or your career site. There is no request on which to send a `webhook_url`, so without a fallback those people would be invisible to you.

Set one URL per registry:

```bash
curl -X PUT https://api.confirm.global/partner/v1/organizations/cl_reg_4471/webhook \
  -H "Authorization: Bearer cfk_..." \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://hooks.example.com/confirm/registry/4471" }'
```

Resolution order is:

1. the applicant's own `webhook_url`, if they have one
2. the registry's default endpoint
3. nothing

**Setting a default never overrides an applicant's own URL.** Your per-developer and per-environment routing keeps working exactly as before; this only catches applicants that had nowhere to go.

`GET` the same path to read it, `DELETE` to stop fallback deliveries. Same URL rules as above, same error codes. There is exactly one endpoint per registry, so there is no endpoint ID for you to store.

* `DELETE` is idempotent: it returns `204` whether or not a default endpoint was set.
* `GET` returns `404 no_webhook_endpoint` when the registry has no default endpoint.
* On all three, a registry ref we don't know returns `404 unknown_organization_ref`.

## The event envelope

Every delivery is a JSON POST with the same envelope:

```json
{
  "id": "0f9d3c14-8a2b-4e77-b6c1-5d2e8a4f1093",
  "type": "candidate.status_changed",
  "created_at": "2026-07-28T14:03:22.481Z",
  "data": { }
}
```

* `id` — a UUID unique to this event. **Deduplicate on it**; delivery is at-least-once.
* `type` — the event type (see below).
* `created_at` — when the event occurred, ISO 8601.
* `data` — the event payload. Always carries both systems' identifiers.

Every `data` block includes the same identity fields, so no event ever requires a lookup to interpret:

| Field            | What it is                                           |
| ---------------- | ---------------------------------------------------- |
| `candidate_id`   | CONFIRM's applicant ID.                              |
| `application_id` | The application at that location.                    |
| `external_ref`   | **Your** applicant ID, as sent on ingest.            |
| `location_ref`   | **Your** location ID.                                |
| `org_ref`        | **Your** registry ID.                                |
| `portal_url`     | Deep link to the applicant in the registry's portal. |

### One status per location

Every event is about **one application**: one applicant at one location. An applicant you put at several locations (with `location_refs`, or with [`POST /candidates/{external_ref}/locations`](/partner-guides/multiple-locations)) has a separate application, and a separate status, at each one. They can be `rejected` at one location and in `interview` at another.

So key your records on `application_id`, or on `external_ref` plus `location_ref`, never on `external_ref` alone. Every event carries both `location_ref` and `application_id` for exactly this reason.

## Event types

v1 delivers six events: `candidate.created`, `candidate.status_changed`, `screening.completed`, `candidate.placed`, `candidate.location_added` and `candidate.location_changed`. The rest of the catalog is committed roadmap — write your handler to **ignore types it does not recognise** and you will not need to change it when they arrive.

| Event type                                            | When it fires                                                       | Status   |
| ----------------------------------------------------- | ------------------------------------------------------------------- | -------- |
| `candidate.created`                                   | CONFIRM sources an applicant you have not seen                      | **Live** |
| `candidate.status_changed`                            | The applicant's status at a location changes                        | **Live** |
| `screening.completed`                                 | An AI screening finishes and its analysis is ready                  | **Live** |
| `candidate.placed`                                    | The registry office chooses a location for the applicant in CONFIRM | **Live** |
| `candidate.location_added`                            | The applicant is added to another location                          | **Live** |
| `candidate.location_changed`                          | The applicant is moved from one location to another                 | **Live** |
| `interview.scheduled` / `.rescheduled` / `.cancelled` | Interview booking changes                                           | Roadmap  |
| `background_check.completed`                          | A background check finishes                                         | Roadmap  |

### `candidate.created`

Fires when **we** find someone for you — a job-board applicant, a career-site applicant — who you have never seen. It never fires for applicants you sent us: you already hold both IDs from the `POST /candidates` response, and echoing it back would only invite you to create a duplicate.

This is the ID exchange running in the other direction. `POST /candidates` gives us your ID and hands you ours; this gives you ours first.

```json
{
  "id": "b1c07a49-2f83-4d16-91ee-0c7d5a3e8b24",
  "type": "candidate.created",
  "created_at": "2026-07-30T15:41:09.220Z",
  "data": {
    "candidate_id": "7d2b91f0-4c68-4a55-b3e9-1f80c2d64a17",
    "application_id": "e04f7c3a-91d2-4b60-8a17-6c5e2f9b0d43",
    "external_ref": null,
    "location_ref": "cl_loc_88213",
    "org_ref": "cl_reg_4471",
    "portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/7d2b91f0-4c68-4a55-b3e9-1f80c2d64a17",
    "status": "screening",
    "first_name": "Devon",
    "last_name": "Marsh",
    "email": "devon.marsh@example.com",
    "phone": "+18135550199",
    "source": "indeed",
    "applied_at": "2026-07-30T15:41:08.744Z"
  }
}
```

`external_ref` is `null` — you have no ID for this person yet. That is the one thing to do with this event:

> **Note**
>
> **Completing the pairing.** Create your own record, then POST the applicant to `/candidates` with your `external_ref` and the same phone number. Ingest matches them to the existing candidate and binds your ID to it — you get `deduped: true` and the same `candidate_id` back. No duplicate person, no duplicate application, and from then on every event about them carries your `external_ref` like any other.

`source` is where *we* found them (`indeed`, `public_apply`, …) — useful when deciding whether to claim someone.

### `candidate.status_changed`

```json
{
  "id": "0f9d3c14-8a2b-4e77-b6c1-5d2e8a4f1093",
  "type": "candidate.status_changed",
  "created_at": "2026-07-28T14:03:22.481Z",
  "data": {
    "candidate_id": "9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
    "application_id": "51c0a7de-2f18-4a3e-96b7-1d0c8b2e4a77",
    "external_ref": "cl_app_84321",
    "location_ref": "cl_loc_88213",
    "org_ref": "cl_reg_4471",
    "portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
    "status": "review",
    "previous_status": "screening",
    "occurred_at": "2026-07-28T14:03:22.402Z"
  }
}
```

#### The status vocabulary

`status` is one of **five** values, and only these five:

| Status      | Means                                                                                                           |
| ----------- | --------------------------------------------------------------------------------------------------------------- |
| `screening` | With CONFIRM. Applied, being contacted, or mid-AI-screening. No verdict yet.                                    |
| `review`    | With the registry. A person is deciding — before an interview, or after one while the hire decision is pending. |
| `interview` | On the interview track: passed review, being scheduled, booked, or rescheduling.                                |
| `hired`     | Hired.                                                                                                          |
| `rejected`  | No longer progressing, for any reason.                                                                          |

> **Warning**
>
> **Do not expect finer granularity, and do not parse around it.** CONFIRM's internal pipeline has dozens of stages, they differ per registry, and they change as the product changes. This five-value enum is the stable contract precisely so that our internal changes cannot break your integration. If you need to know *why* an applicant was rejected, or exactly where in a multi-round pipeline they are, open the `portal_url`.

Two consequences worth designing around:

* **Not every internal move produces an event.** Movements that do not change the coarse status are suppressed — you will not get five `review` events in a row as an applicant walks through a registry's internal review steps.
* **`rejected` is terminal for your purposes, but not always for ours.** A registry can reconsider an applicant; if they do, you will receive a fresh event moving them back.
* **Your own outcomes come back to you.** When you set an outcome with `PATCH /candidates/{external_ref}?location_ref=…` (`withdrawn`, `not_hired`, `hired`) and it changes the status, this event fires for that location like any other status change. Expect the echo of your own change and deduplicate it.
* **A move is not a rejection.** When an applicant is moved to another location, the location they left sends no `rejected`; you get one `candidate.location_changed` (below) instead.

### `screening.completed`

Fires when the AI screening call has completed **and its analysis has finished** — so the result is populated rather than pending.

```json
{
  "id": "c4a71e88-0b93-4a1d-9e2f-73c1d6b0a5f2",
  "type": "screening.completed",
  "created_at": "2026-07-28T14:11:49.106Z",
  "data": {
    "candidate_id": "9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
    "application_id": "51c0a7de-2f18-4a3e-96b7-1d0c8b2e4a77",
    "external_ref": "cl_app_84321",
    "location_ref": "cl_loc_88213",
    "org_ref": "cl_reg_4471",
    "portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
    "status": "review",
    "result": "passed",
    "tier": "strong",
    "completed_at": "2026-07-28T14:11:47.882Z",
    "transcript_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16?tab=calls",
    "recording_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16?tab=calls"
  }
}
```

| Field                             | Values                                                                                                                         |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `result`                          | `passed` · `failed` · `incomplete` (the applicant engaged but the screen did not reach a verdict)                              |
| `tier`                            | `strong` · `moderate` · `limited` · `pending` · `ineligible` — CONFIRM's fit assessment, blending résumé and screening signals |
| `transcript_url`, `recording_url` | **Links, not content.** Both point at the applicant's Calls tab, where the transcript and the recording player live.           |

With one screening call covering several locations, each location gets its own `screening.completed`, with its own `result` and `tier`. Knockout questions count per location: failing one location's knockout question does not fail the others.

A location added after the applicant's call gets its own `screening.completed` too: after its [follow-up call](/partner-guides/multiple-locations#follow-up-calls-for-locations-added-later), or straight away when the earlier answers already settle it with no call. In that last case `completed_at` is the time of the call the answers came from.

> **Note**
>
> **We never put transcripts or audio in a webhook.** A screening transcript is a person's words in a recorded call. It stays behind the portal's authentication, where an authorised registry user reads it — the webhook tells you it exists and where to find it.

### `candidate.placed`

Fires when the registry office chooses this location for the applicant in CONFIRM. Placement is a decision, not a status: it does not change `status`, and it does **not** close the applicant's other locations, which can still hire the same person later. It fires once per application; placing the same application again sends nothing new.

```json
{
  "id": "5e8a2c71-3b94-4f06-a1d8-9c7e0b4f2a63",
  "type": "candidate.placed",
  "created_at": "2026-10-02T16:20:11.538Z",
  "data": {
    "candidate_id": "9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
    "application_id": "51c0a7de-2f18-4a3e-96b7-1d0c8b2e4a77",
    "external_ref": "cl_app_84321",
    "location_ref": "cl_loc_88213",
    "org_ref": "cl_reg_4471",
    "portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
    "status": "interview",
    "placed_at": "2026-10-02T16:20:11.402Z",
    "placed_via": "portal"
  }
}
```

| Field        | Values                                                                                                              |
| ------------ | ------------------------------------------------------------------------------------------------------------------- |
| `status`     | The applicant's current status at this location (one of the five).                                                  |
| `placed_at`  | When the office placed them.                                                                                        |
| `placed_via` | `portal` (a registry user in CONFIRM) or `partner`. Today placement is only done in the portal, so expect `portal`. |

### `candidate.location_added`

Fires when the applicant gets an application at another location through [`POST /candidates/{external_ref}/locations`](/partner-api/candidates/add-candidate-location) (without `move_from_location_ref`), or when the office does the same in CONFIRM. The identity block is the **new** location. The applicant's other applications are unchanged. Once per application.

```json
{
  "id": "a2f6d0b9-7c31-4e58-9b24-6d1e3a8c5f07",
  "type": "candidate.location_added",
  "created_at": "2026-10-02T15:02:44.917Z",
  "data": {
    "candidate_id": "9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
    "application_id": "0b6e3f72-5a19-4d8c-a2e4-7c1d9f8b3a60",
    "external_ref": "cl_app_84321",
    "location_ref": "cl_loc_88214",
    "org_ref": "cl_reg_4471",
    "portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
    "status": "interview",
    "screening": "reused",
    "screening_source_application_id": "51c0a7de-2f18-4a3e-96b7-1d0c8b2e4a77",
    "occurred_at": "2026-10-02T15:02:44.880Z"
  }
}
```

| Field                             | Values                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `screening`                       | `reused` — the applicant's passed screening covers this location, so they start after screening here. `pending` — there is a screening, but it doesn't cover this location (it failed, was partial, or this location asks other questions); the application starts at screening and a [follow-up call](/partner-guides/multiple-locations#follow-up-calls-for-locations-added-later) asks what is missing. `none` — no completed screening to reuse yet; the application is screened as described there. The value describes the application when it was created and does not change afterwards. |
| `screening_source_application_id` | The application whose screening was reused or considered, or `null`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |

### `candidate.location_changed`

Fires when the applicant is **moved**: [`POST /candidates/{external_ref}/locations`](/partner-api/candidates/add-candidate-location) with `move_from_location_ref`, or a reassignment in CONFIRM. The application at the old location is closed, but not as a rejection: there is no `candidate.status_changed` → `rejected` for it. This one event replaces it.

It is delivered against the **new** location's application, so `location_ref` equals `to_location_ref` and `application_id` equals `to_application_id`. Once per moved-away application.

```json
{
  "id": "e93b5f14-0d67-4a2c-8f19-2b4c7e6a0d58",
  "type": "candidate.location_changed",
  "created_at": "2026-10-02T15:07:31.226Z",
  "data": {
    "candidate_id": "9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
    "application_id": "0b6e3f72-5a19-4d8c-a2e4-7c1d9f8b3a60",
    "external_ref": "cl_app_84321",
    "location_ref": "cl_loc_88214",
    "org_ref": "cl_reg_4471",
    "portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
    "status": "screening",
    "from_location_ref": "cl_loc_88213",
    "to_location_ref": "cl_loc_88214",
    "from_application_id": "51c0a7de-2f18-4a3e-96b7-1d0c8b2e4a77",
    "to_application_id": "0b6e3f72-5a19-4d8c-a2e4-7c1d9f8b3a60",
    "screening": "pending",
    "occurred_at": "2026-10-02T15:07:31.190Z"
  }
}
```

`screening` has the same values as on `candidate.location_added`. Treat the `from_` application as closed; the applicant carries on at the `to_` location.

## Delivery semantics

* **At-least-once.** You may receive the same event more than once. Deduplicate on `id`.
* **Retries with backoff.** Any non-2xx response, timeout (10s), or connection failure is retried at roughly **1 min → 5 min → 30 min → 2 h → 6 h → 15 h**. After about 24 hours the delivery is abandoned and CONFIRM engineering is alerted.
* **Ordering is NOT guaranteed.** A `screening.completed` may arrive before the `candidate.status_changed` that accompanied it. Treat each event as a statement about the moment in its `created_at`, and let a later `created_at` win.
* **Payloads are snapshots.** A delivery retried six hours later carries what was true when the event fired, not an updated version of it. The current state is always in the portal.
* **Bulk actions are throttled on our side.** A registry rejecting 300 applicants at once produces 300 events, but they are dispatched in small batches over a few minutes rather than as a burst.

## Verifying signatures

Every delivery carries a signature header:

```
Confirm-Signature: t=1781532202,v1=5f8c1d2e...
```

* `t` — Unix timestamp (seconds) when the delivery was signed.
* `v1` — hex-encoded HMAC-SHA256 of the string `"<t>.<raw body>"`, keyed with your **partner signing secret** (`cfwh_...`).

Your signing secret is **issued by CONFIRM**, separately from your API keys — it is not on the Developer access tab. Ask your CONFIRM contact; it is delivered privately. There is **one per environment** (sandbox and production each have their own), and it is **partner-level**: one secret verifies every delivery for every registry under your account, and minting or revoking API keys never changes it. It is shown once and never recoverable — a lost secret is rotated, and the old one stops verifying immediately.

Two more headers are set for convenience, and are **not** a substitute for verifying the signature: `Confirm-Event-Id` and `Confirm-Event-Type`.

To verify: recompute the HMAC over the **raw, unparsed** request body, compare in constant time, and reject if the clock skew `|now - t|` exceeds **5 minutes**. The timestamp is inside the signed string, so an attacker cannot replay a captured delivery under a fresh `t`.

### Node (Express)

```javascript
const crypto = require("crypto");
const express = require("express");

const app = express();
const SECRET = process.env.CONFIRM_WEBHOOK_SECRET; // cfwh_...

// IMPORTANT: use the raw body — JSON parsing changes the bytes.
app.post(
  "/confirm/:trackingId",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.get("Confirm-Signature") || "";
    const parts = Object.fromEntries(
      header.split(",").map((kv) => kv.split("=", 2))
    );
    const t = parseInt(parts.t, 10);
    const v1 = parts.v1 || "";

    // Reject stale timestamps (clock skew > 5 minutes)
    if (!t || Math.abs(Date.now() / 1000 - t) > 300) {
      return res.status(400).send("timestamp out of tolerance");
    }

    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(`${t}.${req.body.toString("utf8")}`)
      .digest("hex");

    const ok =
      v1.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
    if (!ok) return res.status(400).send("invalid signature");

    res.sendStatus(200); // acknowledge fast

    const event = JSON.parse(req.body.toString("utf8"));
    // req.params.trackingId is the ID you appended to the webhook_url —
    // you already know which applicant this is.
    queueForProcessing(req.params.trackingId, event); // dedupe on event.id
  }
);
```

### Python (Flask)

```python
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["CONFIRM_WEBHOOK_SECRET"]  # cfwh_...

@app.post("/confirm/<tracking_id>")
def confirm_webhook(tracking_id):
    raw_body = request.get_data()  # raw bytes, before any parsing
    header = request.headers.get("Confirm-Signature", "")
    parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)

    try:
        t = int(parts.get("t", "0"))
    except ValueError:
        abort(400)

    # Reject stale timestamps (clock skew > 5 minutes)
    if abs(time.time() - t) > 300:
        abort(400)

    signed_payload = f"{t}.".encode() + raw_body
    expected = hmac.new(SECRET.encode(), signed_payload, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(parts.get("v1", ""), expected):
        abort(400)

    event = json.loads(raw_body)
    queue_for_processing(tracking_id, event)  # dedupe on event["id"]
    return "", 200
```

## Testing your consumer

Point a `webhook_url` at your endpoint and drive a real applicant through the sandbox (`https://api-staging.confirm.global/partner/v1`). Sandbox telephony is restricted to an allowlist of phone numbers you give us, so you can create test applicants freely and exercise the full loop — ingest, screening call, status changes — with no risk of contacting a real person. Deliveries are signed exactly as in production, so your verification path gets a genuine workout.

## Building a consumer: checklist

1. **Generate your own tracking ID** and append it to the `webhook_url` you send. Correlation becomes a routing concern, not a lookup.
2. **Verify the signature before parsing** the body, using the raw bytes.
3. **Respond 2xx fast** — within seconds, before doing any real work.
4. **Process asynchronously** — enqueue the event and handle it off the request path.
5. **Dedupe on event `id`** — delivery is at-least-once and unordered.
6. **Ignore unknown event types** — the catalog grows; your handler should not break when it does.
7. **Treat `status` as five values.** Never parse or branch on anything finer.
8. **Handle `external_ref: null`** on `candidate.created` — that applicant is new to you, and completing the pairing is a POST back to `/candidates`.
9. **Key status on the location.** Store status per `application_id` (or `external_ref` + `location_ref`), not per applicant — each location has its own.
10. **Expect your own outcomes back.** A `PATCH` outcome that changes the status comes back as `candidate.status_changed`; don't treat it as a new decision.