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

# Webhook Events

> Catalog of webhook event types with example payloads.

Every webhook POST iBubbl sends to your URL has the same envelope shape. The `type` field tells you which event it is; the `data` field has the event-specific payload.

## Envelope

```json theme={null}
{
  "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
  "type": "message.inbound",
  "created_at": "2026-05-17T14:23:45.123Z",
  "data": {
    "...": "event-specific fields"
  }
}
```

| Field | Type | Description |
| - | - | - |
| `id` | string | Unique event ID (`evt_<uuid>`). The same on every retry of the event; use it to de-duplicate retries. |
| `type` | string | One of the event types below. |
| `created_at` | string (ISO 8601 UTC) | When iBubbl recorded the event. |
| `data` | object | Event-specific payload. |

> [!NOTE]
>
> **Ignore event types you don't handle.** iBubbl adds new event types over time, and a subscription without an `event_types` filter receives them as they ship. Return `2xx` for any `type` your integration doesn't recognise.

> [!NOTE]
>
> Webhook payloads identify the contact and line by **phone number** (`from_number`, `to_number`, `contact_phone`), not by iBubbl's internal contact ID. If you need the contact ID, look it up via [`POST /v1/contacts`](https://docs.ibubbl.com/api-reference/contacts/upsert) — the upsert is idempotent and returns the ID.

### The `channel` field

`message.sent`, `message.delivered`, `message.read`, `message.failed`, `message.cancelled` and `message.inbound` carry `channel`: how the message travelled.

| Value | Meaning |
| - | - |
| `imessage` | iMessage. |
| `sms` | A text, including RCS. An outbound message goes out as a text when the recipient can't be reached on iMessage. |
| `null` | Not known. An outbound message that never went out (cancelled, expired, or failed before sending), a send whose channel wasn't reported, or an inbound message received before iBubbl recorded the channel. |

