# Test webhooks

> Three ways to check your webhook handler before you go live. Send a signed test event to your address, forward your account's real events to localhost with bluebot listen, or open a tunnel to your laptop.

There are three ways to try a handler. Each suits a different stage of building one:

| Way                                                                  | What reaches your handler                                                           | Best for                                                                  |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| [Send a test event](#send-a-test-event)                              | A signed example of any event type, posted to a webhook's real address              | Checking a deployed handler, and every event type, without sending a text |
| [`bluebot listen`](#forward-events-to-localhost-with-bluebot-listen) | Your account's real events, forwarded to `localhost` and signed with a local secret | Writing a handler on your laptop, with no tunnel and no webhook to set up |
| [A tunnel](#use-a-tunnel)                                            | Real deliveries from a real webhook, with its retries and its secret                | The last check before you deploy, end to end                              |

None of them needs a stranger's phone. When an event needs a real text, text yourself.

## Send a test event

Every webhook has **Send a test event** in **Developers → Webhooks**: pick a type and BlueBot posts an example of it to the webhook's address, signed with the webhook's secret like any delivery. The same thing through the API:

```bash
curl -X POST "https://usebluebot.com/api/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a/test" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "message.received"
  }'
```

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

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

const delivery = await client.webhooks.test('wh_1Hq2LxW9cVbN3mKpRtYs0a', {
  type: 'message.received',
});
console.log(delivery);
```

```python
import os
from bluebot import BlueBot

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

delivery = client.webhooks.test("wh_1Hq2LxW9cVbN3mKpRtYs0a", type="message.received")
print(delivery)
```

BlueBot answers once it has tried, with the delivery, so you see what your handler said in the same request:

The answer, `200`:

```json
{
  "id": "dlv_3Lp3VsC7hGqE5tZyKbWn2d",
  "object": "webhook_delivery",
  "webhook_id": "wh_1Hq2LxW9cVbN3mKpRtYs0a",
  "event_id": "evt_3Lp3VsC7hGqE5tZyKbWn2d",
  "event_type": "message.received",
  "test": true,
  "state": "delivered",
  "attempts": 1,
  "last_status": 204,
  "last_error": null,
  "last_duration_ms": 87,
  "next_attempt_at": null,
  "delivered_at": "2026-11-13T19:41:00.000Z",
  "replay_of": null,
  "created_at": "2026-11-13T19:41:00.000Z"
}
```

| You got                                           | It means                                                           | Do next                                                                                           |
| ------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `state: "delivered"` and a `2xx` in `last_status` | Your handler verified the post and answered.                       | Try the other types your handler takes.                                                           |
| `state: "pending"` with `last_status: 400`        | Your handler rejected it, usually the signature.                   | Check the secret and the raw body. [When a check fails](https://usebluebot.com/docs/webhooks/verify#when-a-check-fails) |
| `state: "pending"` with `last_status: 500`        | Your handler threw.                                                | Read your logs. The test is retried like any delivery, so once you fix the handler it arrives.    |
| `state: "pending"` and `last_error` set           | BlueBot couldn't reach the address or got no answer in 10 seconds. | Check the address is public and quick to answer.                                                  |
| `state: "pending"`, `attempts: 0`                 | The webhook is paused, so nothing was sent.                        | Resume it, then test again.                                                                       |

What to know about test events:

- The body is the [event catalog](https://usebluebot.com/docs/webhooks/events)'s example for that type inside the same envelope a real event has. Its ids are the docs' example ids (`msg_1Hq2LxW9cVbN3mKpRtYs0a`, `ct_1Hq2LxW9cVbN3mKpRtYs0a`), so a handler that looks the record up gets `404 not_found`. Handle that the way you would in production.
- The body says it is a test: it has `"test": true` beside `type`, which a real event never has. Skip writing anything when you see it, or let it run through in a staging copy of your handler. The delivery also shows `test: true` in the deliveries log, and the event never appears in the event feed.
- A test goes to the one webhook you chose, even if it doesn't subscribe to that type.

To try every type your handler takes in one go:

```ts title="TypeScript"
import BlueBot from '@usebluebot/sdk';

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

for (const type of ['message.received', 'message.sent', 'message.failed', 'contact.stopped'] as const) {
  const delivery = await client.webhooks.test(webhookId, { type });
  console.log(type.padEnd(18), delivery.state); // "delivered", or "pending" while it's retried
}
```

```python title="Python"
import os

from bluebot import BlueBot

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

for event_type in ["message.received", "message.sent", "message.failed", "contact.stopped"]:
    delivery = client.webhooks.test(webhook_id, type=event_type)
    print(event_type.ljust(18), delivery.state)  # "delivered", or "pending" while it's retried
```

## Forward events to localhost with bluebot listen

`bluebot listen` lets you write a handler on your laptop with no tunnel and no webhook. It reads your account's event feed every couple of seconds and posts each new event to the address you give it, signed with a secret of its own, exactly as a webhook delivery is.

### Install the CLI and sign in

```bash
npm install -g @usebluebot/cli
bluebot login
```

`bluebot login` asks for an API key. A key with `messages:read` is enough to listen, and `messages:send` lets you text yourself from the CLI too. [CLI](https://usebluebot.com/docs/cli)

### Start listening

```bash
bluebot listen --forward-to http://localhost:3000/bluebot/webhooks
```

```text title="Output"
Listening for events on dana@groomers.example. Press Ctrl+C to stop.
Forwarding to http://localhost:3000/bluebot/webhooks
Signing secret for local testing: whsec_LcL9pQ2wErT7yUiO1aSdF3gH5jK8zX0c
```

Set `BLUEBOT_WEBHOOK_SECRET` in your local environment to the secret it printed. It stays the same each time you run `bluebot listen` on this computer, so you set it once.

### Make something happen

Text your BlueBot number from your phone, or text yourself from the CLI in another terminal:

```bash
bluebot send +15555550123 "Testing my handler"
```

Each event is forwarded as it comes, and the CLI prints what your handler answered:

```text title="Output"
19:41:02  message.queued     evt_6Tn2xL9mQ0cR4bW8kV1sHd  → 204 (12 ms)
19:41:05  message.sent       evt_7Uo3yM0nR1dS5cX9lW2tIe  → 204 (9 ms)
19:41:31  message.received   evt_8Vp4zN1oS2eT6dY0mX3uJf  → 500 (41 ms)
```

Options:

| Option               | What it does                                                                                |
| -------------------- | ------------------------------------------------------------------------------------------- |
| `--forward-to <url>` | Where to post each event. Any address your computer can reach, `http://localhost` included. |
| `--events <types>`   | Only these types, comma-separated: `--events message.received,message.failed`.              |
| `--json`             | Print each event as one line of JSON instead of the summary.                                |

What it doesn't do:

- It forwards only events that happen after it starts.
- It doesn't retry. When your handler errors, it prints the status and moves on; send the event again by making it happen again.
- It doesn't forward test events from **Send a test event**, which never reach the event feed.
- Its secret is for your laptop only. Your deployed handler verifies with the webhook's own secret.

## Use a tunnel

A tunnel gives your laptop a public `https://` address, so a real webhook can deliver to it. Use it for the last check before you deploy, with real deliveries and retries and the webhook's own secret.

### Open a tunnel to your local port

With [ngrok](https://ngrok.com):

```bash
ngrok http 3000
```

Or with Cloudflare's [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/), which needs no account:

```bash
cloudflared tunnel --url http://localhost:3000
```

Each prints an `https://` address, such as `https://example-words.trycloudflare.com`.

### Make a webhook to it

```bash
curl -X POST "https://usebluebot.com/api/v1/webhooks" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: dev-tunnel-2026-11-13" \
  -d '{
    "url": "https://example-words.trycloudflare.com/bluebot/webhooks",
    "events": [
      "message.received",
      "message.sent",
      "message.failed"
    ],
    "description": "Dev tunnel (delete when done)"
  }'
```

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

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

const webhook = await client.webhooks.create({
  url: 'https://example-words.trycloudflare.com/bluebot/webhooks',
  events: ['message.received', 'message.sent', 'message.failed'],
  description: 'Dev tunnel (delete when done)',
}, { idempotencyKey: 'dev-tunnel-2026-11-13' });
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://example-words.trycloudflare.com/bluebot/webhooks",
    events=["message.received", "message.sent", "message.failed"],
    description="Dev tunnel (delete when done)",
    idempotency_key="dev-tunnel-2026-11-13",
)
print(webhook)
```

Put the `secret` from the answer in your local `BLUEBOT_WEBHOOK_SECRET`.

### Send a test event, then a real text

Send a test event to the new webhook, then text your BlueBot number from your phone and watch the real `message.received` arrive.

### Delete the webhook when you're done

```bash
curl -X DELETE "https://usebluebot.com/api/v1/webhooks/wh_2Bf8QzD4kJmA6nXwEuTr1c" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY"
```

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

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

const result = await client.webhooks.delete('wh_2Bf8QzD4kJmA6nXwEuTr1c');
console.log(result);
```

```python
import os
from bluebot import BlueBot

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

result = client.webhooks.delete("wh_2Bf8QzD4kJmA6nXwEuTr1c")
print(result)
```

> **Don't leave a tunnel webhook behind.** A free tunnel's address changes every time you start it. A webhook left pointing at a dead tunnel fails, is retried for 72 hours, then pauses and emails you. Delete it when you stop, and make a new one next time.

## Next

- [Verify signatures](https://usebluebot.com/docs/webhooks/verify): What your handler checks on every post.
- [Event catalog](https://usebluebot.com/docs/webhooks/events): Every event type with a full example.
- [CLI](https://usebluebot.com/docs/cli): Every `bluebot` command.
