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.
curl https://api.boltclaw.ai/v1/webhooks/events \
-H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
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.
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"
}'
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
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. |
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
{
"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) |
{
"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, 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.
{
"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"
}
{
"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.appliedfires when somebody applies to (or re-joins) a program. For a program that approves automatically,referral.partner.approvedfollows straight away.referral.partner.approvedfires when an application is approved — automatically, from the dashboard, or byPOST /v1/referrals/partners/{id}/approve. Approving an already approved partner, or lifting a ban, fires nothing.referral.conversion.createdfires for every lead, sale, refund and dispute recorded — reported through this API, or arriving from your Stripe — whether or not anybody referred it. A repeatedevent_id, or a Stripe event delivered twice, fires nothing the second time.referral.commission.createdfires when a lead or sale earns a commission, once for each reward rule the sale's items matched.referral.bonus.createdfires 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 asreferral.commission.created; a hand-paid bonus'sconversion_idis a record of the payment and has no customer.referral.commission.reversedfires when a refund or dispute reaches one;commission_idis the commission as it now stands, or the negative adjustment a refund after payout created. Adjustments you make yourself fire no event.referral.payout.paidfires when a payout is marked paid.
Referrer events
If you show your own users a 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. |
{
"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.
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 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. |
{
"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}.
The risk alert
referral.risk.alert fires when the risk engine 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}) and,
for a leak, the code. Never what the engine saw: an address, a card or an email.
{
"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 or issues invoices, 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.
{
"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"
}
{
"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, 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:
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:
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.
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:
// 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.
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 '{}'
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.
curl "https://api.boltclaw.ai/v1/webhooks/7b3e1d64-25af-4c90-8e17-6d0b5a92c431/deliveries?limit=20" \
-H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
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.
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 '{}'
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:
{
"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.