Webhooks
Signed HTTPS event deliveries: the envelope, the full event catalogue, signature verification, retries and deduplication.
Webhooks push events to your HTTPS endpoint as they happen. They are the preferred way to track delivery and inbound replies; polling is the fallback.
Register your endpoint
Register self-serve with a live key. One endpoint per workspace; posting again replaces it. The signing secret is returned once, so store it immediately.
curl -X POST https://api.bluereacher.com/v1/webhooks \
-H "Authorization: Bearer brk_live_your_key" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-app.com/webhooks/bluereacher" }'{
"registered": true,
"url": "https://your-app.com/webhooks/bluereacher",
"events": "all",
"secret": "shown once; store it now"
}GET /v1/webhooks returns your current registration (never the secret); DELETE /v1/webhooks stops delivery.
Test the whole loop in the sandbox
A brk_test_ key can register a webhook too, and you can fire a simulated inbound reply at it, so stop-on-reply and exit conditions are testable before a single real message goes out. Register with your test key, then:
curl -X POST https://api.bluereacher.com/v1/sandbox/inbound \
-H "Authorization: Bearer brk_test_your_key" \
-H "Content-Type: application/json" \
-d '{ "from": "+15551112222", "content": "Yes, Thursday works" }'Blue Reacher signs a message.received event with your registered secret and delivers it to your URL, exactly as production would. In the sandbox, test keys also read /v1/contacts, /v1/capability and /v1/usage with realistic data marked sandbox: true, so reads are testable without a live key.
Envelope
Every delivery is an HTTPS POST with a JSON envelope:
{
"event": "message.received",
"event_id": "9f3c1b7e-2a64-4d8f-b0e5-c7a92d4f8e13",
"timestamp": "2026-08-31T07:41:22.310Z",
"api_version": "2026-08-31",
"data": {
"message_id": "m_313",
"phone_number": "+16465550119",
"contact_id": "c9b41d68-3a2f-4e07-b5d1-6f8e2a9c4d17",
"contact_name": "Dana Reyes",
"content": "Sure, Thursday works",
"direction": "incoming",
"service_type": "iMessage",
"device_id": "8c9a4f2e-1d3b-4a6c-9e8f-2b7c5d1a9e3f",
"device_name": "Line 2",
"media_urls": null
}
}Delivery is at-least-once: event_id is unique per event occurrence and stable across retries of that occurrence, so deduplicate on it.
Signature verification
Two headers ride every delivery:
| Header | Content |
|---|---|
X-BlueReacher-Event | The event name |
X-BlueReacher-Signature | sha256=<HMAC-SHA256(secret, raw_body)> |
Verify by recomputing the HMAC over the raw request bytes with your endpoint's secret, before parsing JSON. Compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(sigHeader: string, rawBody: Buffer, secret: string) {
const expected =
"sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
return (
sigHeader.length === expected.length &&
timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected))
);
}Return any 2xx quickly and do heavy work asynchronously. Delivery uses a 5 second timeout with up to 3 attempts (0s / 1s / 3s backoff), and 3 consecutive failed deliveries auto-disable the endpoint until you re-enable it. Send-test and replay are available from the dashboard delivery log.
Event catalogue
Messages
| Event | Fires |
|---|---|
message.received | An inbound message hit one of your lines. The event that drives reply handling |
message.sent | An outbound message left a line, once per message at send confirmation. For API sends this is usually the definitive event: US SMS and many iMessage sends never produce a delivery receipt, so do not wait for message.delivered to conclude a send went out |
message.delivered | First delivery receipt only, edge-triggered. data.delivered_at carries the receipt timestamp |
message.read | First read receipt, only when the recipient has read receipts enabled |
message.failed | An outbound message failed to deliver |
Reactions and contacts
| Event | Fires |
|---|---|
reaction.received | A contact added or removed a tapback. data: message_id, reaction (love/like/dislike/laugh/emphasize/question), emoji, action (added/removed). Contact reactions only; your own tapbacks never fire it |
contact.created | A new contact was created by an inbound or outbound message |
contact.opted_out | STOP reply or API opt-out. Fires from the single enforcement chokepoint, so every path emits identically |
contact.resubscribed | A genuinely opted-out contact sent a recognized START/UNSTOP phrase. Use it to deliberately undo suppression |
Lines
| Event | Fires |
|---|---|
device.status_changed | A line went online or offline. data: device_id, device_name, previous_status, new_status |
device.health_changed | A connected line cannot actually send. data.state is one of send_outage, setup_incomplete, imessage_collapse (critical), sms_not_configured, outdated_version (warning), with a per-state detail object. Online/offline never arrives under this event, so a line powered down on purpose is not a health incident |
Reliability checklist
- Verify the signature over raw bytes before parsing.
- Deduplicate on
event_id. - Return 2xx in under 5 seconds; queue the real work.
- Treat
message.sentas the definitive send confirmation. - On
contact.opted_out, stop everything for that contact in your own system too.
Campaigns and sequencing
How sequencing works over the API: campaigns are a platform feature, and API-driven sequences are built from drip sends plus webhooks.
OpenAPI spec and Postman
Download the machine-readable API spec, import it into Postman or Insomnia, and generate a typed client in any language.

