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

# Webhooks

> practice.opened and practice.signed_up deliveries — ids and timestamps only — signed and retried.

Subscribe with `POST /api/partner/v1/hooks` (scope `hooks:manage`) with your `url` and, optionally, the `events` you
want. The answer carries the signing `secret`, shown only once.

## Events

| Event | When |
|---|---|
| `practice.opened` | The person opened their practice link for the first time. |
| `practice.signed_up` | The person signed in through the link (their practice account now exists). |
| `test.ping` | A test delivery you asked for (`POST /api/partner/v1/hooks/{id}/test`). |

Plan created, practiced and purchased are never sent per person. Use `GET /stats?month=YYYY-MM` for a published month:
each month counts the people who signed up that month and is final when published (on the 1st of the month after next).
Plan created, practiced and purchased are returned only for the whole partnership; with a `sub_partner_ref` filter the
response has invites, opened and signed up only. Groups under 10 come back as "hidden" after Signed up; other counts are
rounded to the nearest 5 ("rounded": true), "<5" or "hidden" when they could identify someone. An unpublished month
returns 422 "counts for this month are not published yet".

The JSON Schema is at [webhooks.schema.json](/developers/webhooks.schema.json); samples at `/developers/samples/{event}.json`.

## Verifying signatures

Deliveries are signed per Standard Webhooks: headers `webhook-id`, `webhook-timestamp`, `webhook-signature`, plus
`x-interview100x-event`.

**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 verifyInterview100xWebhook(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 = { verifyInterview100xWebhook };
```

**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_interview100x_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 verifyInterview100xWebhook(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;
}
```

### Rotating the secret

`POST /api/partner/v1/hooks/{id}/rotate` returns a new secret once. The old secret keeps signing for 24 hours: each
delivery in that window carries both signatures in `webhook-signature`, so a receiver verifying with either secret
accepts it. Switch your receiver to the new secret within the 24 hours.

## Retries and testing

A delivery without a 2xx answer is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10
hours. `POST /api/partner/v1/hooks/{id}/test` sends one signed `test.ping` now (at most 30 per 10 minutes per key).
