# Webhooks

> Signed events for findings, scores, compliance plans, test runs, consents, certificates, notices and law changes — the event types, the payload, signature verification and retries.

Checkbox POSTs an [Event](https://checkbox.site/docs/api/events#the-event-object) to each of your webhook endpoints that subscribed to its
type. Use them to react without polling: open a ticket when a finding appears, tell your agent when a verification
passes, or alert when the law changes.

## Event types {#event-types}

| Event | When | `data.object` |
|---|---|---|
| `plan.updated` | A site's compliance plan changed (its status or a task's status); data.object is the plan without task content — GET the plan and follow next_action. | `CompliancePlanSummary` |
| `task.verified` | A compliance-plan task verification resolved (passed or failed); data.object is the verification with expected vs observed per check. | `Verification` |
| `test_run.completed` | A lifecycle test run finished; data.object is the test_run with every step's observations, check results and fixes. | `TestRun` |
| `test_run.failed` | A lifecycle test run stopped early; data.object.failure has the code, message and fix. | `TestRun` |
| `site.score_changed` | A site's compliance score changed (data.previous_attributes.score has the old score). | `Site` |
| `site.insurable_changed` | A site became insurable or stopped being insurable. | `Site` |
| `finding.created` | A check started failing (new finding, or a resolved / passing one failing again). | `Finding` |
| `finding.resolved` | A failing or undecided check now passes or no longer applies. | `Finding` |
| `scan.completed` | A scan finished (the evaluation may follow in a separate site.score_changed event). | `Scan` |
| `scan.failed` | A scan failed; data.object.error says why. | `Scan` |
| `law.changed` | A law change Checkbox applies affected one of your sites (data.object is the law update with that site's impact). | `LawUpdate` |
| `certificate.issued` | A monitoring certificate was issued. | `Certificate` |
| `certificate.lapsed` | A certificate lapsed (the site stopped meeting the requirements). | `Certificate` |
| `certificate.revoked` | A certificate was revoked. | `Certificate` |
| `notice.sent` | Checkbox sent a legally required notice (acknowledgment, reminder, price change) for the merchant. | `Notice` |
| `consent.created` | A subscriber gave affirmative consent to the automatic-renewal terms through Checkbox Elements (or your server recorded one); data.object is the consent. | `Consent` |
| `site.compliant` | A site's compliance plan became compliant: nothing is left to apply, verify or answer; data.object is the site. | `Site` |

## Create an endpoint {#create}

`enabled_events` takes event types from the table above, or `["*"]` for all of them. The response includes the
endpoint's `secret` (`whsec_…`) once:

```curl
curl https://app.checkbox.site/v1/webhook_endpoints \
  -u "$CHECKBOX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://joybox.example/hooks/checkbox", "enabled_events": ["finding.created", "finding.resolved", "task.verified"] }'
```
```node
import Checkbox from '@checkbox-site/cli';

const checkbox = new Checkbox(process.env.CHECKBOX_SECRET_KEY);

const endpoint = await checkbox.webhookEndpoints.create({
  url: 'https://joybox.example/hooks/checkbox',
  enabled_events: ['finding.created', 'finding.resolved', 'task.verified'],
});
console.log(endpoint.secret);
```
```python
import os
from checkbox import Checkbox

checkbox = Checkbox(os.environ["CHECKBOX_SECRET_KEY"])

endpoint = checkbox.webhook_endpoints.create(
    url="https://joybox.example/hooks/checkbox",
    enabled_events=["finding.created", "finding.resolved", "task.verified"],
)
print(endpoint["secret"])
```

Endpoints must be public HTTPS URLs; redirects are not followed. `rollWebhookEndpointSecret` replaces the secret
immediately and returns the new one (only then), so deploy the new secret right after rolling it.

## The payload {#payload}

```json
{
  "id": "evt_3f9a1c2e7b4d4e0f9a8b7c6d5e4f3a2b",
  "object": "event",
  "type": "finding.created",
  "api_version": "2026-10-30",
  "created": 1793318400,
  "livemode": true,
  "data": {
    "object": { "id": "fnd_3f9a1c2e7b4d4e0f9a8b7c6d5e4f3a2b", "object": "finding", "check_id": "signup.consent.not_prechecked", "status": "fail" },
    "previous_attributes": {}
  },
  "pending_webhooks": 1,
  "request": { "id": null }
}
```

Headers: `Checkbox-Signature`, `Checkbox-Event-Id`, `Checkbox-Event-Type`. Events are kept for 30 days and listed by
[`listEvents`](https://checkbox.site/docs/api/events#listEvents) (`type=finding.*`, `created_gte`…).

## Verify signatures {#verify-signatures}

`Checkbox-Signature: t=<unix seconds>,v1=<hex>`, where `v1` is HMAC-SHA256 of `"<t>.<raw body>"` with the endpoint
secret. Reject anything older than 5 minutes, and accept the delivery if any `v1` entry matches (a header may carry more
than one). Use the raw body exactly as received — not re-serialized JSON.

Without an SDK:

```node
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = header.split(',');
  const t = Number(parts.find((p) => p.startsWith('t='))?.slice(2));
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  return parts
    .filter((p) => p.startsWith('v1='))
    .some((p) => {
      const got = Buffer.from(p.slice(3), 'hex');
      return got.length === expected.length && timingSafeEqual(got, expected);
    });
}

// Self-check with a header built the same way Checkbox builds it.
const secret = 'whsec_test_secret';
const body = '{"id":"evt_123","type":"site.compliant"}';
const t = Math.floor(Date.now() / 1000);
const header = `t=${t},v1=${createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')}`;
console.log(verify(body, header, secret), verify(body, header, 'whsec_wrong'));
```

With the SDKs: `checkbox.webhooks.constructEvent(rawBody, header, secret)` ([Node](https://checkbox.site/docs/sdks/node#webhooks)) and
`checkbox.webhook.construct_event(raw_body, header, secret)` ([Python](https://checkbox.site/docs/sdks/python#webhooks)).

A complete handler (Node, no framework):

```node
import { createServer } from 'node:http';
import Checkbox from '@checkbox-site/cli';

const checkbox = new Checkbox(process.env.CHECKBOX_SECRET_KEY);

createServer((req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    let event;
    try {
      event = checkbox.webhooks.constructEvent(Buffer.concat(chunks).toString('utf8'), req.headers['checkbox-signature'], process.env.CHECKBOX_WEBHOOK_SECRET);
    } catch {
      res.writeHead(400).end();
      return;
    }
    if (event.type === 'task.verified') console.log('verified', event.data.object.id);
    res.writeHead(200).end();
  });
}).listen(3000);
```

## Delivery and retries {#retries}

Any `2xx` response acknowledges a delivery; answer quickly and do the work afterwards. Failed deliveries are retried
after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 24 hours and 24 hours (about three days), then
marked failed. Deliveries to private or loopback addresses are refused. Events can arrive more than once or out of
order: use `id` to deduplicate and `created` to order.