Each event carries the value at the moment it fired; [`GET /v1/messages/{id}`](https://docs.ibubbl.com/api-reference/messages/retrieve) returns the latest. `message.queued` doesn't carry `channel`, because nothing has been sent yet. See [Lines and routing](https://docs.ibubbl.com/concepts/lines-and-routing.md#which-channel-a-message-used).

### The `conversation_id` field

Every `message.*` and `message.reaction.*` event carries `conversation_id`: the conversation the message (or the reacted-to message) is in. It's the same value as `conversation_id` on the [message object](https://docs.ibubbl.com/api-reference/messages/retrieve.md), and it's what [Mark a conversation read](https://docs.ibubbl.com/api-reference/conversations/mark-read.md) and [Start typing](https://docs.ibubbl.com/api-reference/conversations/start-typing.md) take, so you can act on a `message.inbound` without looking anything up first. See [Typing while your AI thinks](https://docs.ibubbl.com/guides/reactions-read-receipts-typing.md#typing-while-your-ai-thinks).

### Ordering and duplicates

* **Events can arrive out of order.** Retries, and events that fire close together, mean `message.read` can reach you before `message.delivered`. Order by the envelope's `created_at` (and treat `status` transitions as described below), not by arrival time.
* **Events can arrive more than once.** Delivery is at-least-once. De-duplicate on the envelope `id`; for the rare case where the same change is recorded twice, also de-duplicate on `data.message_id` plus `type`.
* **Failed deliveries are retried** up to 5 times over about 37 minutes. See [Retries and deduplication](https://docs.ibubbl.com/guides/webhook-retries.md) for the schedule and de-duplication code.
* **A message's status can move "backwards".** `message.failed` can follow `message.sent` (and even `message.read`), and a failed message can later move to `delivered` or `read`. See [`message.failed`](#message-failed). When in doubt, `GET /v1/messages/{id}` returns the current state.

## Event types

> [accordion] message.queued
>
> Fired when a one-to-one message is accepted. That covers every accepted one-to-one send: `status: "pending"` with `failed_reason: null` when it is on its way (including one waiting for the contact's [sending hours](https://docs.ibubbl.com/concepts/working-hours.md)), or `status: "queued"` when it is parked by a capacity hold. Group sends do not fire this event.
>
> ```json theme={null}
> {
>   "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
>   "type": "message.queued",
>   "created_at": "2026-05-17T22:30:00.000Z",
>   "data": {
>     "message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d",
>     "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
>     "status": "queued",
>     "to_number": "+14155551234",
>     "body": "Hey Sam — quick check-in about your order.",
>     "classification": "cold",
>     "line_id": "2c3d4e5f-6a7b-8c9d-0e1f-2a3b4c5d6e7f",
>     "failed_reason": "line_offline"
>   }
> }
> ```
>
> For a `queued` message, `failed_reason` names the capacity hold: today always `line_offline` (`hourly_cap_hit` is a legacy reason no current line uses; see [Limits and queues](https://docs.ibubbl.com/concepts/limits.md)). (Despite the name, this isn't a final failure: queued messages are retried automatically. One still queued 60–65 minutes after it was accepted expires, and [`message.failed`](#message-failed) fires with `status: "expired"` and `failed_reason: "queue_expired"`.) For a `pending` message it is `null`.
>
> A `pending` message is different: if its line has been offline for over an hour, it moves to `failed` and fires [`message.failed`](#message-failed) with `failed_reason: "agent_offline"`.
>
> A `message.sent` event follows when the queued message dispatches, or `message.cancelled` if it was stopped first.
>
> A retried delivery of this event repeats the original payload.

> [accordion] message.sent
>
> Fired when the message has gone out from your iBubbl line.
>
> ```json theme={null}
> {
>   "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
>   "type": "message.sent",
>   "created_at": "2026-05-17T14:23:45.123Z",
>   "data": {
>     "message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d",
>     "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
>     "from_number": "+14155550100",
>     "to_number": "+14155551234",
>     "body": "Hey Sam — quick check-in about your order.",
>     "status": "sent",
>     "occurred_at": "2026-05-17T14:23:45.123Z",
>     "reply_to_message_id": null,
>     "channel": "imessage"
>   }
> }
> ```
>
> `from_number` is the iBubbl line that sent the message; `to_number` is the contact. For a group message, `to_number` is the group's primary contact (the first participant), not the whole group.
>
> `reply_to_message_id` is non-null when this message was sent as a reply (quote) to a previous inbound; otherwise null.
>
> The `message.sent` event for a new group's first message can occasionally be delivered twice; de-duplicate on `data.message_id` plus `type`.

> [accordion] message.delivered
>
> Fired when the recipient's device confirms delivery. Same payload shape as `message.sent`, with `status: "delivered"` and `occurred_at` set to the delivery time.
>
> ```json theme={null}
> {
>   "id": "evt_9c4b5d83-2e3f-5a6b-0c1d-3e4f5a6b7c8d",
>   "type": "message.delivered",
>   "created_at": "2026-05-17T14:23:47.010Z",
>   "data": {
>     "message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d",
>     "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
>     "from_number": "+14155550100",
>     "to_number": "+14155551234",
>     "body": "Hey Sam — quick check-in about your order.",
>     "status": "delivered",
>     "occurred_at": "2026-05-17T14:23:46.900Z",
>     "reply_to_message_id": null,
>     "channel": "imessage"
>   }
> }
> ```
>
> If delivery and read are reported together, you may receive only `message.read`. Delivery receipts aren't available for every message (for example, some SMS), so don't wait on this event to consider a message sent.

> [accordion] message.read
>
> Fired when the recipient reads the message (only when they have read receipts turned on). Same payload shape as `message.sent`, with `status: "read"` and `occurred_at` set to the read time.
>
> ```json theme={null}
> {
>   "id": "evt_0d5c6e94-3f4a-6b7c-1d2e-4f5a6b7c8d9e",
>   "type": "message.read",
>   "created_at": "2026-05-17T14:30:02.215Z",
>   "data": {
>     "message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d",
>     "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
>     "from_number": "+14155550100",
>     "to_number": "+14155551234",
>     "body": "Hey Sam — quick check-in about your order.",
>     "status": "read",
>     "occurred_at": "2026-05-17T14:30:02.100Z",
>     "reply_to_message_id": null,
>     "channel": "imessage"
>   }
> }
> ```
>
> When a contact reads a message, iMessage implies they've read your earlier messages in the conversation too. iBubbl marks those earlier messages read as well, but fires `message.read` only for the message the receipt was for.

> [accordion] message.failed
>
> Fired when a send fails. `failed_reason` carries a stable failure code; `error_message` is a free-form diagnostic for your logs — don't parse it or show it to end users.
>
> It also fires when a `queued` message expires: then `status` is `"expired"` (the message's status in [`GET /v1/messages/{id}`](https://docs.ibubbl.com/api-reference/messages/retrieve)) and `failed_reason` is `queue_expired`. Both are final; branch on `failed_reason`, not on `status`.
>
> `message.failed` can follow a `message.sent` for the same message. That happens when iMessage reports the send failed after it was handed over. It can also follow `message.read` when no delivery was ever confirmed. A message with a confirmed delivery never moves to `failed`.
>
> A failed message can also move back. If Apple later confirms delivery or a read for a message that failed with an `imessage_*` reason, it moves to `delivered` or `read` and you receive that event.
>
> ```json theme={null}
> {
>   "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
>   "type": "message.failed",
>   "created_at": "2026-05-17T14:23:45.123Z",
>   "data": {
>     "message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d",
>     "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
>     "from_number": "+14155550100",
>     "to_number": "+14155551234",
>     "body": "Hey Sam — quick check-in about your order.",
>     "status": "failed",
>     "occurred_at": "2026-05-17T14:23:45.123Z",
>     "reply_to_message_id": null,
>     "channel": "imessage",
>     "failed_reason": "imessage_undeliverable",
>     "error_message": "Message failed to send"
>   }
> }
> ```
>
> `occurred_at` on this event is when the message was sent (or accepted, if it never went out), not when it failed; use the envelope's `created_at` for the failure time.
>
> `failed_reason` values (treat them as opaque codes; new ones may be added):
>
> * `imessage_undeliverable` — iMessage couldn't deliver it to this number: the number isn't reachable on iMessage. Resending fails the same way; reach the contact another way.
> * `imessage_error:<code>` — iMessage reported another failure; `<code>` is its error number
> * `mac_send_failed` — your line couldn't send it, or it wasn't confirmed sent within 10 minutes
> * `bluebubbles_unreachable` — your line couldn't be reached at send time
> * `agent_offline` — your line was offline for over an hour, so the message was failed rather than sent late
> * `line_inactive` — the line was suspended or retired before the message went out
> * `queue_expired` — the message was still `queued` 60–65 minutes after it was accepted, so it was dropped. This one arrives with `status: "expired"`, not `"failed"`, and `channel: null`. Its `idempotency_key` is released, so send it again if it still matters.
> * `no_eligible_line` — a queued message lost the line it was waiting for, so it can't go out
> * `cold_cap_exhausted`, `followup_cap_exhausted`, `daily_cap_exhausted`, `unresponsive_contact` — legacy codes: a queued message failed a limit re-check on a line in the old limits mode. No current line uses that mode (see [Limits and queues](https://docs.ibubbl.com/concepts/limits.md)).
>
> See the [Error Reference](https://docs.ibubbl.com/errors.md) for the full catalog.

> [accordion] message.cancelled
>
> Fired when a message that hadn't been sent yet is stopped in the dashboard: one waiting for the contact's [working hours](https://docs.ibubbl.com/concepts/working-hours.md), or queued by a capacity hold. iBubbl also cancels an [after-hours auto-reply](https://docs.ibubbl.com/concepts/after-hours-reply.md) that couldn't go out within 20 minutes of the text it answers. It will never send. This is terminal, like `message.failed`, but nothing went wrong: the message was withdrawn on purpose.
>
> ```json theme={null}
> {
>   "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
>   "type": "message.cancelled",
>   "created_at": "2026-05-17T14:23:45.123Z",
>   "data": {
>     "message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d",
>     "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
>     "from_number": "+14155550100",
>     "to_number": "+14155551234",
>     "body": "Hey Sam — quick check-in about your order.",
>     "status": "cancelled",
>     "occurred_at": "2026-05-17T14:23:45.123Z",
>     "reply_to_message_id": null,
>     "channel": null
>   }
> }
> ```
>
> `occurred_at` is when it was cancelled. Retrying the original `POST /v1/messages` with the same `idempotency_key` returns this cancelled message; it does not send it again.

> [accordion] message.inbound
>
> Fired when a contact replies to you. This is the event most integrations care about.
>
> ```json theme={null}
> {
>   "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
>   "type": "message.inbound",
>   "created_at": "2026-05-17T14:24:12.456Z",
>   "data": {
>     "message_id": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e",
>     "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
>     "from_number": "+14155551234",
>     "to_number": "+14155550100",
>     "body": "yeah sounds good, what time?",
>     "received_at": "2026-05-17T14:24:12.456Z",
>     "attachments": [],
>     "reply_to_message_id": null,
>     "conversation_kind": "one_to_one",
>     "sender_phone": "+14155551234",
>     "participants": [
>       {"phone": "+14155551234", "first_name": "Sam", "last_name": "Lee"}
>     ],
>     "channel": "imessage"
>   }
> }
> ```
>
> `from_number` is the contact (the sender) and `to_number` is your iBubbl line (the recipient). `from_number` is the sender's address exactly as iMessage reported it: usually E.164, but it can be an Apple ID email address, or a number without the leading `+` for some SMS senders.
>
> `reply_to_message_id` is non-null when the contact is quoting one of your previous messages.
>
> `channel` is how the message arrived: `imessage`, or `sms` for a text (RCS included). It is `null` for messages received before iBubbl started recording it.
>
> ### Attachments
>
> If the message contained attachments (images, GIFs, videos, audio, files), `attachments` lists them, and the event fires once, after every attachment has been fetched. Each entry:
>
> ```json theme={null}
> {
>   "id": "6c7d8e9f-0a1b-2c3d-4e5f-6a7b8c9d0e1f",
>   "kind": "image",
>   "mime_type": "image/jpeg",
>   "url": "https://…signed download URL…",
>   "expires_at": "2026-05-18T14:24:12.456Z",
>   "size_bytes": 204811,
>   "width": 1170,
>   "height": 2532
> }
> ```
>
> * `kind` is one of `image`, `gif`, `video`, `audio`, `file`.
> * `url` is a signed download link that **expires after 24 hours** (`expires_at`). Download the file promptly if you need to keep it. Retries of the same event carry the same URL.
> * An attachment that couldn't be fetched has `"unavailable": true` and no `url`.
> * Attachments of unsupported types are left out of the array.
>
> ### Group inbound
>
> For group conversations (`conversation_kind: "group"`), `sender_phone` identifies which of the group's participants posted the message, and `participants` lists the participants other than your iBubbl line:
>
> ```json theme={null}
> {
>   "id": "evt_9c4b5d83-2e3f-5a6b-0c1d-3e4f5a6b7c8d",
>   "type": "message.inbound",
>   "created_at": "2026-05-17T14:24:12.456Z",
>   "data": {
>     "message_id": "6c7d8e9f-0a1b-2c3d-4e5f-6a7b8c9d0e1f",
>     "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
>     "from_number": "+14155551234",
>     "to_number": "+14155550100",
>     "body": "what time tomorrow?",
>     "received_at": "2026-05-17T14:24:12.456Z",
>     "attachments": [],
>     "reply_to_message_id": null,
>     "conversation_kind": "group",
>     "sender_phone": "+14155551234",
>     "participants": [
>       {"phone": "+14155551234", "first_name": "Sarah", "last_name": "Smith"},
>       {"phone": "+14155557654", "first_name": "Bob",   "last_name": "Jones"}
>     ],
>     "channel": "imessage"
>   }
> }
> ```
>
> `sender_phone` always equals `from_number`. For 1:1 conversations, `participants` holds the one contact. The event doesn't carry the group's `chat_guid`; to reply into the group, call `GET /v1/messages/{message_id}` to get it. See [Group Conversations](https://docs.ibubbl.com/concepts/group-conversations.md) for the full model, including why `participants` can list people who have left.
>
> > [!NOTE]
> >
> > If the inbound body matches a STOP-pattern keyword (see [Suppression & Opt-Outs](https://docs.ibubbl.com/concepts/suppression.md)), iBubbl marks the contact opted out and fires a separate `contact.opted_out` event. You'll receive **both** events for the same incoming text. In a group, opting out the sender blocks all subsequent outbound to the group with `group_outbound_blocked_suppression` — the rest of the group can't be messaged in that thread either.

> [accordion] message.edited
>
> Fired when a contact edits a message they previously sent you (iMessage allows edits for about 15 minutes), or when a message you sent is edited from the dashboard.
>
> ```json theme={null}
> {
>   "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
>   "type": "message.edited",
>   "created_at": "2026-05-17T14:25:00.000Z",
>   "data": {
>     "message_id": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e",
>     "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
>     "new_body": "yeah sounds good, what time were you thinking?",
>     "edited_at": "2026-05-17T14:25:00.000Z",
>     "direction": "inbound"
>   }
> }
> ```
>
> The `message_id` matches the original `message.inbound` (or outbound) message — your store can update the existing record. `direction` is `inbound` for edits the contact made to a message they sent, or `outbound` for edits to a message you sent.
>
> When a contact unsends a message, no event fires.

> [accordion] message.reaction.added
>
> Fired when a tapback reaction is added to a message. Most commonly this is a contact reacting to one of your messages (`direction: "inbound"`). A tapback you send, from the dashboard or with [Add a tapback](https://docs.ibubbl.com/api-reference/messages/react.md), fires as `direction: "outbound"` once it has reached the contact's phone; if it can't be sent, [`message.reaction.failed`](#message-reaction-failed) fires instead.
>
> ```json theme={null}
> {
>   "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
>   "type": "message.reaction.added",
>   "created_at": "2026-05-17T14:26:00.000Z",
>   "data": {
>     "reaction_id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
>     "target_message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d",
>     "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
>     "from_number": "+14155551234",
>     "reaction_type": "love",
>     "emoji": "❤️",
>     "direction": "inbound"
>   }
> }
> ```
>
> `reaction_type` values: `love`, `like`, `dislike`, `laugh`, `emphasize`, `question`.
>
> `emoji` carries the rendered Unicode character — useful if you're displaying reactions in your own UI without maintaining a tapback-to-emoji mapping.
>
> `from_number` is the contact who reacted. Outbound reactions (`direction: "outbound"`) have no `from_number`. `target_message_id` is the message being reacted to, and `conversation_id` its conversation.
>
> `reaction_id` is the reaction's `id` from [Add a tapback](https://docs.ibubbl.com/api-reference/messages/react.md) for one you sent.

> [accordion] message.reaction.removed
>
> Fired when a previously-added reaction is removed: the reactor un-tapped it, or replaced it with a different tapback (iMessage keeps one per person per message). For your own tapbacks, that's [Remove a tapback](https://docs.ibubbl.com/api-reference/messages/remove-reaction.md) (once the phone confirms), or a newer tapback of yours on the same message replacing one that had been sent. Same payload shape as `message.reaction.added`.
>
> ```json theme={null}
> {
>   "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
>   "type": "message.reaction.removed",
>   "created_at": "2026-05-17T14:27:00.000Z",
>   "data": {
>     "reaction_id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
>     "target_message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d",
>     "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
>     "from_number": "+14155551234",
>     "reaction_type": "love",
>     "emoji": "❤️",
>     "direction": "inbound"
>   }
> }
> ```
>
> The `reaction_id` matches the original `.added` event so you can update or delete the reaction in your own store.

> [accordion] message.reaction.failed
>
> Fired when a tapback you sent, from the dashboard or with [Add a tapback](https://docs.ibubbl.com/api-reference/messages/react.md), or its removal, couldn't be sent from your line. Always `direction: "outbound"`. It fires once per reaction.
>
> ```json theme={null}
> {
>   "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
>   "type": "message.reaction.failed",
>   "created_at": "2026-05-17T14:26:03.000Z",
>   "data": {
>     "reaction_id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
>     "target_message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d",
>     "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
>     "reaction_type": "love",
>     "emoji": "❤️",
>     "direction": "outbound",
>     "action": "add",
>     "failed_reason": "sms_target"
>   }
> }
> ```
>
> `action` is `add` when the tapback never reached the contact (no `message.reaction.added` fired for it), or `remove` when taking it back failed and it may still show on their phone.
>
> `failed_reason` values (treat them as opaque codes; new ones may be added):
>
> * `sms_target` — the message or the conversation is on SMS, where tapbacks can't be sent
> * `target_not_found` — your line couldn't find the message on its side
> * `bb_rejected` — your line refused the tapback
> * `messages_error` — Messages on your line reported an error
> * `expired` — it couldn't be sent within 10 minutes, so it was dropped rather than sent late
> * `target_unsent` — the contact unsent the message before the tapback went out
> * `retract_rejected` — the removal was refused (`action: "remove"`)
>
> [Retrieve](https://docs.ibubbl.com/api-reference/messages/retrieve.md) shows nothing for a failed tapback; this event is how you learn of it. A failed tapback can be sent again with [Add a tapback](https://docs.ibubbl.com/api-reference/messages/react.md), which starts a new reaction.

> [accordion] contact.opted\_out
>
> Fired when a contact opts out.
>
> ```json theme={null}
> {
>   "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
>   "type": "contact.opted_out",
>   "created_at": "2026-05-17T14:28:00.000Z",
>   "data": {
>     "contact_phone": "+14155551234",
>     "source": "auto_keyword",
>     "triggered_by_message_id": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e",
>     "opted_out_at": "2026-05-17T14:28:00.000Z"
>   }
> }
> ```
>
> `source` values:
>
> * `auto_keyword` — auto-detected from a STOP-pattern inbound reply
> * `manual` — marked opted out by a rep from the dashboard
> * `ghl_dnd` — the contact was marked Do Not Disturb (SMS or all channels) in your connected GoHighLevel sub-account
> * `api` — opted out through the API with [`POST /v1/contacts/{id}/opt-out`](https://docs.ibubbl.com/api-reference/contacts/opt-out)
>
> `triggered_by_message_id` is the inbound message that triggered the keyword detection (for `auto_keyword`), or null otherwise.
>
> The event fires once per person: the first time they opt out. A person who is already opted out (under any form of their number) doesn't produce another event.
>
> After this event, [`POST /v1/messages`](https://docs.ibubbl.com/api-reference/messages/send) to this contact returns `422 contact_opted_out`. See [Suppression & Opt-Outs](https://docs.ibubbl.com/concepts/suppression.md) for what happens to messages that were already accepted.

## Filtering subscriptions

When creating a subscription, pass `event_types` to receive only a subset:

```bash cURL theme={null}
curl -X POST https://api.ibubbl.com/v1/webhooks \
  -H "Authorization: Bearer $IBUBBL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/replies-only",
    "event_types": ["message.inbound", "message.edited"]
  }'
```

```typescript TypeScript theme={null}
const res = await fetch("https://api.ibubbl.com/v1/webhooks", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://api.example.com/replies-only",
    event_types: ["message.inbound", "message.edited"],
  }),
  signal: AbortSignal.timeout(10_000),
});

const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`);
console.log(data.id, data.event_types);
```

```python Python theme={null}
import os

import requests

API_KEY = os.environ["IBUBBL_API_KEY"]

res = requests.post(
    "https://api.ibubbl.com/v1/webhooks",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
        "url": "https://api.example.com/replies-only",
        "event_types": ["message.inbound", "message.edited"],
    },
    timeout=10,
)
data = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {data['detail']['error']}")
print(data["id"], data["event_types"])
```

Filtering is exact-match on the `type` field. Omitting `event_types` (or passing `null` or `[]`) subscribes to every event type, including types added later.


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