> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bluereacher.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Create, manage, and verify BlueReacher webhooks, including the full event catalog, payload shapes, HMAC-SHA256 signature verification, and retry behavior.

Webhooks push message and account activity to your server as it happens. Register an HTTPS endpoint, subscribe to the events you care about, and BlueReacher sends a signed JSON POST for every one of them.

Use webhooks for inbound replies, delivery status, opt-outs, line status, and campaign completion. Polling is not required for any of these.

## Base URL and auth

All webhook management endpoints live under:

```
https://api.bluereacher.com/functions/v1/
```

Every request needs a live API key:

```
Authorization: Bearer bluereacher_your_api_key
Content-Type: application/json
```

Keys are account scoped. A webhook belongs to the account that created it, and it only receives events for that account.

## Endpoints

| Method | Path              | Purpose                                                |
| ------ | ----------------- | ------------------------------------------------------ |
| POST   | `/create-webhook` | Register an endpoint and get its signing secret        |
| GET    | `/list-webhooks`  | List webhooks with 24 hour delivery health             |
| POST   | `/update-webhook` | Change url, events, name, status, or rotate the secret |
| DELETE | `/delete-webhook` | Remove a webhook permanently                           |
| POST   | `/test-webhook`   | Send a signed sample event and see the raw result      |

### Create a webhook

Registers an HTTPS endpoint and returns its signing secret. The secret is shown in full once, in this response. Store it before you close the connection.

```
POST https://api.bluereacher.com/functions/v1/create-webhook
```

**Auth:** `Authorization: Bearer bluereacher_your_api_key`. The webhook is created on the account tied to the key.

**Request fields**

| Field     | Type             | Required | Description                                                                                                                                                    |
| --------- | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url`     | string           | Yes      | HTTPS endpoint that receives deliveries. Port 443, publicly resolvable, valid certificate from a public CA. Max 2048 characters.                               |
| `events`  | array of strings | Yes      | Events to subscribe to. Values come from the [event catalog](#event-catalog). Pass `["*"]` to receive every event, including ones added later. Max 10 entries. |
| `name`    | string           | Yes      | Label for the webhook, 1 to 60 characters. Shown in the dashboard and in `list-webhooks`.                                                                      |
| `line_id` | string           | No       | Restrict deliveries to one line. Omit to receive events for every line on the account.                                                                         |
| `status`  | string           | No       | `active` (default) or `paused`. A paused webhook receives no deliveries.                                                                                       |

**Response fields**

| Field            | Type             | Description                                                                       |
| ---------------- | ---------------- | --------------------------------------------------------------------------------- |
| `id`             | string           | Webhook id, prefixed `wh_`. Use it for update, delete, and test calls.            |
| `name`           | string           | Label you sent.                                                                   |
| `url`            | string           | Delivery endpoint.                                                                |
| `events`         | array of strings | Subscribed events, echoed back. `["*"]` stays `["*"]`.                            |
| `line_id`        | string or null   | Line filter, or `null` for all lines.                                             |
| `status`         | string           | `active` or `paused`.                                                             |
| `signing_secret` | string           | HMAC key, prefixed `whsec_`. Returned in full only here and on a secret rotation. |
| `created_at`     | string           | ISO 8601 UTC.                                                                     |
| `updated_at`     | string           | ISO 8601 UTC.                                                                     |

**Example request**

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/create-webhook \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production inbound",
    "url": "https://hooks.yourapp.com/bluereacher",
    "events": ["message.received", "message.delivered", "message.failed", "contact.opted_out"]
  }'
```

**Example response** (`201 Created`)

```json theme={null}
{
  "id": "wh_4c8b1e9f30",
  "name": "Production inbound",
  "url": "https://hooks.yourapp.com/bluereacher",
  "events": ["message.received", "message.delivered", "message.failed", "contact.opted_out"],
  "line_id": null,
  "status": "active",
  "signing_secret": "whsec_9c1f4b7a2d6e8035af41c2b9d7e60513",
  "created_at": "2026-07-24T15:02:11.004Z",
  "updated_at": "2026-07-24T15:02:11.004Z"
}
```

**Error notes**

| Status | `code`                  | Cause                                                                                                                    |
| ------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| 400    | `invalid_request`       | `url`, `events`, or `name` missing or wrong type.                                                                        |
| 400    | `invalid_webhook_url`   | Not HTTPS, unresolvable host, private or loopback address, or a certificate that does not validate.                      |
| 400    | `unknown_event`         | An entry in `events` is not in the catalog. The bad value is returned in `message`.                                      |
| 401    | `invalid_api_key`       | Missing or wrong bearer token.                                                                                           |
| 404    | `line_not_found`        | `line_id` does not belong to your account.                                                                               |
| 409    | `duplicate_webhook`     | An active webhook already covers this `url` and one of these events. The existing `webhook_id` is returned in `message`. |
| 422    | `webhook_limit_reached` | 10 webhooks per account is the cap. Delete one first.                                                                    |
| 429    | `rate_limited`          | More than 30 management calls in a minute. Retry after the `Retry-After` header.                                         |

### List webhooks

Returns every webhook on the account with rolling 24 hour delivery health, so you can see failures without reading your own logs.

```
GET https://api.bluereacher.com/functions/v1/list-webhooks
```

**Auth:** `Authorization: Bearer bluereacher_your_api_key`.

**Request fields** (query string)

| Field     | Type    | Required | Description                                    |
| --------- | ------- | -------- | ---------------------------------------------- |
| `status`  | string  | No       | Filter by `active` or `paused`. Omit for both. |
| `line_id` | string  | No       | Only webhooks filtered to this line.           |
| `limit`   | integer | No       | 1 to 100. Default 50.                          |
| `offset`  | integer | No       | Default 0.                                     |

**Response fields**

