# Webhooks

Register a URL and we POST to it. The event catalog, the signature, and what happens when your receiver stops answering.

Rather than polling, register a URL and we will POST to it when something
happens in your workspace: a new message on a conversation, a comment or a DM
on a connected social account, a post published or failed, a lead captured, or
anything in a referral program.

Register one in the dashboard (**Settings → Developers → Webhooks**) or with
`POST /v1/webhooks`. Up to five per workspace by default (`webhook_endpoints`
in `GET /v1/usage`).

**Choose which events you want.** Leave `events` out and the endpoint receives
every event, now and in future, which is what most integrations should do.
Name them and only those arrive: an unknown name is refused rather than quietly
dropped, so a typo is a `400` and not a webhook that never fires.
`GET /v1/webhooks/events` is the live list.

**The live event catalog** — answers `200`

Read this rather than hardcoding the list: a new event is an addition inside `/v1`, so the catalog grows without a version change.

```bash
curl https://api.boltclaw.ai/v1/webhooks/events \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```js
const res = await fetch("https://api.boltclaw.ai/v1/webhooks/events", {
  headers: {
    Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`,
  },
});

// 200
const data = await res.json();
```

**Register an endpoint** — answers `201`

`Idempotency-Key` is required here: a retried timeout would otherwise leave you with two endpoints and two signing secrets. The secret is in this response and in no other.

```bash
curl -X POST https://api.boltclaw.ai/v1/webhooks \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5b8f6e2c-1d4a-4f7b-9c3e-8a2d6f0b4e17" \
  -d '{
  "url": "https://example.com/hooks/boltclaw",
  "events": [
    "conversation.message.created",
    "social.post.published"
  ],
  "description": "Production receiver"
}'
```

```js
const res = await fetch("https://api.boltclaw.ai/v1/webhooks", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "url": "https://example.com/hooks/boltclaw",
    "events": [
      "conversation.message.created",
      "social.post.published"
    ],
    "description": "Production receiver"
  }),
});

// 201
const data = await res.json();
```

## Every event

<!-- WEBHOOK EVENT CATALOG -->

| `event` | About | What it means |
|---|---|---|
| `conversation.message.created` | a conversation | A conversation on one of your pages received a message. Your own replies fire too. |
| `social.inbox.item.created` | a social inbox item | A connected social account received a comment, a mention or a direct message. |
| `social.post.published` | a post | A scheduled post went live on at least one network. |
| `social.post.failed` | a post | A post could not be published. Read the post for the reason. |
| `lead.created` | a lead | An automation captured a contact detail somebody chose to give. |
| `referral.partner.applied` | a partner | Somebody applied to promote one of your referral programs. |
| `referral.partner.approved` | a partner | A partner was approved for one of your referral programs. |
| `referral.conversion.created` | a conversion | A referred signup, sale or refund was recorded. |
| `referral.commission.created` | a commission | A commission was created for a referred conversion. |
| `referral.bonus.created` | a commission | A partner crossed a milestone and earned a bonus, or you paid one by hand. |
| `referral.referrer.created` | a referrer | One of your own users became a referrer: you made one through the API, or their panel session did. |
| `referral.referrer.conversion` | a referrer | A friend of one of your referrers signed up, or paid. |
| `referral.referrer.reward_earned` | a referrer | One of your referrers earned a reward, with its amount and its status. |
| `referral.referrer.payout_details_changed` | a referrer | One of your referrers saved or changed how they are paid. Tell your user: it is how an account takeover is noticed. |
| `referral.commission.reversed` | a commission | A commission was reversed, usually because the sale was refunded. |
| `referral.payout.paid` | a payout | A partner payout was marked paid. |
| `referral.grant.ready` | a grant | A reward that is not cash (credit, a free period, a perk, a gift card) finished its wait and is ready for you to give. Give it, then tell us with its reference. |
| `referral.grant.fulfilled` | a grant | A reward that is not cash was given: by your own system, through your Stripe, or by a person on your team. |
| `referral.grant.cancelled` | a grant | A reward that is not cash was cancelled before it was given, because its sale was refunded or disputed. |
| `referral.grant.reversed` | a grant | A reward that was already given belongs to a sale that was refunded or disputed. Take it back if your own terms allow; Bolt Claw never does. |
| `referral.referee.invite_ready` | a referrer | A friend who just made their first purchase now has their own referral link, so your thank-you page can ask them to invite their friends. |
| `referral.risk.alert` | a partner | A promoter crossed your review line, or a discount code started being redeemed far faster than usual with fewer clicks than uses. Their money waits for you on the Fraud tab; nothing was rejected. At most once per promoter a day. |
| `referral.payout.sent` | a payout | A payout was handed to your payout provider (or your own payout system). It is on its way; referral.payout.paid follows when it arrives. |
| `referral.payout.failed` | a payout | Your payout provider could not pay a payout. Its commissions are back in Ready to pay, and the reason is a fixed code. |
| `referral.payout.returned` | a payout | A payout came back: unclaimed for too long, or returned by the recipient's bank. Before it was paid, its commissions are back in Ready to pay; after (after_paid true), nothing changed and it is yours to decide. |
| `referral.payout.requested` | a payout | Only sent to the endpoint a program chose as its own payout system: pay this promoter, then report the result with POST /v1/referrals/payouts/{id}/result. Read where to send it with GET /v1/referrals/payouts/{id}/details. |
| `referral.tax_form.signed` | a tax form | A promoter signed a W-9, W-8BEN or W-8BEN-E (or said they are not a US person) for your business. The body names the form type and status, never its contents. |
| `referral.tax_form.expiring` | a tax form | A promoter's W-8 expires in 60 or 14 days (days_left). Their payouts wait from the day it expires until they sign a new one. |
| `referral.invoice.issued` | an invoice | A self-billed invoice was issued for a paid payout (kind self_billed), or a promoter uploaded their own invoice (kind uploaded). |
| `referral.invoice.disputed` | an invoice | A promoter disputed a self-billed invoice within its 14 days. It is paused until you issue a corrected one. |
| `referral.broadcast.sent` | a broadcast | A message went to a program's promoters. partners is how many have a Bolt Claw account (emailed, unless they muted the program); referrers is how many of your own users it reached: Bolt Claw never emails them, so send them your own message. |
| `referral.partner.imported_claimed` | a partner | A person you imported signed in through their invite link and joined: their code, group, rate and opening balance (a bonus paid by hand) are theirs now. |
| `referral.contest.ended` | a contest | A contest reached its end: its standings are final. Prizes wait for the holds and the open flags, and for a person to award them. |
| `referral.milestone.reached` | a promoter | A promoter crossed a milestone and was given its reward: a bonus (commission_id) or a reward that is not cash (grant_id). |
| `referral.waitlist.entry_created` | a waitlist entry | Somebody joined a waitlist: through your own form when you add them, or on the hosted page once they confirmed their address. external_id is your id for them; never an address. |
| `referral.waitlist.position_changed` | a waitlist entry | An entry's place changed: it was confirmed, a friend it invited was confirmed, or it was disqualified or put back. At most once an hour per entry. |
| `webhook.test` | the endpoint | Sent only when you ask for it, to check your receiver and its signature check. |

<!-- /WEBHOOK EVENT CATALOG -->

**An integration should ignore an `event` it does not recognise.** New names are
added without a version change, and a handler that rejects an unknown one is a
handler that starts failing deliveries the day one is added.

**What each event reaches.** Only the conversation event is about a page, so it
is the only one a page-filtered endpoint receives. Everything else goes to
endpoints that cover every page (`page_slugs: null`), and `webhook.test` goes
to the one endpoint you asked for, whatever it covers and whatever it
subscribed to.

**And one thing worth knowing about roles:** an endpoint receives an event only
while the person who registered it still has permission to read what the event
is about. Take `referrals.view` away from them and their endpoint stops getting
referral events, the same way their API keys stop carrying `referrals:read`.
Nothing else changes, and the delivery log is where it shows: the event simply
never appears in it.

## The request carries no message text

```json
{
  "event": "conversation.message.created",
  "channel": "page",
  "inquiry_id": "c41f9a08-6d52-4e17-8b3c-2f7a91d05e64",
  "page_slug": "acme",
  "occurred_at": "2026-03-04T14:02:55.301118+00:00"
}
```

That is the whole body, and it will not grow to include the message. **A
webhook is a nudge, not a copy.** Call
`GET /v1/inquiries/{inquiry_id}/messages` to read the words.

Two reasons, and both are worth knowing because they shape how you should build
against it:

**A webhook URL ends up in places you did not plan.** Zapier steps, n8n nodes,
Slack relays, request logs, a proxy's access log. If the body carried what
somebody wrote to you privately, it would land in all of them. It carries only
identifiers you already have, so a misdirected endpoint discloses that a
conversation was active — never what was said.

**It makes the delivery guarantees ones you can rely on.** Because the body has
no content and no ordering, a duplicate is harmless and a late delivery is
harmless. So: **expect duplicates, do not assume order, and treat every
delivery as "something changed on this thread — go and look".** Written that
way, your integration is correct by construction. Written as a replica that
applies each body in sequence, it will drift.

`page_slug` is present when `channel` is `"page"`. Branch on `channel` before
reading it.

**Your own replies fire too.** A reply you send from the dashboard, or through
this API, is still a change your other systems are mirroring. A mirror that
only hears one side of a conversation is wrong in a way that is very hard to
notice.

## Social events

The same endpoints receive four more events, under the same rule —
**identifiers only**. A comment's words, a DM, and a lead's email address or
phone number never go into a webhook body; fetch them with a key that has the
right scope.

| `event` | Extra fields | Fetch with |
|---|---|---|
| `social.inbox.item.created` | `inbox_item_id`, `account_id` | `GET /v1/inbox/{inbox_item_id}` (`social:read`) |
| `social.post.published` | `post_id` | `GET /v1/posts/{post_id}` (`social:read`) |
| `social.post.failed` | `post_id` | `GET /v1/posts/{post_id}` (`social:read`) |
| `lead.created` | `lead_id` | `GET /v1/leads` (`leads:read`) |

```json
{
  "event": "social.post.published",
  "post_id": "5e0c2b7a-1d94-4f38-a6e2-7b3c9d10f4a8",
  "occurred_at": "2026-10-01T09:00:04.118904+00:00"
}
```

Social events are not about a page, so they go only to endpoints that cover
**every** page (`page_slugs: null`). An endpoint limited to particular pages
receives page conversation events and nothing else.

`social.post.published` and `social.post.failed` fire once per post, when the
last of its accounts settles. A post that published on some accounts and failed
on others is `partial` and fires `social.post.published` — read the post's
`targets` to see which failed. `social.post.failed` means it went out nowhere.

## Referral events

If you run a [referral program](https://boltclaw.ai/docs/api/referrals), the same endpoints receive seven
more events, under the same rule — **identifiers only**. No amount, no email,
no account id, and nothing about the other party: fetch the details with a key
that has `referrals:read`.

| `event` | Extra fields | Fetch with |
|---|---|---|
| `referral.partner.applied` | `program_id`, `partner_id` | `GET /v1/referrals/programs/{program_id}/partners` |
| `referral.partner.approved` | `program_id`, `partner_id` | `GET /v1/referrals/programs/{program_id}/partners` |
| `referral.conversion.created` | `program_id`, `conversion_id` | `GET /v1/referrals/programs/{program_id}/commissions` (a commission's `conversion.id`) |
| `referral.commission.created` | `program_id`, `commission_id`, `conversion_id`, `partner_id` | `GET /v1/referrals/programs/{program_id}/commissions` |
| `referral.bonus.created` | `program_id`, `commission_id`, `conversion_id`, `partner_id` | `GET /v1/referrals/programs/{program_id}/commissions` |
| `referral.commission.reversed` | `program_id`, `commission_id`, `conversion_id` | `GET /v1/referrals/programs/{program_id}/commissions` |
| `referral.payout.paid` | `program_id`, `payout_id`, `partner_id` | `GET /v1/referrals/programs/{program_id}/payouts` |

Payouts sent through your own provider (Wave 6) add three more, each with `program_id`,
`payout_id`, `partner_id`, `provider`, `amount_minor` and `currency`: `referral.payout.sent`,
`referral.payout.failed` (with `reason`, a fixed code) and `referral.payout.returned` (with
`reason` and `after_paid`). Fetch one with `GET /v1/referrals/payouts/{payout_id}`. The fourth,
`referral.payout.requested`, goes only to the endpoint a program chose as its own payout
system: see [Your own payout system](https://boltclaw.ai/docs/referral-payout-webhook).

```json
{
  "event": "referral.commission.created",
  "program_id": "6f0e3b9a-4c21-4d8e-9a57-0b3c2d1e4f60",
  "commission_id": "0b6e9d2c-71a4-4f3e-8c05-9d1a2b3c4e5f",
  "conversion_id": "1d2c7e48-93b5-4f0a-8c61-5e2b9a0d7f13",
  "partner_id": "a3c5e7f9-1b2d-4c6e-8f0a-2b4d6f8a0c1e",
  "occurred_at": "2026-10-16T09:12:45.118904+00:00"
}
```

```json
{
  "event": "referral.payout.paid",
  "program_id": "6f0e3b9a-4c21-4d8e-9a57-0b3c2d1e4f60",
  "payout_id": "c8d0e2f4-6a8c-4e0a-9c2e-4f6a8c0e2a4c",
  "partner_id": "a3c5e7f9-1b2d-4c6e-8f0a-2b4d6f8a0c1e",
  "occurred_at": "2026-11-02T10:30:00.502117+00:00"
}
```

`partner_id` is the partner's **membership** in that program — the `id` the
partners list returns — never their account. The same person in two of your
programs is two unrelated ids.

**There is no opt-in.** Referral events go to every endpoint registered for
**all** pages, exactly like the social events, as soon as something happens in
a program; an endpoint limited to particular pages receives none of them. An
integration should ignore any `event` it does not recognise — new event names
are added without a version change, and a handler that rejects an unknown one
is a handler that starts failing deliveries the day one is added.

- `referral.partner.applied` fires when somebody applies to (or re-joins) a
  program. For a program that approves automatically,
  `referral.partner.approved` follows straight away.
- `referral.partner.approved` fires when an application is approved —
  automatically, from the dashboard, or by
  `POST /v1/referrals/partners/{id}/approve`. Approving an already approved
  partner, or lifting a ban, fires nothing.
- `referral.conversion.created` fires for every lead, sale, refund and dispute
  recorded — reported through this API, or arriving from your Stripe — whether
  or not anybody referred it. **A repeated `event_id`, or a Stripe event
  delivered twice, fires nothing the second time.**
- `referral.commission.created` fires when a lead or sale earns a commission,
  once for each [reward rule](https://boltclaw.ai/docs/rewards) the sale's items matched.
- `referral.bonus.created` fires when a partner crosses a milestone and earns
  its bonus, or when you pay a partner a bonus by hand
  (`POST /v1/referrals/partners/{id}/bonuses`). Its body is the same four ids
  as `referral.commission.created`; a hand-paid bonus's `conversion_id` is a
  record of the payment and has no customer.
- `referral.commission.reversed` fires when a refund or dispute reaches one;
  `commission_id` is the commission as it now stands, or the negative
  adjustment a refund after payout created. Adjustments you make yourself fire
  no event.
- `referral.payout.paid` fires when a payout is marked paid.

## Referrer events

If you show your own users a [referral panel](https://boltclaw.ai/docs/referral-panel), four more
events tell your server what happened to one of them. They differ from the
events above in one way, on purpose: **the body carries `external_id`**, your
own id for the user. You supplied it and you are the only receiver, so there is
nothing to look up before you can tell your user. They carry no email, no name,
and nothing about the friend. Each also has the usual `event` and `occurred_at`.

| `event` | Extra fields | When |
|---|---|---|
| `referral.referrer.created` | `program_id`, `referrer_id`, `external_id` | One of your users became a referrer: you made one through the API, or their panel session did. A repeated call fires nothing. |
| `referral.referrer.conversion` | `program_id`, `referrer_id`, `external_id`, `conversion_id`, `kind` | A friend of the referrer signed up (`kind` `signed_up`) or paid (`paid`). |
| `referral.referrer.reward_earned` | `program_id`, `referrer_id`, `external_id`, `commission_id`, `amount_minor`, `currency`, `status` | The referrer earned a reward. `amount_minor` is in minor units of `currency`; `status` is `pending`, `approved`, `payable` or `paid`. |
| `referral.referrer.payout_details_changed` | `program_id`, `referrer_id`, `external_id`, `method` | The referrer saved or changed how they are paid (`method` is `paypal` or `wise`). Never the details. Tell your user: it is how an account takeover is noticed. |

```json
{
  "event": "referral.referrer.reward_earned",
  "program_id": "6f0e3b9a-4c21-4d8e-9a57-0b3c2d1e4f60",
  "referrer_id": "a3c5e7f9-1b2d-4c6e-8f0a-2b4d6f8a0c1e",
  "external_id": "user-1042",
  "commission_id": "0b6e9d2c-71a4-4f3e-8c05-9d1a2b3c4e5f",
  "amount_minor": 1500,
  "currency": "USD",
  "status": "pending",
  "occurred_at": "2026-11-02T10:30:00.502117+00:00"
}
```

`referrer_id` is the referrer's `id` in [`GET /v1/referrals/programs/{id}/referrers`](https://boltclaw.ai/docs/api/referrals).
These events are subject to the same rule as every referral event: they go to
every endpoint registered for all pages, and a handler should ignore any `event`
it does not recognise.

## Grant events

When a [reward that is not cash](https://boltclaw.ai/docs/rewards-not-cash) changes state, a grant
event says so. Like the referrer events, the body names the recipient as you
know them, so you can act without a lookup: `recipient_kind` is `referrer` (one
of your own users: `referrer_id` and your `external_id`), `customer` (the
friend: your `external_id` for the customer) or `partner` (an affiliate:
`display_name`, their name in the program). Never an email or an account. A
friend's discount, applied at your checkout, raises none of them.

| `event` | When |
|---|---|
| `referral.grant.ready` | The grant finished the program's hold and is yours to give. |
| `referral.grant.fulfilled` | It was given; `reference` is yours, or Stripe's. |
| `referral.grant.cancelled` | The sale was refunded or disputed before it was given. |
| `referral.grant.reversed` | The sale was refunded or disputed after it was given. |

```json
{
  "event": "referral.grant.ready",
  "program_id": "6f0e3b9a-4c21-4d8e-9a57-0b3c2d1e4f60",
  "grant_id": "2d4f6a8c-0e2a-4c6e-8a0c-2e4a6c8e0a2c",
  "conversion_id": "8b0d2f4a-6c8e-4a0c-8e2a-4c6e8a0c2e4a",
  "reward_type": "free_period",
  "beneficiary": "promoter",
  "status": "ready",
  "amount_minor": null,
  "currency": null,
  "percent_bps": null,
  "period_count": 1,
  "period_unit": "month",
  "perk_text": null,
  "recipient_kind": "referrer",
  "referrer_id": "a3c5e7f9-1b2d-4c6e-8f0a-2b4d6f8a0c1e",
  "external_id": "user-1042",
  "display_name": null,
  "reference": null,
  "occurred_at": "2026-11-02T10:30:00.502117+00:00"
}
```

`grant_id` is the grant in [`GET /v1/referrals/grants/{id}`](https://boltclaw.ai/docs/api/referrals).

## The risk alert

**`referral.risk.alert`** fires when the [risk engine](https://boltclaw.ai/docs/referral-fraud) first asks a
person to look at a promoter (a conversion's score reached the program's review line)
or when one of their discount codes starts leaking (far more redemptions than clicks),
at most once per promoter a day. Nothing was rejected or banned: their money waits for
a decision. The body names the promoter as you know them (`recipient_kind`
`partner` with `partner_id` and `display_name`, or `referrer` with `referrer_id` and
your `external_id`), the `kind` (`review` or `code_leak`), the `score`, the open flag
(`flag_id`, read it with [`GET /v1/referrals/flags/{id}`](https://boltclaw.ai/docs/api/referrals)) and,
for a leak, the `code`. Never what the engine saw: an address, a card or an email.

```json
{
  "event": "referral.risk.alert",
  "program_id": "6f0e3b9a-4c21-4d8e-9a57-0b3c2d1e4f60",
  "alert_id": "0c2e4a6c-8e0a-4c2e-a4c6-e8a0c2e4a6c8",
  "kind": "review",
  "score": 74,
  "flag_id": "4e6a8c0e-2a4c-4e8a-8c0e-2a4c6e8a0c2e",
  "recipient_kind": "partner",
  "partner_id": "9a1c3e5f-7b9d-4f1a-8c3e-5a7c9e1b3d5f",
  "referrer_id": null,
  "display_name": "Jane",
  "external_id": null,
  "code": null,
  "occurred_at": "2026-11-02T10:30:00.502117+00:00"
}
```

**`referral.referee.invite_ready`** fires when a friend's first payment gave them
a referral link of their own (the program's `invite_after_purchase`), with
`program_id`, `referrer_id`, `external_id` (your id for the customer), `code`
and `link`, so your thank-you page can ask them to invite their friends.

## Tax and invoice events

When a referral program [asks for tax forms](https://boltclaw.ai/docs/referral-tax) or
[issues invoices](https://boltclaw.ai/docs/referral-self-billing), four more events say what happened. Like every
referral event they carry ids, a type and a status: **never a form's contents, a tax number, a
name, an address or a VAT number**.

| `event` | Extra fields |
|---|---|
| `referral.tax_form.signed` | `program_id`, `partner_id`, `form_type`, `status`, `valid_until` |
| `referral.tax_form.expiring` | the same, and `days_left` (60 or 14) |
| `referral.invoice.issued` | `program_id`, `payout_id`, `partner_id`, `invoice_id`, `kind`, `number`, `status`, `vat_treatment`, `currency`, `amount_minor`, `replaces` |
| `referral.invoice.disputed` | `program_id`, `payout_id`, `partner_id`, `invoice_id`, `number`, `reason` |

A form belongs to a person, not to one program, so a form event arrives once for each of
your programs they are a member of, each with that membership's `partner_id`. `form_type` is
`w9`, `w8ben`, `w8bene` or `foreign_statement` (the short statement that they are not a US
person). An uploaded invoice has no `number` and no `vat_treatment`; a corrected invoice names
the one it `replaces`. A dispute's `reason` is `details_wrong`, `amount_wrong`, `vat_wrong`,
`not_mine` or `other`.

```json
{
  "event": "referral.tax_form.expiring",
  "program_id": "6f0e3b9a-4c21-4d8e-9a57-0b3c2d1e4f60",
  "partner_id": "a3c5e7f9-1b2d-4c6e-8f0a-2b4d6f8a0c1e",
  "form_type": "w8ben",
  "status": "signed",
  "valid_until": "2029-12-31T23:59:59+00:00",
  "days_left": 14,
  "occurred_at": "2029-12-17T06:00:00.418204+00:00"
}
```

```json
{
  "event": "referral.invoice.issued",
  "program_id": "6f0e3b9a-4c21-4d8e-9a57-0b3c2d1e4f60",
  "payout_id": "c8d0e2f4-6a8c-4e0a-9c2e-4f6a8c0e2a4c",
  "partner_id": "a3c5e7f9-1b2d-4c6e-8f0a-2b4d6f8a0c1e",
  "invoice_id": "1e3a5c7e-9b0d-4f2a-8c4e-6a8c0e2a4c6e",
  "kind": "self_billed",
  "number": "SB1A2B3C4D-00042",
  "status": "issued",
  "vat_treatment": "reverse_charge",
  "currency": "EUR",
  "amount_minor": 24000,
  "replaces": null,
  "occurred_at": "2026-11-02T10:30:00.502117+00:00"
}
```

Read a payout's invoices with
[`GET /v1/referrals/payouts/{id}/invoices`](https://boltclaw.ai/docs/api/referrals), and a program's forms by
status with `GET /v1/referrals/programs/{id}/tax-forms`.

## Growth events

Two more come from [growing a partner program](https://boltclaw.ai/docs/referral-growth):

| `event` | Extra fields |
|---|---|
| `referral.broadcast.sent` | `broadcast_id`, `program_id`, `subject`, `audience` (`all`, `group` or `partners`), `partners`, `referrers` |
| `referral.partner.imported_claimed` | `program_id`, `partner_id`, `import_id`, `code`, `group_id`, `rate_bps`, `balance_minor`, `currency` |

`referral.broadcast.sent` is how a message reaches your own users in the in-app panel: Bolt
Claw shows it in the panel's News and never emails them, so list them with
`GET /v1/referrals/broadcasts/{id}/referrers` (your own ids) and send your own. The body is the
subject only; read the text with `GET /v1/referrals/broadcasts/{id}`. In
`referral.partner.imported_claimed`, `code` is null when the reserved code could not be used,
and `balance_minor` and `currency` are null when no opening balance was brought over.

## Campaign events

Four more come from [campaigns](https://boltclaw.ai/docs/referral-campaigns):

| `event` | Extra fields |
|---|---|
| `referral.contest.ended` | `program_id`, `contest_id`, `ended_at` |
| `referral.milestone.reached` | `program_id`, `partner_id` or `referrer_id` and `external_id`, `reward_id`, `reward_type`, `commission_id`, `grant_id` |
| `referral.waitlist.entry_created` | `program_id`, `entry_id`, `external_id`, `source` (`api` or `hosted`), `verified`, `referred_by_external_id`, `referred_by_partner_id` |
| `referral.waitlist.position_changed` | `program_id`, `entry_id`, `external_id`, `position`, `friends` |

None carries an address, not even for a person who joined on the hosted page: export those from
the dashboard. A contest's standings and the prizes are read with
`GET /v1/referrals/contests/{id}`; a milestone's `commission_id` is a bonus, its `grant_id` a
reward that is not cash (`reward_type`), and only one of the two is set. A waitlist entry's
`position` is its place once it confirmed, and `position_changed` is sent at most once an hour
per entry, so read the line itself with `GET /v1/referrals/programs/{id}/entries` when you need
it exact.

## Verifying a delivery

Every request carries three headers:

```
X-BoltClaw-Timestamp: 1772629375
X-BoltClaw-Signature: sha256=<hex>
X-BoltClaw-Delivery:  <uuid>
```

The signature is HMAC-SHA256, keyed with your signing secret, over the exact
string `{timestamp}.{raw body}` — the raw bytes, before any JSON parsing.

```python
import hmac, hashlib

expected = hmac.new(
    secret.encode(),
    f"{timestamp}.{raw_body}".encode(),
    hashlib.sha256,
).hexdigest()

if not hmac.compare_digest(expected, signature.removeprefix("sha256=")):
    return 401
```

The same thing in Node, and this exact snippet is executed by our own test
suite against the function that signs your deliveries, so it cannot drift from
what we send:

```js
// verify-webhook.js
const crypto = require("node:crypto");

function verifyWebhook(secret, timestamp, rawBody, signature) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

module.exports = { verifyWebhook };
```

Compare in constant time, and reject a timestamp older than **300 seconds**.
We do not enforce that window and cannot: the only clock that can usefully
judge a stale delivery is the one it arrived at. What makes the check work at
all is that the timestamp is inside the signed string, so an attacker who moves
it invalidates the signature in doing so.

`X-BoltClaw-Event` and `X-BoltClaw-Attempt` come with every delivery too: the
event name, so you can route without parsing the body, and which try this is,
so a log can tell a retry from a second occurrence without keeping state.

**The signing secret is shown once**, when you register the endpoint and again
when you rotate it. There is no endpoint that will show it a second time.

**Send yourself a test event** — answers `202`

`202`: queued, not delivered. The point of it is to prove your signature check works before a real event finds out that it does not.

```bash
curl -X POST https://api.boltclaw.ai/v1/webhooks/7b3e1d64-25af-4c90-8e17-6d0b5a92c431/test \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```js
const res = await fetch("https://api.boltclaw.ai/v1/webhooks/7b3e1d64-25af-4c90-8e17-6d0b5a92c431/test", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({}),
});

// 202
const data = await res.json();
```

**Read what happened to it** — answers `200`

One row per attempt, with the status we got back. Not paged: an endpoint's recent attempts are a short list by construction.

```bash
curl "https://api.boltclaw.ai/v1/webhooks/7b3e1d64-25af-4c90-8e17-6d0b5a92c431/deliveries?limit=20" \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```js
const res = await fetch("https://api.boltclaw.ai/v1/webhooks/7b3e1d64-25af-4c90-8e17-6d0b5a92c431/deliveries?limit=20", {
  headers: {
    Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`,
  },
});

// 200
const data = await res.json();
```

## Rotating a signing secret

`POST /v1/webhooks/{id}/rotate-secret` replaces it and returns the new value
once. The endpoint keeps its id, its URL and its subscriptions.

**There is no grace window, and that is deliberate.** Rotating an API key
leaves the old one working for a while, because many systems hold it and we are
the ones verifying it. A signing secret is verified by one receiver you control,
so a window in which two secrets both validate is a window in which a leaked
secret still validates, which is the only reason anybody rotates one in a
hurry. The old secret stops at once: paste the new one in, and the deliveries
in between fail their signature check and are retried for about twelve hours.

**Rotate the signing secret** — answers `200`

The new secret is shown once. Deliveries signed with the old one stop at once, so change your receiver first or accept both for a moment.

```bash
curl -X POST https://api.boltclaw.ai/v1/webhooks/7b3e1d64-25af-4c90-8e17-6d0b5a92c431/rotate-secret \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5b8f6e2c-1d4a-4f7b-9c3e-8a2d6f0b4e17" \
  -d '{}'
```

```js
const res = await fetch("https://api.boltclaw.ai/v1/webhooks/7b3e1d64-25af-4c90-8e17-6d0b5a92c431/rotate-secret", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({}),
});

// 200
const data = await res.json();
```

## Testing a receiver

`POST /v1/webhooks/{id}/test` queues a `webhook.test` delivery to that one
endpoint, signed exactly as a real event is:

```json
{
  "event": "webhook.test",
  "webhook_id": "7a1c3e5f-9b2d-4a6c-8e0f-1b3d5f7a9c2e",
  "occurred_at": "2026-10-18T11:04:02.771903+00:00"
}
```

It is sent whatever the endpoint has subscribed to and whatever pages it
covers: a test you have to subscribe to first cannot tell you whether your
subscription works. Use it to prove your signature check passes **before** a
real event arrives to tell you it does not.

**Test events.** A referral program's test data (a test sale, a conversion sent
with `"test": true`, a test-mode or sandbox event from a billing system) raises
the same events as live data, but only for endpoints that asked: set
`test_events` to `true` on `POST /v1/webhooks` or `PATCH /v1/webhooks/{id}` (or
switch **Also send test events** on in the dashboard). A test delivery's body is
the same identifiers plus `"test": true`; a live delivery never carries that
field, so a receiver can tell the two apart. `test_events` is `false` by default,
so an endpoint that never asked receives nothing a test causes.

## Retries, and being switched off

Respond `2xx` within 5 seconds. Anything else — a non-2xx status, a timeout, a
connection failure — is a failure and is retried with backoff over roughly 12
hours, then abandoned.

**Answer quickly and do the work afterwards.** A handler that fetches the
messages, writes to a database and posts to Slack before responding is a
handler that times out under load. Acknowledge, then process.

After 20 consecutive failures the endpoint is **disabled** and we email
everyone who manages webhooks in that workspace. Nothing is lost when that
happens: every conversation is still in Bolt Claw and still in this API. Fix
the endpoint, re-enable it with `PATCH /v1/webhooks/{id}` and `enabled: true`
(which also clears the failure count), and back-fill with
`GET /v1/inquiries?since=…`.

**Pause rather than delete while you are working on a receiver.**
`enabled: false` stops the events and keeps the endpoint, its subscriptions and
its signing secret; deleting the endpoint destroys the secret your other
environments are still using. Events that happen while an endpoint is off are
not queued and are not replayed.

**At most one redirect is followed**, by us rather than by the HTTP client, as
a POST with the same body and the same signature, and only if the target
passes the same checks the original URL did. So an ordinary
`hooks.example.com` to `www.hooks.example.com` setup works, and a `302` to an
address we would never deliver to does not.

The URL must be `https` and must resolve to a public host: loopback, private
ranges and bare IP addresses are refused, both when you register the endpoint
and again at every delivery.

<!-- https://boltclaw.ai/docs/webhooks as markdown. Generated from openapi.yaml; API version 1.22.0. -->
<!-- Every /docs page answers as markdown: add .md to its path, or send Accept: text/markdown. -->
