Webhooks
BlueBot posts a signed JSON event to your address when a text comes in, a text you sent changes state, or your Mac needs you. Make a webhook, choose its events and answer fast.
A webhook is an address of yours that BlueBot posts to when something happens on your account: somebody texts you, a text you sent is delivered or fails, a person texts STOP, your Mac goes offline. Each post is one event as JSON, signed so you can prove it came from BlueBot.
For example, Dana runs a dog-grooming shop and texts clients from her own number through BlueBot. When Maria replies "Yes, 3 PM works", BlueBot posts a message.received event to Dana's booking system within about a second, with Maria's number, her words and the conversation it belongs to. Dana's system confirms the slot without polling BlueBot for replies.
How a delivery works
- BlueBot sends one
POSTper event to each webhook that asked for that event's type, withContent-Type: application/jsonandUser-Agent: BlueBot-Webhooks/1.0. - Every post is signed the way, with three headers:
webhook-id,webhook-timestampandwebhook-signature. shows how to check them in every language. - A
2xxanswer within 10 seconds means delivered. Anything else, a timeout, or a redirect counts as a failure, and BlueBot tries again for 72 hours. has the schedule. - Most events go out within a second of happening. A delivery that waits behind a retry goes on the next pass, within a minute.
- The address must be
https://on the public internet. BlueBot checks where the name points on every delivery, never posts to a private or loopback address, and never follows a redirect.
Make a webhook
In the dashboard
- Open Developers → Webhooks and press Add webhook.
- Paste your address, add a description you'll recognize later ("Replies to the booking system"), and tick the events you want.
- Press Save. BlueBot shows the signing secret,
whsec_…, once. Copy it into your app's environment asBLUEBOT_WEBHOOK_SECRET. - Press Send a test event to post an example to your address and see what it answered.
With the API
Make one with POST/v1/webhooks. The key needs the webhooks:manage scope. Send an Idempotency-Key so a retried request never makes two webhooks.
/v1/webhookscurl -X POST "https://usebluebot.com/api/v1/webhooks" \
-H "Authorization: Bearer $BLUEBOT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: booking-system-webhook" \
-d '{
"url": "https://bookings.example.com/bluebot/webhooks",
"events": [
"message.received",
"message.failed",
"contact.stopped"
],
"description": "Replies to the booking system"
}'{
"id": "wh_1Hq2LxW9cVbN3mKpRtYs0a",
"object": "webhook",
"url": "https://bookings.example.com/bluebot/webhooks",
"description": "Replies to the booking system",
"events": [
"message.failed",
"message.received",
"contact.stopped"
],
"api_version": "2026-11-13",
"status": "active",
"paused_at": null,
"pause_reason": null,
"failing_since": null,
"last_success_at": null,
"last_failure_at": null,
"last_status": null,
"previous_secret_expires_at": null,
"created_at": "2026-11-13T19:41:00.000Z",
"secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}| You got | It means | Do next |
|---|---|---|
201 with a secret | The webhook is live. The secret is in this answer and nowhere else. | Store the secret as BLUEBOT_WEBHOOK_SECRET, then . |
400 invalid_request | The address isn't https://, points at a private network, carries a user name or password before the host, or events is empty. details names the field. | Fix the field and send again. |
403 insufficient_scope | The key can't manage webhooks. | Make a key with webhooks:manage. |
409 limit_reached | The account already has 20 webhooks. | Delete one you no longer use, or point several jobs at one address. |
Lost the secret? You can't read it again, but you can make a new one: .
Choose its events
A webhook receives only the types it asked for. Pick by job:
| If you're building | Ask for |
|---|---|
| Replies into a CRM, a helpdesk or Slack | message.received |
| Delivery tracking for texts you send | message.sent, message.delivered, message.failed, message.unconfirmed, message.expired |
| A record of opt-outs | contact.stopped, contact.unstopped |
| An alert when the Mac needs you | device.offline, device.check_failed, device.online |
| An AI agent that answers texts | message.received, and message.failed to know when an answer didn't go |
| A mirror of everything | ["*"] |
["*"] subscribes to every type, including types BlueBot adds later: the webhook answers "all_events": true, and its events lists every type there is now. A webhook with a list of types gets only those, so a new type doesn't reach it until you add it to events.
Every type, with when it fires:
| Event | When it fires |
|---|---|
| A text was accepted and is waiting for the Mac, or a scheduled text became due. | |
Messages on the Mac showed the text sent. Also fires for a text you typed by hand on your Mac or iPhone, with source: "mac". | |
| iMessage showed the text delivered to the person’s device. | |
| The person read the text. Only for people who share read receipts. | |
| Messages couldn’t send the text, or it was an iMessage-only text to somebody not on iMessage. | |
| The Mac took the text and didn’t say whether it went before its 3-minute lease ran out. | |
The text was still waiting when its expires_at passed, so it will never go. | |
| A queued or scheduled text was canceled: by you, because the person is on your stop list, or when its sequence or broadcast stopped. | |
| Somebody texted you. Pictures and voice memos are on the message as files. | |
| A text was edited in Messages, by you or by the other person. | |
| A text was unsent in Messages. Its words are gone from BlueBot too. | |
| Somebody added a tapback (love, like, laugh and so on) to a text, or you did from your Mac or iPhone. | |
| A tapback was taken back. | |
| The Mac saw a chat BlueBot didn’t know: a new group, or a first text with somebody. | |
| A person was added: by a send to somebody new, a text from somebody new, an import, the API or the dashboard. | |
| A person went on your stop list. Their queued texts were canceled. | |
| A person came off your stop list: they texted START, or you took them off. | |
| A person reached the end of a sequence, or it stopped early because they replied or stopped. | |
| Every text in a broadcast has settled: sent, failed, skipped, expired or canceled. | |
| An incoming text matched a keyword rule that has the “Send a webhook event” action. | |
| The Mac checked in after being offline. Held texts start going. | |
The Mac hasn’t checked in for 2 minutes. Texts wait with hold.reason: "device_offline". | |
One of the Mac’s setup checks turned needs_you, such as Messages signing out or Full Disk Access being turned off. |
The has a full example of each.
What a delivery looks like
Each delivery is a POST with these headers:
POST /bluebot/webhooks HTTP/1.1
Host: bookings.example.com
Content-Type: application/json
User-Agent: BlueBot-Webhooks/1.0
webhook-id: evt_1Hq2LxW9cVbN3mKpRtYs0a
webhook-timestamp: 1763062940
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=And a body like this one, for Maria's reply:
{
"id": "evt_1Hq2LxW9cVbN3mKpRtYs0a",
"object": "event",
"type": "message.received",
"api_version": "2026-11-13",
"timestamp": "2026-11-13T19:44:12.000Z",
"data": {
"id": "msg_2Bf8QzD4kJmA6nXwEuTr1c",
"object": "message",
"direction": "in",
"state": "received",
"to": "+15555550100",
"from": "+15555550123",
"chat_id": null,
"contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a",
"conversation_id": "conv_1Hq2LxW9cVbN3mKpRtYs0a",
"text": "Perfect, see you there!",
"files": [],
"service": "imessage",
"source": "mac",
"metadata": {},
"received_at": "2026-11-13T19:44:10.000Z",
"created_at": "2026-11-13T19:44:12.000Z"
}
}The body is trimmed here; a real message.received carries every field a message has, as the shows.
| Field | What it is |
|---|---|
id | The event's id, evt_…. It's also the webhook-id header, the same on every retry and replay, so you can skip an event you already handled. |
object | Always "event". |
type | What happened, such as message.received. |
api_version | The payload's shape. A webhook keeps the version it was made with, so a later change to the API never changes what your handler receives. |
timestamp | When it happened, in UTC. |
test | true on a test event from Send a test event or POST /v1/webhooks/{id}/test, whose data is an example with example ids. Absent on every real event. |
data | The record itself, as the API returns it at that moment: the message for message.*, the contact for contact.*, the Mac for device.*. Your handler needs no second request. |
data is the record, not a wrapper around it: Maria's number is event.data.from and her words are event.data.text. On a text you sent, data.metadata is the metadata you sent with it, which is how you match an event to the record in your own system.
A handler
Read the raw body, verify it, then act on the type. This is the whole shape of a good handler:
// app/api/bluebot/webhooks/route.ts (Next.js)
import BlueBot, { WebhookVerificationError } from '@usebluebot/sdk';
const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });
export async function POST(request: Request) {
const payload = await request.text();
let event;
try {
event = client.webhooks.verify(payload, request.headers, process.env.BLUEBOT_WEBHOOK_SECRET!);
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response('Bad signature', { status: 400 });
throw error;
}
switch (event.type) {
case 'message.received':
await saveReply({ eventId: event.id, from: event.data.from, text: event.data.text });
break;
case 'message.failed':
await markUndelivered(event.data.metadata.booking_id, event.data.failure?.reason);
break;
case 'contact.stopped':
await optOut(event.data.handle);
break;
}
return new Response(null, { status: 204 });
}In Python the message's from is from_, since from is a keyword.
Answer fast
Answer with a 2xx as soon as you've checked the signature and saved the event, and do the slow work afterwards. BlueBot waits 10 seconds for an answer and counts a slower one as a failure, then sends the same event again, so a handler that calls a CRM and an AI model before answering gets the same reply twice.
A dependable pattern:
- Verify the signature.
- Write the event to your database or a queue, keyed by
id. If the key is already there, answer200and stop. - Answer
204. - Process the event from the queue.
Answer 200, 202 or 204. BlueBot ignores what your answer's body says.
Webhook status
A webhook's status says how its deliveries are going:
status | It means |
|---|---|
active | The last delivery succeeded, or none has been tried yet. |
failing | Deliveries have failed since failing_since. BlueBot keeps retrying each one for 72 hours. |
paused | Nothing is posted. pause_reason says why: failing (no 2xx for 72 hours), customer (you paused it) or staff. |
When a webhook goes 72 hours with no successful answer, BlueBot pauses it and emails you once. Events keep being recorded while it's paused. Fix the address, then resume it from Developers → Webhooks or with PATCH /v1/webhooks/{id}; BlueBot sends what waited from the last 72 hours.
/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0acurl -X PATCH "https://usebluebot.com/api/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a" \
-H "Authorization: Bearer $BLUEBOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"paused": false
}'To stop deliveries for a while yourself, send "paused": true.
Limits and housekeeping
- An account can have 20 webhooks. Each can take any number of event types.
- Revoking an API key deletes the webhooks a program made with that key, so an integration you disconnect stops receiving events. Webhooks made in the dashboard stay.
- Deleting a webhook drops the deliveries still waiting for it.
- Deliveries are listed for 30 days, and events are kept in the event feed for 30 days.
Not able to take webhooks?
A script behind a firewall can poll the event feed instead. /v1/events lists the last 30 days of events, newest first, in the same envelope a webhook receives. Ask for the types you want and the time you last read up to:
/v1/eventscurl "https://usebluebot.com/api/v1/events?type=message.received&since=2026-11-13T19%3A00%3A00Z" \
-H "Authorization: Bearer $BLUEBOT_API_KEY"The feed is also how you catch up after an outage on your side: read everything since your last handled event, and skip the ids you already have.