| Field                            | Type             | Description                                                                                                        |
| -------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------ |
| `webhooks`                       | array            | Webhook objects, newest first.                                                                                     |
| `webhooks[].id`                  | string           | Webhook id.                                                                                                        |
| `webhooks[].name`                | string           | Label.                                                                                                             |
| `webhooks[].url`                 | string           | Delivery endpoint.                                                                                                 |
| `webhooks[].events`              | array of strings | Subscribed events.                                                                                                 |
| `webhooks[].line_id`             | string or null   | Line filter.                                                                                                       |
| `webhooks[].status`              | string           | `active` or `paused`. `paused` is also set automatically after 50 consecutive failures.                            |
| `webhooks[].signing_secret_hint` | string           | Last four characters of the secret, for matching against what you stored. The full secret is never returned again. |
| `webhooks[].created_at`          | string           | ISO 8601 UTC.                                                                                                      |
| `webhooks[].updated_at`          | string           | ISO 8601 UTC.                                                                                                      |
| `webhooks[].health`              | object           | Rolling 24 hour delivery health.                                                                                   |
| `health.deliveries_24h`          | integer          | Events attempted in the last 24 hours. Retries of the same event count once.                                       |
| `health.succeeded_24h`           | integer          | Events that got a 2xx.                                                                                             |
| `health.failed_24h`              | integer          | Events that exhausted all 7 attempts.                                                                              |
| `health.success_rate_24h`        | number           | `succeeded_24h / deliveries_24h`, two decimals. `1.0` when there was no traffic.                                   |
| `health.avg_response_ms`         | integer          | Mean time to your first byte of response.                                                                          |
| `health.consecutive_failures`    | integer          | Failures in a row right now. Resets to 0 on any 2xx.                                                               |
| `health.last_delivery_at`        | string or null   | ISO 8601 UTC of the most recent attempt.                                                                           |
| `health.last_status_code`        | integer or null  | HTTP status of the most recent attempt. `null` when the connection never completed.                                |
| `health.last_error`              | string or null   | Short reason for the most recent failure.                                                                          |
| `total`                          | integer          | Webhooks matching the filter.                                                                                      |
| `limit`                          | integer          | Echoed.                                                                                                            |
| `offset`                         | integer          | Echoed.                                                                                                            |

**Example request**

```bash theme={null}
curl -X GET "https://api.bluereacher.com/functions/v1/list-webhooks?status=active&limit=50" \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json"
```

**Example response** (`200 OK`)

```json theme={null}
{
  "webhooks": [
    {
      "id": "wh_4c8b1e9f30",
      "name": "Production inbound",
      "url": "https://hooks.yourapp.com/bluereacher",
      "events": ["message.received", "message.delivered", "message.failed", "contact.opted_out"],
      "line_id": null,
      "status": "active",
      "signing_secret_hint": "0513",
      "created_at": "2026-07-24T15:02:11.004Z",
      "updated_at": "2026-07-24T15:02:11.004Z",
      "health": {
        "deliveries_24h": 4128,
        "succeeded_24h": 4126,
        "failed_24h": 2,
        "success_rate_24h": 1.0,
        "avg_response_ms": 142,
        "consecutive_failures": 0,
        "last_delivery_at": "2026-07-24T16:41:09.630Z",
        "last_status_code": 200,
        "last_error": null
      }
    },
    {
      "id": "wh_77aa03be51",
      "name": "Staging mirror",
      "url": "https://staging.yourapp.com/bluereacher",
      "events": ["*"],
      "line_id": "line_4a1c8e",
      "status": "paused",
      "signing_secret_hint": "8fd2",
      "created_at": "2026-06-02T09:14:52.771Z",
      "updated_at": "2026-07-23T22:07:40.118Z",
      "health": {
        "deliveries_24h": 61,
        "succeeded_24h": 11,
        "failed_24h": 50,
        "success_rate_24h": 0.18,
        "avg_response_ms": 10000,
        "consecutive_failures": 50,
        "last_delivery_at": "2026-07-23T22:07:39.902Z",
        "last_status_code": null,
        "last_error": "timeout after 10000ms"
      }
    }
  ],
  "total": 2,
  "limit": 50,
  "offset": 0
}
```

**Error notes**

| Status | `code`            | Cause                                                |
| ------ | ----------------- | ---------------------------------------------------- |
| 400    | `invalid_request` | `limit` outside 1 to 100, or a non integer `offset`. |
| 401    | `invalid_api_key` | Missing or wrong bearer token.                       |
| 429    | `rate_limited`    | More than 30 management calls in a minute.           |

### Update a webhook

Changes any part of a webhook, and rotates the signing secret when you ask for it. Send only the fields you want to change.

```
POST https://api.bluereacher.com/functions/v1/update-webhook
```

**Auth:** `Authorization: Bearer bluereacher_your_api_key`.

**Request fields**

| Field           | Type             | Required | Description                                                                   |
| --------------- | ---------------- | -------- | ----------------------------------------------------------------------------- |
| `webhook_id`    | string           | Yes      | Id from `create-webhook` or `list-webhooks`.                                  |
| `url`           | string           | No       | New HTTPS endpoint. Same rules as create.                                     |
| `events`        | array of strings | No       | Replaces the whole array. It is not merged, so send the full list you want.   |
| `name`          | string           | No       | New label.                                                                    |
| `line_id`       | string or null   | No       | New line filter. Pass `null` to clear it and receive events for all lines.    |
| `status`        | string           | No       | `active` or `paused`. Set `active` to bring an auto paused webhook back.      |
| `rotate_secret` | boolean          | No       | Default `false`. When `true`, a new signing secret is generated and returned. |

**Response fields**

Same object as `create-webhook`, with the update applied. `signing_secret` appears only when `rotate_secret` was `true`.

