Skip to the page

Webhooks

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 POST per event to each webhook that asked for that event's type, with Content-Type: application/json and User-Agent: BlueBot-Webhooks/1.0.
  • Every post is signed the Standard Webhooks way, with three headers: webhook-id, webhook-timestamp and webhook-signature. Verify signatures shows how to check them in every language.
  • A 2xx answer within 10 seconds means delivered. Anything else, a timeout, or a redirect counts as a failure, and BlueBot tries again for 72 hours. Retries and the deliveries log 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

  1. Open Developers → Webhooks and press Add webhook.
  2. Paste your address, add a description you'll recognize later ("Replies to the booking system"), and tick the events you want.
  3. Press Save. BlueBot shows the signing secret, whsec_…, once. Copy it into your app's environment as BLUEBOT_WEBHOOK_SECRET.
  4. 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.

POST/v1/webhooksMake a webhook →
curl -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"
  }'
Response201
{
  "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 gotIt meansDo next
201 with a secretThe webhook is live. The secret is in this answer and nowhere else.Store the secret as BLUEBOT_WEBHOOK_SECRET, then send a test event.
400 invalid_requestThe 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. invalid_request
403 insufficient_scopeThe key can't manage webhooks.Make a key with webhooks:manage. API keys and scopes
409 limit_reachedThe account already has 20 webhooks.Delete one you no longer use, or point several jobs at one address. limit_reached

Lost the secret? You can't read it again, but you can make a new one: Rotate secrets.

Choose its events

A webhook receives only the types it asked for. Pick by job:

If you're buildingAsk for
Replies into a CRM, a helpdesk or Slackmessage.received
Delivery tracking for texts you sendmessage.sent, message.delivered, message.failed, message.unconfirmed, message.expired
A record of opt-outscontact.stopped, contact.unstopped
An alert when the Mac needs youdevice.offline, device.check_failed, device.online
An AI agent that answers textsmessage.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:

EventWhen it fires
message.queuedA text was accepted and is waiting for the Mac, or a scheduled text became due.
message.sentMessages on the Mac showed the text sent. Also fires for a text you typed by hand on your Mac or iPhone, with source: "mac".
message.deliverediMessage showed the text delivered to the person’s device.
message.readThe person read the text. Only for people who share read receipts.
message.failedMessages couldn’t send the text, or it was an iMessage-only text to somebody not on iMessage.
message.unconfirmedThe Mac took the text and didn’t say whether it went before its 3-minute lease ran out.
message.expiredThe text was still waiting when its expires_at passed, so it will never go.
message.canceledA queued or scheduled text was canceled: by you, because the person is on your stop list, or when its sequence or broadcast stopped.
message.receivedSomebody texted you. Pictures and voice memos are on the message as files.
message.editedA text was edited in Messages, by you or by the other person.
message.unsentA text was unsent in Messages. Its words are gone from BlueBot too.
reaction.addedSomebody added a tapback (love, like, laugh and so on) to a text, or you did from your Mac or iPhone.
reaction.removedA tapback was taken back.
chat.createdThe Mac saw a chat BlueBot didn’t know: a new group, or a first text with somebody.
contact.createdA person was added: by a send to somebody new, a text from somebody new, an import, the API or the dashboard.
contact.stoppedA person went on your stop list. Their queued texts were canceled.
contact.unstoppedA person came off your stop list: they texted START, or you took them off.
sequence.completedA person reached the end of a sequence, or it stopped early because they replied or stopped.
broadcast.completedEvery text in a broadcast has settled: sent, failed, skipped, expired or canceled.
automation.matchedAn incoming text matched a keyword rule that has the “Send a webhook event” action.
device.onlineThe Mac checked in after being offline. Held texts start going.
device.offlineThe Mac hasn’t checked in for 2 minutes. Texts wait with hold.reason: "device_offline".
device.check_failedOne of the Mac’s setup checks turned needs_you, such as Messages signing out or Full Disk Access being turned off.

The event catalog has a full example of each.

What a delivery looks like

Each delivery is a POST with these headers:

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:

message.received
{
  "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 event catalog shows.

FieldWhat it is
idThe 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.
objectAlways "event".
typeWhat happened, such as message.received.
api_versionThe 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. Versioning
timestampWhen it happened, in UTC.
testtrue 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. Test webhooks
dataThe 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. Metadata

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:

  1. Verify the signature.
  2. Write the event to your database or a queue, keyed by id. If the key is already there, answer 200 and stop.
  3. Answer 204.
  4. 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:

statusIt means
activeThe last delivery succeeded, or none has been tried yet.
failingDeliveries have failed since failing_since. BlueBot keeps retrying each one for 72 hours.
pausedNothing 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.

PATCH/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0aChange a webhook →
curl -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. GET/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:

GET/v1/eventsList events →
curl "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.

Next