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

# Webhooks

> Get replies, delivery updates and other events pushed to your server as they happen.

Webhooks tell your server when something happens: a contact replies, a message is delivered or read, a message fails, someone reacts, or a contact is tagged. Blujay sends a signed `POST` with a JSON body to your HTTPS endpoint.

## Set up an endpoint

### 1. Build a receiver

Your endpoint must:

1. accept `POST` requests over public HTTPS;
2. [verify the signature](/webhooks/verify-signatures.md) using the raw request body;
3. store the event (deduplicating by `event_id`) and return any `2xx` within 10 seconds;
4. do slow work afterwards, from your own queue.

### 2. Create a subscription

```bash theme={null}
curl https://api.blujay.io/v1/webhook-subscriptions \
  -H "Authorization: Bearer blu_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.example.com/webhooks/blujay",
    "event_types": ["message.received", "message.sent", "message.delivered", "message.read", "message.failed"]
  }'
```

```json Response (201) theme={null}
{
  "status": "success",
  "data": {
    "subscription_id": "whsub_abc123",
    "url": "https://yourapp.example.com/webhooks/blujay",
    "event_types": ["message.received", "message.sent", "message.delivered", "message.read", "message.failed"],
    "webhook_version": "2026-10-03",
    "status": "active",
    "phone_number_filter": { "mode": "all" },
    "signing_secret_hint": "Y3OA==",
    "signing_secret": "whsec_ZXhhbXBsZS1zZWNyZXQtZG8tbm90LXVzZS0xMjM0NTY3OA==",
    "created_at": "2026-10-14T17:00:00.000Z",
    "updated_at": "2026-10-14T17:00:00.000Z"
  },
  "trace_id": "trc_7e1c4b2a9f03",
  "timestamp": "2026-10-14T17:00:00.000Z"
}
```

> [!WARNING]
>
> `signing_secret` is shown **only once**, in this response. Save it in your secret manager now. Later reads return only `signing_secret_hint`.