| Field                         | Type             | Description                                           |
| ----------------------------- | ---------------- | ----------------------------------------------------- |
| `id`                          | string           | Webhook id.                                           |
| `name`                        | string           | Current label.                                        |
| `url`                         | string           | Current endpoint.                                     |
| `events`                      | array of strings | Current subscriptions.                                |
| `line_id`                     | string or null   | Current line filter.                                  |
| `status`                      | string           | `active` or `paused`.                                 |
| `signing_secret`              | string           | New secret, present only on rotation.                 |
| `previous_secret_valid_until` | string           | Present only on rotation. ISO 8601 UTC, 24 hours out. |
| `created_at`                  | string           | ISO 8601 UTC.                                         |
| `updated_at`                  | string           | ISO 8601 UTC.                                         |

Rotation is zero downtime. For 24 hours after a rotation, every delivery carries two signatures in one header, the new secret first:

```
X-BlueReacher-Signature: v1=<new secret signature>,v1=<old secret signature>
```

Accept the delivery if any `v1` value matches. Deploy the new secret inside that window, then the old one stops being sent.

**Example request**

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/update-webhook \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_id": "wh_4c8b1e9f30",
    "events": ["message.received", "message.delivered", "message.read", "message.failed", "message.fallback", "contact.opted_out"],
    "status": "active",
    "rotate_secret": true
  }'
```

**Example response** (`200 OK`)

```json theme={null}
{
  "id": "wh_4c8b1e9f30",
  "name": "Production inbound",
  "url": "https://hooks.yourapp.com/bluereacher",
  "events": ["message.received", "message.delivered", "message.read", "message.failed", "message.fallback", "contact.opted_out"],
  "line_id": null,
  "status": "active",
  "signing_secret": "whsec_2f70b81e5ac94d3608e7f1a5cb2d94e6",
  "previous_secret_valid_until": "2026-07-25T16:55:03.219Z",
  "created_at": "2026-07-24T15:02:11.004Z",
  "updated_at": "2026-07-24T16:55:03.219Z"
}
```

**Error notes**

| Status | `code`                | Cause                                       |
| ------ | --------------------- | ------------------------------------------- |
| 400    | `invalid_request`     | `webhook_id` missing.                       |
| 400    | `no_fields_to_update` | Only `webhook_id` was sent.                 |
| 400    | `invalid_webhook_url` | New `url` fails the create rules.           |
| 400    | `unknown_event`       | An entry in `events` is not in the catalog. |
| 401    | `invalid_api_key`     | Missing or wrong bearer token.              |
| 404    | `webhook_not_found`   | No webhook with that id on this account.    |
| 429    | `rate_limited`        | More than 30 management calls in a minute.  |

### Delete a webhook

Removes a webhook permanently and drops any retries still queued for it. Deleted webhooks are not recoverable, and events that fire afterward are never replayed.

```
DELETE https://api.bluereacher.com/functions/v1/delete-webhook
```

**Auth:** `Authorization: Bearer bluereacher_your_api_key`.

**Request fields**

| Field        | Type   | Required | Description                                                                                                                                           |
| ------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `webhook_id` | string | Yes      | Id of the webhook to delete. Send it in the JSON body, or as a `?webhook_id=` query parameter if your HTTP client strips bodies from DELETE requests. |

**Response fields**

| Field                     | Type    | Description                                    |
| ------------------------- | ------- | ---------------------------------------------- |
| `id`                      | string  | Deleted webhook id.                            |
| `deleted`                 | boolean | Always `true` on a 200.                        |
| `pending_retries_dropped` | integer | Queued retry attempts discarded by the delete. |
| `deleted_at`              | string  | ISO 8601 UTC.                                  |

**Example request**

```bash theme={null}
curl -X DELETE https://api.bluereacher.com/functions/v1/delete-webhook \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"webhook_id": "wh_77aa03be51"}'
```

**Example response** (`200 OK`)

```json theme={null}
{
  "id": "wh_77aa03be51",
  "deleted": true,
  "pending_retries_dropped": 3,
  "deleted_at": "2026-07-24T17:03:26.540Z"
}
```

**Error notes**

| Status | `code`              | Cause                                                               |
| ------ | ------------------- | ------------------------------------------------------------------- |
| 400    | `invalid_request`   | `webhook_id` missing from both body and query string.               |
| 401    | `invalid_api_key`   | Missing or wrong bearer token.                                      |
| 404    | `webhook_not_found` | No webhook with that id on this account, or it was already deleted. |

To stop deliveries without losing the registration, set `status` to `paused` with `update-webhook` instead.

### Send a test delivery

Sends a signed sample event to the webhook's URL right now and returns exactly what your server answered. Use it after every change to a URL, a secret, or a firewall rule.

```
POST https://api.bluereacher.com/functions/v1/test-webhook
```

**Auth:** `Authorization: Bearer bluereacher_your_api_key`.

**Request fields**

| Field        | Type   | Required | Description                                                                                                                     |
| ------------ | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `webhook_id` | string | Yes      | Webhook to test. Works even when its `status` is `paused`.                                                                      |
| `event`      | string | No       | Event to simulate. Default `message.received`. Any catalog value is allowed, including events the webhook is not subscribed to. |

**Response fields**

| Field              | Type            | Description                                                                           |
| ------------------ | --------------- | ------------------------------------------------------------------------------------- |
| `webhook_id`       | string          | Webhook tested.                                                                       |
| `event`            | string          | Event simulated.                                                                      |
| `delivered`        | boolean         | `true` when your server returned 2xx within 10 seconds.                               |
| `status_code`      | integer or null | HTTP status your server returned. `null` when the connection never completed.         |
| `response_time_ms` | integer         | Time to your first byte of response.                                                  |
| `response_body`    | string or null  | First 500 characters of your response body.                                           |
| `error`            | string or null  | Reason the attempt failed, such as `timeout after 10000ms` or `tls handshake failed`. |
| `delivery_id`      | string          | Id sent in `X-BlueReacher-Delivery-Id`.                                               |
| `attempted_at`     | string          | ISO 8601 UTC.                                                                         |
| `payload`          | object          | The exact JSON body that was posted.                                                  |

Test deliveries are signed the same way real ones are, carry `"test": true` in the envelope, are never retried, and never count toward delivery health or the auto pause counter. Sample data uses `msg_test_` and `evt_test_` prefixes so you can filter it out.

**Example request**

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/test-webhook \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"webhook_id": "wh_4c8b1e9f30", "event": "message.received"}'
```

