<!-- Generated by scripts/gen-developer-docs.ts from src/developers/content/recruit/webhooks.md — do not edit. -->

# Webhooks

> Every event Recruit100x can send, its schema and sample, how deliveries are signed and retried, and how to test them.

Subscribe with `POST /api/v1/hooks` (scope `manage:hooks`) or in **Settings › Integrations › Webhooks**. Choose the
payload version per endpoint with `api_version`:

- `2026-09-01` — the original payloads, frozen: fields are only ever added.
- `2026-11-01` — adds your own ids (`external_ref`), the invite, band counts, `livemode`, and the `role.*` events.

## Events

| Event | When | Scope | Since |
|---|---|---|---|
| `invite.sent` | An interview invitation was sent (or, with `return_link`, created for you to deliver). | `read:roles` | 2026-09-01 |
| `invite.opened` | The candidate opened their invitation link for the first time. | `read:roles` | 2026-09-01 |
| `screen.completed` | The candidate finished the interview; the report is being written. | `read:reports` | 2026-09-01 |
| `report.ready` | The report is ready — the same payload as `GET /api/v1/reports/{reportId}`. | `read:reports` | 2026-09-01 |
| `report.failed` | The report is delayed; we keep trying and send `report.ready` when it is done. | `read:reports` | 2026-09-01 |
| `decision.recorded` | A person on your team recorded a decision (advance, reject or hold). | `read:reports` | 2026-09-01 |
| `client_feedback.received` | Your client left feedback on a shared shortlist. | `read:reports` | 2026-09-01 |
| `candidate.sms_opted_out` | The candidate opted out of text messages. | `read:roles` | 2026-09-01 |
| `shortlist.created` | A shortlist link was created for your client. | `read:reports` | 2026-09-01 |
| `candidate.handoff` | An advanced candidate was sent on to your ATS. | `read:reports` | 2026-09-01 |
| `role.ready` | The role's interview guide was approved: you can invite candidates. | `read:roles` | 2026-11-01 |
| `role.closed` | The role was closed. | `read:roles` | 2026-11-01 |
| `role.guide_failed` | The role's interview guide could not be drafted. | `read:roles` | 2026-11-01 |

Each event's JSON Schema and a sample are published: [webhooks.2026-11-01.schema.json](/developers/webhooks.2026-11-01.schema.json),
[webhooks.2026-09-01.schema.json](/developers/webhooks.2026-09-01.schema.json), and `/developers/samples/{event}.json`.
There is no per-candidate candidate-care event; use the monthly stats (`GET /api/v1/stats/candidate-care`).

## Verifying signatures

Each delivery is signed per Standard Webhooks with your endpoint's secret: headers `webhook-id`, `webhook-timestamp` and
`webhook-signature`, plus `x-recruit100x-event`. Verify the raw body before you trust it:

**Node**

```js
const crypto = require("node:crypto");

// secret: the whsec_… value shown once when you created the endpoint.
// headers: the request headers (lower-case names). rawBody: the body exactly as received.
function verifyRecruit100xWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"] || "";
  if (!id || !timestamp) return false;
  // Refuse messages older or newer than 5 minutes (replay protection).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = "v1," + crypto
    .createHmac("sha256", key)
    .update(id + "." + timestamp + "." + rawBody)
    .digest("base64");
  // During a secret rotation the header carries two values; either may match.
  return signatures.split(" ").some((candidate) =>
    candidate.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)));
}

module.exports = { verifyRecruit100xWebhook };
```

**Python**

```python
import base64, hashlib, hmac, time

# secret: the whsec_... value shown once when you created the endpoint.
# headers: a dict of request headers (lower-case names). raw_body: the body bytes as received.
def verify_recruit100x_webhook(secret, headers, raw_body):
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature", "")
    if not msg_id or not timestamp:
        return False
    # Refuse messages older or newer than 5 minutes (replay protection).
    if abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    # During a secret rotation the header carries two values; either may match.
    return any(hmac.compare_digest(candidate, expected) for candidate in signatures.split(" "))
```

**PHP**

```php
<?php
// $secret: the whsec_… value shown once when you created the endpoint.
// $headers: request headers with lower-case names. $rawBody: the body exactly as received.
function verifyRecruit100xWebhook(string $secret, array $headers, string $rawBody): bool
{
    $id = $headers['webhook-id'] ?? '';
    $timestamp = $headers['webhook-timestamp'] ?? '';
    $signatures = $headers['webhook-signature'] ?? '';
    if ($id === '' || $timestamp === '') {
        return false;
    }
    // Refuse messages older or newer than 5 minutes (replay protection).
    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }
    $key = base64_decode(preg_replace('/^whsec_/', '', $secret));
    $expected = 'v1,' . base64_encode(hash_hmac('sha256', $id . '.' . $timestamp . '.' . $rawBody, $key, true));
    // During a secret rotation the header carries two values; either may match.
    foreach (explode(' ', $signatures) as $candidate) {
        if (hash_equals($expected, $candidate)) {
            return true;
        }
    }
    return false;
}
```

## Retries and replay

A delivery that doesn't get a 2xx answer is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours
and 10 hours. Use `webhook-id` to ignore a delivery you already handled. Failed deliveries can be replayed from
Settings › Integrations.

## Test events

`POST /api/v1/hooks/{hookId}/test` sends a signed `test.ping` to one endpoint now. In [test mode](/developers/test-mode)
the test page sends the interview events to your test endpoints.
