# 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](https://www.standardwebhooks.com) way, with three headers: `webhook-id`, `webhook-timestamp` and `webhook-signature`. [Verify signatures](https://usebluebot.com/docs/webhooks/verify) 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](https://usebluebot.com/docs/webhooks/retries) 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.

```bash
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"
  }'
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const webhook = await client.webhooks.create({
  url: 'https://bookings.example.com/bluebot/webhooks',
  events: ['message.received', 'message.failed', 'contact.stopped'],
  description: 'Replies to the booking system',
}, { idempotencyKey: 'booking-system-webhook' });
console.log(webhook);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

webhook = client.webhooks.create(
    url="https://bookings.example.com/bluebot/webhooks",
    events=["message.received", "message.failed", "contact.stopped"],
    description="Replies to the booking system",
    idempotency_key="booking-system-webhook",
)
print(webhook)
```

The answer, `201`:

```json
{
  "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 [send a test event](https://usebluebot.com/docs/webhooks/testing).                 |
| `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. [invalid_request](https://usebluebot.com/docs/errors/invalid_request)                                   |
| `403 insufficient_scope` | The key can't manage webhooks.                                                                                                                               | Make a key with `webhooks:manage`. [API keys and scopes](https://usebluebot.com/docs/concepts/api-keys)                               |
| `409 limit_reached`      | The account already has 20 webhooks.                                                                                                                         | Delete one you no longer use, or point several jobs at one address. [limit_reached](https://usebluebot.com/docs/errors/limit_reached) |

Lost the secret? You can't read it again, but you can make a new one: [Rotate secrets](https://usebluebot.com/docs/webhooks/rotate-secrets).

## 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                                                                                                                        |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| [`message.queued`](https://usebluebot.com/docs/webhooks/events#messagequeued)           | A text was accepted and is waiting for the Mac, or a scheduled text became due.                                                      |
| [`message.sent`](https://usebluebot.com/docs/webhooks/events#messagesent)               | 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"`.       |
| [`message.delivered`](https://usebluebot.com/docs/webhooks/events#messagedelivered)     | iMessage showed the text delivered to the person’s device.                                                                           |
| [`message.read`](https://usebluebot.com/docs/webhooks/events#messageread)               | The person read the text. Only for people who share read receipts.                                                                   |
| [`message.failed`](https://usebluebot.com/docs/webhooks/events#messagefailed)           | Messages couldn’t send the text, or it was an iMessage-only text to somebody not on iMessage.                                        |
| [`message.unconfirmed`](https://usebluebot.com/docs/webhooks/events#messageunconfirmed) | The Mac took the text and didn’t say whether it went before its 3-minute lease ran out.                                              |
| [`message.expired`](https://usebluebot.com/docs/webhooks/events#messageexpired)         | The text was still waiting when its `expires_at` passed, so it will never go.                                                        |
| [`message.canceled`](https://usebluebot.com/docs/webhooks/events#messagecanceled)       | A queued or scheduled text was canceled: by you, because the person is on your stop list, or when its sequence or broadcast stopped. |
| [`message.received`](https://usebluebot.com/docs/webhooks/events#messagereceived)       | Somebody texted you. Pictures and voice memos are on the message as files.                                                           |
| [`message.edited`](https://usebluebot.com/docs/webhooks/events#messageedited)           | A text was edited in Messages, by you or by the other person.                                                                        |
| [`message.unsent`](https://usebluebot.com/docs/webhooks/events#messageunsent)           | A text was unsent in Messages. Its words are gone from BlueBot too.                                                                  |
| [`reaction.added`](https://usebluebot.com/docs/webhooks/events#reactionadded)           | Somebody added a tapback (love, like, laugh and so on) to a text, or you did from your Mac or iPhone.                                |
| [`reaction.removed`](https://usebluebot.com/docs/webhooks/events#reactionremoved)       | A tapback was taken back.                                                                                                            |
| [`chat.created`](https://usebluebot.com/docs/webhooks/events#chatcreated)               | The Mac saw a chat BlueBot didn’t know: a new group, or a first text with somebody.                                                  |
| [`contact.created`](https://usebluebot.com/docs/webhooks/events#contactcreated)         | A person was added: by a send to somebody new, a text from somebody new, an import, the API or the dashboard.                        |
| [`contact.stopped`](https://usebluebot.com/docs/webhooks/events#contactstopped)         | A person went on your stop list. Their queued texts were canceled.                                                                   |
| [`contact.unstopped`](https://usebluebot.com/docs/webhooks/events#contactunstopped)     | A person came off your stop list: they texted START, or you took them off.                                                           |
| [`sequence.completed`](https://usebluebot.com/docs/webhooks/events#sequencecompleted)   | A person reached the end of a sequence, or it stopped early because they replied or stopped.                                         |
| [`broadcast.completed`](https://usebluebot.com/docs/webhooks/events#broadcastcompleted) | Every text in a broadcast has settled: sent, failed, skipped, expired or canceled.                                                   |
| [`automation.matched`](https://usebluebot.com/docs/webhooks/events#automationmatched)   | An incoming text matched a keyword rule that has the “Send a webhook event” action.                                                  |
| [`device.online`](https://usebluebot.com/docs/webhooks/events#deviceonline)             | The Mac checked in after being offline. Held texts start going.                                                                      |
| [`device.offline`](https://usebluebot.com/docs/webhooks/events#deviceoffline)           | The Mac hasn’t checked in for 2 minutes. Texts wait with `hold.reason: "device_offline"`.                                            |
| [`device.check_failed`](https://usebluebot.com/docs/webhooks/events#devicecheck_failed) | One of the Mac’s setup checks turned `needs_you`, such as Messages signing out or Full Disk Access being turned off.                 |

The [event catalog](https://usebluebot.com/docs/webhooks/events) has a full example of each.

## What a delivery looks like

Each delivery is a `POST` with these headers:

```text title="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:

```json title="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](https://usebluebot.com/docs/webhooks/events#messagereceived) 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. [Versioning](https://usebluebot.com/docs/concepts/versioning)                    |
| `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. [Test webhooks](https://usebluebot.com/docs/webhooks/testing) |
| `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. [Metadata](https://usebluebot.com/docs/concepts/metadata)

## A handler

Read the raw body, verify it, then act on the type. This is the whole shape of a good handler:

```ts title="TypeScript"
// 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 });
}
```

```python title="Python"
# FastAPI
import os

from fastapi import FastAPI, HTTPException, Request, Response
from bluebot import BlueBot, WebhookVerificationError

app = FastAPI()
client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

@app.post("/bluebot/webhooks")
async def bluebot_webhook(request: Request):
    payload = await request.body()
    try:
        event = client.webhooks.verify(payload, request.headers, os.environ["BLUEBOT_WEBHOOK_SECRET"])
    except WebhookVerificationError:
        raise HTTPException(status_code=400, detail="Bad signature")

    if event.type == "message.received":
        save_reply(event_id=event.id, sender=event.data.from_, text=event.data.text)
    elif event.type == "message.failed":
        mark_undelivered(event.data.metadata.get("booking_id"), event.data.failure.reason)
    elif event.type == "contact.stopped":
        opt_out(event.data.handle)
    return Response(status_code=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.

> **Answer 410 to delete a webhook.** Answer `410 Gone` when an address will never take events again, and BlueBot deletes the webhook instead of retrying. Zapier does this when a Zap is turned off.

## 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.

```bash
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
  }'
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const webhook = await client.webhooks.update('wh_1Hq2LxW9cVbN3mKpRtYs0a', {
  paused: false,
});
console.log(webhook);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

webhook = client.webhooks.update("wh_1Hq2LxW9cVbN3mKpRtYs0a", paused=False)
print(webhook)
```

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:

```bash
curl "https://usebluebot.com/api/v1/events?type=message.received&since=2026-11-13T19%3A00%3A00Z" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY"
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const page = await client.events.list({
  type: 'message.received',
  since: '2026-11-13T19:00:00Z',
});
console.log(page.data);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

page = client.events.list(type="message.received", since="2026-11-13T19:00:00Z")
print(page.data)
```

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

- [Verify signatures](https://usebluebot.com/docs/webhooks/verify): Check that each post came from BlueBot, with examples in every language.
- [Event catalog](https://usebluebot.com/docs/webhooks/events): Every event type with a full example payload.
- [Retries and the deliveries log](https://usebluebot.com/docs/webhooks/retries): The retry schedule, duplicates and ordering.
- [Test webhooks](https://usebluebot.com/docs/webhooks/testing): Send a test event, use `bluebot listen`, or open a tunnel.