**Example response** (`200 OK`)

```json theme={null}
{
  "webhook_id": "wh_4c8b1e9f30",
  "event": "message.received",
  "delivered": true,
  "status_code": 200,
  "response_time_ms": 138,
  "response_body": "",
  "error": null,
  "delivery_id": "dlv_1f6b90c4aa",
  "attempted_at": "2026-07-24T17:10:44.219Z",
  "payload": {
    "id": "evt_test_5b1c9034",
    "event": "message.received",
    "created_at": "2026-07-24T17:10:44.180Z",
    "test": true,
    "data": {
      "id": "msg_test_84f0b1",
      "from": "+15555550123",
      "to": "+14155550142",
      "content": "This is a BlueReacher test event.",
      "channel": "imessage",
      "external_id": "test_external_id",
      "media_url": null,
      "line_id": "line_4a1c8e",
      "contact_id": "cnt_test_0001",
      "status": "received",
      "created_at": "2026-07-24T17:10:44.180Z"
    }
  }
}
```

A failed test still returns `200 OK` on the API call. Check `delivered`, not the HTTP status of your `test-webhook` request.

**Error notes**

| Status | `code`              | Cause                                                |
| ------ | ------------------- | ---------------------------------------------------- |
| 400    | `invalid_request`   | `webhook_id` missing.                                |
| 400    | `unknown_event`     | `event` is not in the catalog.                       |
| 401    | `invalid_api_key`   | Missing or wrong bearer token.                       |
| 404    | `webhook_not_found` | No webhook with that id on this account.             |
| 429    | `rate_limited`      | More than 10 test deliveries per webhook per minute. |

## Event catalog

Subscribe to any subset, or pass `["*"]` for all of them.

| Event                 | Fires when                                                                                                                         | `data` payload                                                                          |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `message.received`    | A contact sends a message to one of your lines.                                                                                    | Message object                                                                          |
| `message.sent`        | An outbound message leaves your line and is accepted by the channel.                                                               | Message object, `status` is `sent`                                                      |
| `message.delivered`   | Delivery is confirmed on the channel.                                                                                              | Message object, `status` is `delivered`, plus `delivered_at`                            |
| `message.read`        | The recipient opens the message. iMessage only, and only when read receipts are on for that contact.                               | Message object, `status` is `read`, plus `read_at`                                      |
| `message.failed`      | An outbound message will not be delivered and no further attempts will be made.                                                    | Message object, `status` is `failed`, plus `error_code`, `error_message`, `failed_at`   |
| `message.fallback`    | A message requested on one channel went out on another, such as iMessage falling back to SMS.                                      | Message object with the final `channel`, plus `requested_channel` and `fallback_reason` |
| `message.reaction`    | A contact adds or removes a tapback on one of your messages. iMessage only.                                                        | Message object, plus `reaction`, `action`, `target_message_id`                          |
| `contact.opted_out`   | A contact opts out by keyword, by your API call, or by a manual action in the dashboard. That contact is suppressed on every line. | Contact opt out object                                                                  |
| `line.status_changed` | One of your dedicated lines changes status, such as `warming` to `active`.                                                         | Line object                                                                             |
| `campaign.completed`  | A campaign finishes sending its last message.                                                                                      | Campaign object                                                                         |

Events fire once per state change. A single outbound message typically produces `message.sent`, then `message.delivered`, then `message.read`, all carrying the same message `id`.

## Payload reference

Every delivery uses the same envelope. Only `data` changes shape.

**Envelope fields**

| Field        | Type    | Description                                                                              |
| ------------ | ------- | ---------------------------------------------------------------------------------------- |
| `id`         | string  | Event id, prefixed `evt_`. Constant across all retry attempts of the same event.         |
| `event`      | string  | One of the catalog values.                                                               |
| `created_at` | string  | ISO 8601 UTC, millisecond precision, when the event happened. Not when it was delivered. |
| `data`       | object  | Event payload.                                                                           |
| `test`       | boolean | Present and `true` only on deliveries from `/test-webhook`. Absent on real events.       |

**Delivery headers**

| Header                      | Example                  | Notes                                                  |
| --------------------------- | ------------------------ | ------------------------------------------------------ |
| `Content-Type`              | `application/json`       | Always.                                                |
| `User-Agent`                | `BlueReacher-Webhooks/1` | Allow this in any WAF rule.                            |
| `X-BlueReacher-Event`       | `message.received`       | Matches `event` in the body.                           |
| `X-BlueReacher-Webhook-Id`  | `wh_4c8b1e9f30`          | Which webhook this delivery belongs to.                |
| `X-BlueReacher-Delivery-Id` | `dlv_1f6b90c4aa`         | New for every attempt. Never dedupe on this.           |
| `X-BlueReacher-Attempt`     | `1`                      | 1 through 7.                                           |
| `X-BlueReacher-Timestamp`   | `1784911269`             | Unix seconds, part of the signed string.               |
| `X-BlueReacher-Signature`   | `v1=8e4246bb...`         | Comma separated when a secret rotation is in progress. |

### Message object

Used by every `message.*` event.