A subscription you create with the API starts `active` unless you send `"status": "disabled"`; a subscription you create in [Blujay](https://app.blujay.io) under **API** → **Webhooks** starts switched off until you turn on delivery there. Blujay records an event only for subscriptions that are active when it happens, so nothing from a disabled period is stored, and nothing from it can be replayed later.

### 3. Send a test message

There is no test event. Send a real message to your own phone with `POST /v1/chats` and reply to it. With the event list above you receive `message.sent`, then `message.delivered` on iMessage, then `message.received` when you reply.

## The event payload

Every event has the same envelope. Event-specific fields are in `data`:

```json theme={null}
{
  "api_version": "v1",
  "webhook_version": "2026-10-03",
  "event_type": "message.received",
  "event_id": "evt_abc123",
  "created_at": "2026-10-14T17:05:00.120Z",
  "trace_id": "trc_0f4e8a2b6c1d",
  "workspace_id": "workspace_abc123",
  "subscription_id": "whsub_abc123",
  "data": {
    "message_id": "msg_def456",
    "chat_id": "chat_abc123",
    "phone_number_id": "phone_abc123",
    "contact_id": "contact_abc123",
    "text": "Sounds good, see you then!",
    "status": "received",
    "direction": "inbound",
    "requested_service": null,
    "service": "imessage",
    "sent_at": null,
    "delivered_at": null,
    "read_at": null,
    "failed_at": null,
    "failure": null,
    "degradations": [],
    "parts": [{ "type": "text", "text": "Sounds good, see you then!" }],
    "reply_to": null
  }
}
```

| Field | Meaning |
| - | - |
| `event_id` | Unique ID for this event. Same value as the `webhook-id` header. Use it to deduplicate. |
| `event_type` | What happened. See [Events](/webhooks/events.md). |
| `webhook_version` | The payload version this subscription is pinned to. See [Versions](/webhooks/versions.md). |
| `created_at` | When Blujay recorded the event. Message events also carry `sent_at`, `delivered_at`, `read_at` and `failed_at`; reaction, typing and group events carry `occurred_at`. See [Timestamps](/webhooks/events.md#timestamps). |
| `trace_id` | Quote it to [support](/support.md) with the `event_id`. |
| `data` | The event's details. |

## Handle events correctly

* **Deduplicate by `event_id`.** Delivery is at least once, so you may receive the same event more than once.
* **Expect any order.** Events can arrive out of order. Compare timestamps (`sent_at`, `delivered_at`, `occurred_at`) and never overwrite newer state with an older event.
* **Reconcile when in doubt.** The event body is a snapshot from when it happened. For the current state, read the resource, for example `GET /v1/messages/{message_id}`.
* **Fetch media with your key.** Media `download_url` values are relative API paths, such as `/v1/attachments/att_abc123/content`. Call them on `https://api.blujay.io` with your API key. Never send your key to a URL from any other host.

For a walkthrough of what arrives when a contact texts you, see [Receive messages](/messaging/receive-messages.md).

## Select phone numbers

By default a subscription covers all your phone numbers, including ones you add later. To limit it to some numbers, set `phone_number_filter`:

```json theme={null}
{ "phone_number_filter": { "mode": "selected", "phone_number_ids": ["phone_abc123", "phone_def456"] } }
```

Use 1 to 100 IDs from `GET /v1/phone-numbers`. A selection stays fixed when you add numbers. Contact events (`contact.tag_added`, `contact.assigned`) aren't tied to a number and always cover the whole workspace.

## Manage subscriptions

| Task | Request |
| - | - |
| List subscriptions | `GET /v1/webhook-subscriptions` |
| Get one subscription | `GET /v1/webhook-subscriptions/{subscription_id}` |
| Change URL, events, numbers or version | `PATCH /v1/webhook-subscriptions/{subscription_id}` |
| Pause delivery | `PATCH` with `{"status": "disabled"}`; resume with `{"status": "active"}` |
| Delete | `DELETE /v1/webhook-subscriptions/{subscription_id}` |
| See delivery attempts | `GET /v1/webhook-subscriptions/{subscription_id}/deliveries` |

While a subscription is disabled, Blujay doesn't record events for it. When you resume it, you receive only new events; the ones you missed are not delivered and can't be replayed. A lost signing secret can't be recovered: create a new subscription, switch your receiver to its secret, then delete the old one.

Creating a subscription doesn't use an `Idempotency-Key`. If a create request times out, list your subscriptions before trying again, so you don't end up with two.

## Where Blujay won't deliver

Blujay checks the URL when you create or update a subscription, and resolves the hostname again before every delivery attempt.

* **Only `https://`.** An `http://` URL, or one with a username, password or `#fragment`, returns `400` [`WEBHOOK_URL_INVALID`](/errors/webhook-url-invalid). A URL over 2048 characters fails validation with `400` [`VALIDATION_ERROR`](/errors/validation-error).
* **Only public addresses.** `localhost`, Docker and cloud metadata hostnames, and any address in a loopback, link-local, private (`10/8`, `172.16/12`, `192.168/16`), carrier-grade NAT, multicast, documentation or reserved range, in IPv4 or IPv6, return `400` [`WEBHOOK_URL_UNSAFE`](/errors/webhook-url-unsafe). A hostname that later starts resolving to such an address fails the attempt with the same code. For local development, use a tunnel that gives you a public HTTPS URL.
* **The hostname must resolve.** Otherwise creating the subscription returns `400` [`WEBHOOK_URL_UNRESOLVABLE`](/errors/webhook-url-unresolvable). A DNS failure during delivery is retried.
* **Redirects.** Blujay follows up to 3 redirects per attempt, sending the same `POST` with the body and signature headers to each address. Every address must pass the same checks. See [Delivery and retries](/webhooks/delivery.md#redirects).
* **A valid certificate.** Blujay verifies your TLS certificate the way a browser does. A self-signed, expired or mismatched certificate fails the attempt with `WEBHOOK_DELIVERY_NETWORK_ERROR`, and the attempt is retried until you fix it.

Your endpoint must respond within 10 seconds with any `2xx` status. The response body is ignored.

## What you don't get

* **No test event.** Send a real message to yourself instead.
* **No source IP range.** Blujay doesn't publish the addresses it sends from, so you can't allowlist by IP. Trust the [signature](/webhooks/verify-signatures.md) instead.
* **No events for opt-outs, contact creation, updates or deletion, or chat creation.** Those event types exist in the API schema but are reserved: subscribing to them returns `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled). See [Reserved event types](/webhooks/events.md#reserved-event-types). To mirror opt-outs, poll `opted_out` on the contact ([Opt-outs](/opt-outs.md)).
* **No `contact_id` on delivery events.** `message.sent`, `message.delivered`, `message.read` and `message.failed` identify the chat, not the person. See [Message lifecycle](/webhooks/events.md#message-lifecycle).
* **No events from a disabled period**, and no replay of them.
* **No secret rotation in place.** To change the secret, create a new subscription and delete the old one.

## Common mistakes

> [!WARNING]
>
> * Answer within 10 seconds. Store the event and return `2xx`; do the real work from your own queue.
> * Verify the signature with the raw request body, byte for byte. A re-serialized body never matches.
> * Deduplicate by `event_id`. The same event can arrive more than once.
> * Never overwrite newer state with an older event. Compare `sent_at`, `delivered_at` and `occurred_at` before you update a record.
> * List subscriptions before you retry a create that timed out. Create is not idempotent.

## Next steps

> **[Verify signatures](/webhooks/verify-signatures.md)**
>
> Confirm every request really came from Blujay.

> **[Events](/webhooks/events.md)**
>
> Every event type and its payload.

> **[Delivery and retries](/webhooks/delivery.md)**
>
> What happens when your endpoint is down.

> **[Receive messages](/messaging/receive-messages.md)**
>
> What arrives when a contact texts you.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.