| Field               | Type           | Description                                                                                                                                                                                                 |
| ------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                | string         | Message id, prefixed `msg_`. Stable for the whole lifecycle of that message, so `sent`, `delivered`, and `read` all carry the same value.                                                                   |
| `from`              | string         | E.164 number that sent the message. On `message.received` and `message.reaction` this is the contact. On outbound events it is your line.                                                                   |
| `to`                | string         | E.164 number that received the message.                                                                                                                                                                     |
| `content`           | string         | Text body. Empty string when the message is media only.                                                                                                                                                     |
| `channel`           | string         | `imessage`, `rcs`, or `sms`. On `message.fallback` this is the channel the message actually went out on.                                                                                                    |
| `external_id`       | string or null | Your own identifier. On outbound events it is the value you sent. On `message.received` it is copied from the most recent outbound message to that contact on the same line, and `null` when there is none. |
| `media_url`         | string or null | URL of the attachment when one is present, otherwise `null`. Inbound attachment URLs expire 24 hours after the event. Download and store what you need.                                                     |
| `line_id`           | string         | Line the message went through.                                                                                                                                                                              |
| `contact_id`        | string         | Contact record, prefixed `cnt_`. Stable across every message with that contact.                                                                                                                             |
| `status`            | string         | `queued`, `sent`, `delivered`, `read`, `failed`, or `received`.                                                                                                                                             |
| `created_at`        | string         | ISO 8601 UTC when the message was created.                                                                                                                                                                  |
| `delivered_at`      | string         | Present on `message.delivered`.                                                                                                                                                                             |
| `read_at`           | string         | Present on `message.read`.                                                                                                                                                                                  |
| `failed_at`         | string         | Present on `message.failed`.                                                                                                                                                                                |
| `error_code`        | string         | Present on `message.failed`: `invalid_number`, `undeliverable`, `opted_out`, `line_unavailable`, or `content_rejected`.                                                                                     |
| `error_message`     | string         | Present on `message.failed`. Plain text explanation.                                                                                                                                                        |
| `requested_channel` | string         | Present on `message.fallback`. The channel you asked for.                                                                                                                                                   |
| `fallback_reason`   | string         | Present on `message.fallback`: `recipient_not_on_imessage`, `no_delivery_confirmation`, or `channel_unavailable`.                                                                                           |
| `reaction`          | string         | Present on `message.reaction`: `love`, `like`, `dislike`, `laugh`, `emphasize`, or `question`.                                                                                                              |
| `action`            | string         | Present on `message.reaction`: `added` or `removed`.                                                                                                                                                        |
| `target_message_id` | string         | Present on `message.reaction`. The `msg_` id the reaction was applied to.                                                                                                                                   |

### message.received

The inbound payload, in full. This is the exact body used in the [worked verification example](#worked-example) below.

```json theme={null}
{
  "id": "evt_01K3XQ4M2PJ7",
  "event": "message.received",
  "created_at": "2026-07-24T16:41:09.482Z",
  "data": {
    "id": "msg_7d2c9f1a4b",
    "from": "+13105557821",
    "to": "+14155550142",
    "content": "Sounds good, send it over",
    "channel": "imessage",
    "external_id": "lead_8823",
    "media_url": null,
    "line_id": "line_4a1c8e",
    "contact_id": "cnt_62b9d0",
    "status": "received",
    "created_at": "2026-07-24T16:41:08.910Z"
  }
}
```

Reply to it with `POST /send-message` using `to` set to `data.from` and `line_id` set to `data.line_id`.

### message.delivered

```json theme={null}
{
  "id": "evt_01K3XQ7B0M42",
  "event": "message.delivered",
  "created_at": "2026-07-24T16:39:52.006Z",
  "data": {
    "id": "msg_5e11c8b072",
    "from": "+14155550142",
    "to": "+13105557821",
    "content": "Morning Dana, want the pricing sheet?",
    "channel": "imessage",
    "external_id": "lead_8823",
    "media_url": null,
    "line_id": "line_4a1c8e",
    "contact_id": "cnt_62b9d0",
    "status": "delivered",
    "created_at": "2026-07-24T16:39:50.441Z",
    "delivered_at": "2026-07-24T16:39:51.987Z"
  }
}
```

### message.failed

```json theme={null}
{
  "id": "evt_01K3XQ9C4T18",
  "event": "message.failed",
  "created_at": "2026-07-24T16:44:03.512Z",
  "data": {
    "id": "msg_b0937fe2c1",
    "from": "+14155550142",
    "to": "+13105550000",
    "content": "Quick question about your Q3 rollout",
    "channel": "sms",
    "external_id": "lead_9012",
    "media_url": null,
    "line_id": "line_4a1c8e",
    "contact_id": "cnt_7f31aa",
    "status": "failed",
    "created_at": "2026-07-24T16:43:58.220Z",
    "failed_at": "2026-07-24T16:44:03.480Z",
    "error_code": "invalid_number",
    "error_message": "The number is not in service."
  }
}
```

### message.fallback

`channel` is what the message went out on. `requested_channel` is what you asked for.

```json theme={null}
{
  "id": "evt_01K3XQB7E9KD",
  "event": "message.fallback",
  "created_at": "2026-07-24T16:46:20.771Z",
  "data": {
    "id": "msg_c4f8a26d55",
    "from": "+14155550142",
    "to": "+12065559930",
    "content": "Here is the link we discussed",
    "channel": "sms",
    "requested_channel": "imessage",
    "fallback_reason": "recipient_not_on_imessage",
    "external_id": "lead_4471",
    "media_url": null,
    "line_id": "line_4a1c8e",
    "contact_id": "cnt_9d02b4",
    "status": "sent",
    "created_at": "2026-07-24T16:46:19.902Z"
  }
}
```

### message.reaction

```json theme={null}
{
  "id": "evt_01K3XQD1H6RM",
  "event": "message.reaction",
  "created_at": "2026-07-24T16:48:31.115Z",
  "data": {
    "id": "msg_9a0e7c2f11",
    "from": "+13105557821",
    "to": "+14155550142",
    "content": "Loved \"Morning Dana, want the pricing sheet?\"",
    "channel": "imessage",
    "external_id": "lead_8823",
    "media_url": null,
    "line_id": "line_4a1c8e",
    "contact_id": "cnt_62b9d0",
    "status": "received",
    "reaction": "love",
    "action": "added",
    "target_message_id": "msg_5e11c8b072",
    "created_at": "2026-07-24T16:48:30.844Z"
  }
}
```

### contact.opted\_out

Opt outs are enforced across every line on your account. Suppress the contact in your own system when this arrives.

| Field         | Type           | Description                                                     |
| ------------- | -------------- | --------------------------------------------------------------- |
| `contact_id`  | string         | Contact record.                                                 |
| `from`        | string         | E.164 number of the contact who opted out.                      |
| `line_id`     | string         | Line the opt out came in on.                                    |
| `external_id` | string or null | Your identifier for the contact, when known.                    |
| `channel`     | string         | `imessage`, `rcs`, or `sms`.                                    |
| `reason`      | string         | `keyword`, `api`, or `manual`.                                  |
| `keyword`     | string or null | Matched keyword when `reason` is `keyword`, such as `STOP`.     |
| `message_id`  | string or null | Message that triggered the opt out, when `reason` is `keyword`. |
| `created_at`  | string         | ISO 8601 UTC.                                                   |

```json theme={null}
{
  "id": "evt_01K3XQF5N2VB",
  "event": "contact.opted_out",
  "created_at": "2026-07-24T16:52:07.309Z",
  "data": {
    "contact_id": "cnt_62b9d0",
    "from": "+13105557821",
    "line_id": "line_4a1c8e",
    "external_id": "lead_8823",
    "channel": "imessage",
    "reason": "keyword",
    "keyword": "STOP",
    "message_id": "msg_ee20c71b93",
    "created_at": "2026-07-24T16:52:07.221Z"
  }
}
```

### line.status\_changed

Your dedicated iMessage lines are managed for you. This event tells you when one changes state so you can pause or reroute sending in your own system.

| Field             | Type   | Description                                                                     |
| ----------------- | ------ | ------------------------------------------------------------------------------- |
| `line_id`         | string | Line that changed.                                                              |
| `from`            | string | The line's E.164 number, the value that appears as `from` on messages it sends. |
| `status`          | string | `warming`, `active`, `paused`, or `suspended`.                                  |
| `previous_status` | string | Status before the change.                                                       |
| `reason`          | string | `warmup_complete`, `manual_pause`, `health_check`, or `account_hold`.           |
| `created_at`      | string | ISO 8601 UTC.                                                                   |

```json theme={null}
{
  "id": "evt_01K3XQH8Q7WC",
  "event": "line.status_changed",
  "created_at": "2026-07-24T09:00:02.640Z",
  "data": {
    "line_id": "line_4a1c8e",
    "from": "+14155550142",
    "status": "active",
    "previous_status": "warming",
    "reason": "warmup_complete",
    "created_at": "2026-07-24T09:00:02.601Z"
  }
}
```

Sending on a `paused` or `suspended` line returns `line_unavailable` on `/send-message`. Sending resumes on its own once the status returns to `active`, and you get another `line.status_changed` when it does.

### campaign.completed

| Field          | Type    | Description                                      |
| -------------- | ------- | ------------------------------------------------ |
| `campaign_id`  | string  | Campaign record, prefixed `cmp_`.                |
| `name`         | string  | Campaign name.                                   |
| `line_id`      | string  | Line the campaign sent from.                     |
| `status`       | string  | `completed`.                                     |
| `total`        | integer | Recipients in the campaign.                      |
| `sent`         | integer | Messages accepted for delivery.                  |
| `failed`       | integer | Messages that ended in `failed`.                 |
| `skipped`      | integer | Recipients skipped, mostly opted out contacts.   |
| `replies`      | integer | Distinct contacts who replied before completion. |
| `started_at`   | string  | ISO 8601 UTC.                                    |
| `completed_at` | string  | ISO 8601 UTC.                                    |

```json theme={null}
{
  "id": "evt_01K3XQK2R5YE",
  "event": "campaign.completed",
  "created_at": "2026-07-24T17:00:00.884Z",
  "data": {
    "campaign_id": "cmp_51d0a3",
    "name": "July reactivation",
    "line_id": "line_4a1c8e",
    "status": "completed",
    "total": 1840,
    "sent": 1817,
    "failed": 14,
    "skipped": 9,
    "replies": 212,
    "started_at": "2026-07-24T14:30:00.112Z",
    "completed_at": "2026-07-24T17:00:00.802Z"
  }
}
```

## Signature verification

Every delivery is signed with HMAC-SHA256 using the webhook's signing secret. Verify it on every request, before you parse or trust anything in the body.

**Steps**

1. Read the raw request body as bytes, before any JSON parsing. The signature is computed over the exact bytes sent.
2. Read `X-BlueReacher-Timestamp`. Reject the request when it is more than 300 seconds away from your own clock, in either direction.
3. Build the signed string: the timestamp, a period, then the raw body. `"{timestamp}.{raw_body}"`.
4. Compute `HMAC-SHA256(signing_secret, signed_string)` and hex encode it.
5. Compare against each `v1=` value in `X-BlueReacher-Signature` using a constant time comparison. Accept when any one matches.

The secret is the raw string including the `whsec_` prefix, with no decoding.

### Node

```javascript theme={null}
const crypto = require("crypto");
const express = require("express");

const app = express();
const SIGNING_SECRET = process.env.BLUEREACHER_SIGNING_SECRET;
const TOLERANCE_SECONDS = 300;

function isValid(rawBody, timestamp, signatureHeader) {
  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("hex");
  const expectedBuf = Buffer.from(expected, "utf8");

  // The header carries two v1 values during a secret rotation.
  return String(signatureHeader || "")
    .split(",")
    .map((part) => part.trim())
    .filter((part) => part.startsWith("v1="))
    .map((part) => Buffer.from(part.slice(3), "utf8"))
    .some((buf) => buf.length === expectedBuf.length && crypto.timingSafeEqual(buf, expectedBuf));
}

// express.raw keeps the body as bytes. express.json would re-serialize it and break the signature.
app.post("/bluereacher", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");

  if (!isValid(rawBody, req.get("X-BlueReacher-Timestamp"), req.get("X-BlueReacher-Signature"))) {
    return res.status(401).end();
  }

  const event = JSON.parse(rawBody);

  res.status(200).end();   // acknowledge inside 10 seconds
  queue.push(event);       // then do the slow work
});

app.listen(3000);
```

### Python

```python theme={null}
import hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)
SIGNING_SECRET = os.environ["BLUEREACHER_SIGNING_SECRET"].encode()
TOLERANCE_SECONDS = 300


def is_valid(raw_body: bytes, timestamp: str, signature_header: str) -> bool:
    try:
        skew = abs(int(time.time()) - int(timestamp))
    except (TypeError, ValueError):
        return False
    if skew > TOLERANCE_SECONDS:
        return False

    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(SIGNING_SECRET, signed, hashlib.sha256).hexdigest()

    # The header carries two v1 values during a secret rotation.
    for part in (signature_header or "").split(","):
        part = part.strip()
        if part.startswith("v1=") and hmac.compare_digest(part[3:], expected):
            return True
    return False


@app.post("/bluereacher")
def bluereacher_webhook():
    raw_body = request.get_data()  # bytes, untouched

    if not is_valid(
        raw_body,
        request.headers.get("X-BlueReacher-Timestamp"),
        request.headers.get("X-BlueReacher-Signature"),
    ):
        return "", 401

    event = request.get_json(force=True)
    enqueue(event)  # hand off, return fast
    return "", 200
```

### Worked example

Run these exact values through your code. If you get the same digest, your verification is correct.

Signing secret:

```
whsec_9c1f4b7a2d6e8035af41c2b9d7e60513
```

Timestamp header, unix seconds for 2026-07-24T16:41:09Z:

```
X-BlueReacher-Timestamp: 1784911269
```

Raw body, 377 bytes, exactly as sent on the wire:

```json theme={null}
{"id":"evt_01K3XQ4M2PJ7","event":"message.received","created_at":"2026-07-24T16:41:09.482Z","data":{"id":"msg_7d2c9f1a4b","from":"+13105557821","to":"+14155550142","content":"Sounds good, send it over","channel":"imessage","external_id":"lead_8823","media_url":null,"line_id":"line_4a1c8e","contact_id":"cnt_62b9d0","status":"received","created_at":"2026-07-24T16:41:08.910Z"}}
```

Signed string, the timestamp, a period, then that body:

```
1784911269.{"id":"evt_01K3XQ4M2PJ7","event":"message.received", ... }
```

Resulting header:

```
X-BlueReacher-Signature: v1=8e4246bb716ac5f4918c9db7aab7c710dc1fad78ffe5743d6190ba7b230cf186
```

Reproduce it from a shell:

```bash theme={null}
BODY='{"id":"evt_01K3XQ4M2PJ7","event":"message.received","created_at":"2026-07-24T16:41:09.482Z","data":{"id":"msg_7d2c9f1a4b","from":"+13105557821","to":"+14155550142","content":"Sounds good, send it over","channel":"imessage","external_id":"lead_8823","media_url":null,"line_id":"line_4a1c8e","contact_id":"cnt_62b9d0","status":"received","created_at":"2026-07-24T16:41:08.910Z"}}'

printf '%s' "1784911269.$BODY" \
  | openssl dgst -sha256 -hmac "whsec_9c1f4b7a2d6e8035af41c2b9d7e60513"
```

Output:

```
8e4246bb716ac5f4918c9db7aab7c710dc1fad78ffe5743d6190ba7b230cf186
```

Note the `printf '%s'` with no trailing newline. A trailing newline changes the bytes and changes the digest, which is the most common reason a hand check fails.

## Endpoint requirements

| Requirement      | Value                                                                                                                                                                 |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Scheme           | HTTPS on port 443, valid certificate from a public CA                                                                                                                 |
| Method accepted  | `POST` with `Content-Type: application/json`                                                                                                                          |
| Success response | Any 2xx, returned within 10 seconds                                                                                                                                   |
| Redirects        | Not followed. A 3xx counts as a failure                                                                                                                               |
| Max request body | 256 KB. Larger media stays behind `media_url`                                                                                                                         |
| Response body    | Ignored. Return an empty 200                                                                                                                                          |
| Concurrency      | Up to 8 deliveries in flight per webhook                                                                                                                              |
| Source addresses | Deliveries come from a fixed set of egress addresses. Email [support@bluereacher.com](mailto:support@bluereacher.com) for the current list if you run an IP allowlist |

Acknowledge first, process second. Write the payload to a queue, return 200, and do database writes, CRM calls, and AI replies outside the request. Slow handlers are the main cause of duplicate deliveries.

## Retry policy

**At-least-once delivery.** An event can arrive more than once. Your handler has to be idempotent. This is the guarantee, not an edge case.

A delivery counts as a success only when your server returns a 2xx within 10 seconds. Timeouts, connection resets, TLS failures, redirects, and any non 2xx status all count as failures.

**Backoff schedule**

| Attempt | Delay after the previous attempt |
| ------- | -------------------------------- |
| 1       | Immediate, as the event fires    |
| 2       | 10 seconds                       |
| 3       | 1 minute                         |
| 4       | 5 minutes                        |
| 5       | 30 minutes                       |
| 6       | 2 hours                          |
| 7       | 6 hours                          |

Each delay carries up to 20 percent random jitter so retries from a burst do not land together. The full window runs about 8 hours 45 minutes. After attempt 7 fails, the event is dropped and counted in `health.failed_24h`. Dropped events are not replayed.

`X-BlueReacher-Attempt` tells you which attempt you are on. `X-BlueReacher-Delivery-Id` is new every attempt. The envelope `id` and `data.id` never change.

**Auto pause.** After 50 consecutive failures, the webhook's `status` flips to `paused` and the account owner gets an email. Events that fire while a webhook is paused are not queued and are not replayed later. Fix your endpoint, confirm with `/test-webhook`, then set `status` back to `active` with `/update-webhook`.

**Ordering is not guaranteed.** A retried `message.sent` can land after `message.delivered` for the same message. Sort by `data.created_at` and treat message status as latest wins, keyed on `data.id`.

### Deduplication

Dedupe on the message id plus the event name:

```
dedupe_key = data.id + ":" + event
```

Both values are constant across every attempt of the same event, so a retry produces the same key. The event name belongs in the key because one message id produces several events across its lifecycle. For non message events, use the envelope `id`, which is also constant across attempts and works as a single column key everywhere.

Store the key with a 7 day TTL. The retry window closes in under 9 hours, so 7 days is a wide margin. On a repeat, return 200 immediately and skip the work. Never dedupe on `X-BlueReacher-Delivery-Id`, since it changes every attempt.

```javascript theme={null}
const key = `${event.data.id}:${event.event}`;
if (await seen.exists(key)) return res.status(200).end();  // already handled
await seen.set(key, 1, { ttlSeconds: 604800 });
```

## Errors

Management endpoints return this shape on every 4xx and 5xx:

```json theme={null}
{
  "error": {
    "code": "invalid_webhook_url",
    "message": "url must use https and resolve to a public address.",
    "field": "url"
  }
}
```

| Field           | Type           | Description                                                            |
| --------------- | -------------- | ---------------------------------------------------------------------- |
| `error.code`    | string         | Stable machine readable code. Branch on this.                          |
| `error.message` | string         | Human readable explanation. Wording can change, so do not match on it. |
| `error.field`   | string or null | Request field that caused the error, when one applies.                 |

| Status | Meaning                                                                              |
| ------ | ------------------------------------------------------------------------------------ |
| 400    | The request is malformed or a field is invalid.                                      |
| 401    | The API key is missing, malformed, or revoked.                                       |
| 403    | The key is valid but lacks access to this webhook or line.                           |
| 404    | The `webhook_id` or `line_id` does not exist on this account.                        |
| 409    | A webhook already covers this url and event pair.                                    |
| 422    | The request is well formed but the account limit blocks it, such as 10 webhooks.     |
| 429    | Rate limited. Wait for the seconds in `Retry-After`.                                 |
| 500    | Something failed on our side. Retry with backoff and contact support if it persists. |

## Troubleshooting

**No deliveries arrive at all.** Work through this in order: call `/list-webhooks` and check `status` is `active`; check `events` actually includes the event you expect; check `line_id` is not filtering out the traffic; check `health.consecutive_failures` and `health.last_error`. Then call `/test-webhook`. It reports the exact status code and error from your server.

**Signatures never match.** Almost always a raw body problem. Your framework parsed the JSON and re-serialized it, which changes key order or whitespace and breaks the digest. Capture the bytes before any body parser touches them: `express.raw()` in Node, `request.get_data()` in Flask, `request.body` before `await request.json()` in FastAPI. Other causes: comparing the full header value including the `v1=` prefix, whitespace or a newline copied into the secret, hashing the body alone instead of `timestamp + "." + body`, or a rotation in progress where the second `v1` value is the one matching your deployed secret.

**Signature checks fail with a stale timestamp.** Your server clock has drifted past the 300 second tolerance. Run NTP. Never widen the tolerance past 300 seconds as a workaround, since that opens a replay window.

**The same message arrives several times.** Expected under at-least-once delivery, and it usually means your handler took longer than 10 seconds, so the attempt was recorded as failed and retried while your code was still working. Acknowledge with a 200 first, process afterward, and dedupe on `data.id + ":" + event`.

**Deliveries stopped and the webhook shows `paused`.** It hit 50 consecutive failures and auto paused. Check `health.last_status_code` and `health.last_error` for the cause. Events during the pause are gone and are not replayed, so backfill with `/list-messages` for the window before you resume.

**Your server returns 403 or 406 and the payload never reaches your code.** A WAF or proxy is filtering the request. Allow the `BlueReacher-Webhooks/1` user agent, allow `POST` with `application/json` on that path, and email [support@bluereacher.com](mailto:support@bluereacher.com) for the current egress address list if you need an IP allowlist.

**Events arrive out of order.** Ordering is not guaranteed, especially after a retry. Sort by `data.created_at` and apply status as latest wins per `data.id`. Do not treat `message.delivered` arriving before `message.sent` as a bug.

**`message.read` never fires.** Read receipts are controlled by the recipient and are off by default for most people. Absence of `message.read` says nothing about delivery. Use `message.delivered` for that.

**`external_id` is `null` on inbound messages.** On `message.received` it is copied from the most recent outbound message to that contact on the same line. Cold inbound messages with no prior outbound have nothing to copy, so it comes through `null`. Match on `contact_id` or `from` in that case.

**`media_url` returns 404 later.** Inbound attachment URLs expire 24 hours after the event. Download the file inside your handler and store it yourself.

**Local development gets `invalid_webhook_url`.** Plain HTTP, `localhost`, and private addresses are rejected at create time. Use a tunneling service that terminates TLS on a public hostname, and point the webhook at that URL.

**Rotating a secret without dropping events.** Call `/update-webhook` with `rotate_secret: true`, deploy the new secret within 24 hours, and keep the multi value signature check shown above. Both signatures are sent during the overlap window, so no delivery fails while you deploy.
