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

# Send messages

> Send to a phone number, a saved contact, an existing group or a new group, then track what happened.

Every send is one call to [`POST /v1/messages`](https://docs.ibubbl.com/api-reference/messages/send). It returns `202 Accepted` as soon as iBubbl has the message; your line sends it a moment later. This guide covers the four ways to address a message, what the `202` tells you, how to follow the message after that, and the errors that are specific to sending.

> [!WARNING]
>
> **Every request is live.** There is no sandbox: each call below sends a real message from your line. Test with your own phone number.

## Choose who to send to

Address each message in **exactly one** of four ways. Sending two of them (or none) returns `422 validation_error`.

| Field | Sends | Use it when |
| - | - | - |
| `to` | One-to-one, by E.164 phone number | You have a number. If no contact has it yet, one is created. |
| `contact_id` | One-to-one, to a saved contact | You stored the `contact_id` from an earlier send or from [`POST /v1/contacts`](https://docs.ibubbl.com/api-reference/contacts/upsert). |
| `chat_guid` | Into an existing group | You're replying in a group. Webhooks don't carry it: read it from [`GET /v1/messages/{id}`](https://docs.ibubbl.com/api-reference/messages/retrieve) for any message in the group. |
| `participant_phones` (+ `group_name`) | A new group, created by this message | You're starting a group with 2 or more people. |

Every request also needs `body` and an `idempotency_key`. Generate the key **once per message**, before the first attempt, and send the same key if you retry; see [Idempotency](https://docs.ibubbl.com/idempotency.md).

> [tab] To phone
>
> `to` must be E.164: `+`, the country code, then the number, with no spaces or punctuation.
>
> ```bash cURL theme={null}
> # One key per message: generate it once, and reuse it if you retry.
> IDEMPOTENCY_KEY=$(uuidgen)
>
> curl -X POST https://api.ibubbl.com/v1/messages \
>   -H "Authorization: Bearer $IBUBBL_API_KEY" \
>   -H "Content-Type: application/json" \
>   -d "{
>     \"to\": \"+14155551234\",
>     \"body\": \"Hi Sam, your order shipped today.\",
>     \"idempotency_key\": \"$IDEMPOTENCY_KEY\"
>   }"
> ```
>
> ```typescript TypeScript theme={null}
> import { randomUUID } from "node:crypto";
>
> // One key per message: generate it once, and reuse it if you retry.
> const idempotencyKey = randomUUID();
>
> const res = await fetch("https://api.ibubbl.com/v1/messages", {
>   method: "POST",
>   headers: {
>     Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`,
>     "Content-Type": "application/json",
>   },
>   body: JSON.stringify({
>     to: "+14155551234",
>     body: "Hi Sam, your order shipped today.",
>     idempotency_key: idempotencyKey,
>   }),
>   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.status);
> ```
>
> ```python Python theme={null}
> import os
> import uuid
>
> import requests
>
> API_KEY = os.environ["IBUBBL_API_KEY"]
>
> # One key per message: generate it once, and reuse it if you retry.
> idempotency_key = str(uuid.uuid4())
>
> res = requests.post(
>     "https://api.ibubbl.com/v1/messages",
>     headers={"Authorization": f"Bearer {API_KEY}"},
>     json={
>         "to": "+14155551234",
>         "body": "Hi Sam, your order shipped today.",
>         "idempotency_key": idempotency_key,
>     },
>     timeout=10,
> )
> data = res.json()
> if not res.ok:
>     raise RuntimeError(f"{res.status_code} {data['detail']['error']}")
> print(data["id"], data["status"])
> ```

> [tab] Contact ID
>
> Use the `contact_id` from an earlier `202` or from [`POST /v1/contacts`](https://docs.ibubbl.com/api-reference/contacts/upsert). An unknown ID returns `404 not_found`.
>
> ```bash cURL theme={null}
> # One key per message: generate it once, and reuse it if you retry.
> IDEMPOTENCY_KEY=$(uuidgen)
>
> curl -X POST https://api.ibubbl.com/v1/messages \
>   -H "Authorization: Bearer $IBUBBL_API_KEY" \
>   -H "Content-Type: application/json" \
>   -d "{
>     \"contact_id\": \"c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f\",
>     \"body\": \"Hi Sam, your order shipped today.\",
>     \"idempotency_key\": \"$IDEMPOTENCY_KEY\"
>   }"
> ```
>
> ```typescript TypeScript theme={null}
> import { randomUUID } from "node:crypto";
>
> // One key per message: generate it once, and reuse it if you retry.
> const idempotencyKey = randomUUID();
>
> const res = await fetch("https://api.ibubbl.com/v1/messages", {
>   method: "POST",
>   headers: {
>     Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`,
>     "Content-Type": "application/json",
>   },
>   body: JSON.stringify({
>     contact_id: "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f",
>     body: "Hi Sam, your order shipped today.",
>     idempotency_key: idempotencyKey,
>   }),
>   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.status);
> ```
>
> ```python Python theme={null}
> import os
> import uuid
>
> import requests
>
> API_KEY = os.environ["IBUBBL_API_KEY"]
>
> # One key per message: generate it once, and reuse it if you retry.
> idempotency_key = str(uuid.uuid4())
>
> res = requests.post(
>     "https://api.ibubbl.com/v1/messages",
>     headers={"Authorization": f"Bearer {API_KEY}"},
>     json={
>         "contact_id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f",
>         "body": "Hi Sam, your order shipped today.",
>         "idempotency_key": idempotency_key,
>     },
>     timeout=10,
> )
> data = res.json()
> if not res.ok:
>     raise RuntimeError(f"{res.status_code} {data['detail']['error']}")
> print(data["id"], data["status"])
> ```

> [tab] Chat GUID
>
> Reply into an existing group. Get `chat_guid` from [`GET /v1/messages/{id}`](https://docs.ibubbl.com/api-reference/messages/retrieve) for any message in the group (an inbound `message_id` from a webhook works). It changes when someone is added to the group, and an old value returns `404 group_not_found`, so read it fresh rather than storing it forever. See [Group conversations](https://docs.ibubbl.com/concepts/group-conversations.md).
>
> ```bash cURL theme={null}
> # One key per message: generate it once, and reuse it if you retry.
> IDEMPOTENCY_KEY=$(uuidgen)
>
> curl -X POST https://api.ibubbl.com/v1/messages \
>   -H "Authorization: Bearer $IBUBBL_API_KEY" \
>   -H "Content-Type: application/json" \
>   -d "{
>     \"chat_guid\": \"any;+;067ace6caf6c43aaa2f7d643975e15ea\",
>     \"body\": \"Works for everyone? See you at 3.\",
>     \"idempotency_key\": \"$IDEMPOTENCY_KEY\"
>   }"
> ```
>
> ```typescript TypeScript theme={null}
> import { randomUUID } from "node:crypto";
>
> // One key per message: generate it once, and reuse it if you retry.
> const idempotencyKey = randomUUID();
>
> const res = await fetch("https://api.ibubbl.com/v1/messages", {
>   method: "POST",
>   headers: {
>     Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`,
>     "Content-Type": "application/json",
>   },
>   body: JSON.stringify({
>     chat_guid: "any;+;067ace6caf6c43aaa2f7d643975e15ea",
>     body: "Works for everyone? See you at 3.",
>     idempotency_key: idempotencyKey,
>   }),
>   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.status);
> ```
>
> ```python Python theme={null}
> import os
> import uuid
>
> import requests
>
> API_KEY = os.environ["IBUBBL_API_KEY"]
>
> # One key per message: generate it once, and reuse it if you retry.
> idempotency_key = str(uuid.uuid4())
>
> res = requests.post(
>     "https://api.ibubbl.com/v1/messages",
>     headers={"Authorization": f"Bearer {API_KEY}"},
>     json={
>         "chat_guid": "any;+;067ace6caf6c43aaa2f7d643975e15ea",
>         "body": "Works for everyone? See you at 3.",
>         "idempotency_key": idempotency_key,
>     },
>     timeout=10,
> )
> data = res.json()
> if not res.ok:
>     raise RuntimeError(f"{res.status_code} {data['detail']['error']}")
> print(data["id"], data["status"])
> ```

> [tab] New group
>
> List at least 2 people in E.164. Don't include your own line; iBubbl adds it. `group_name` is optional (at most 80 characters) and only allowed here. Fewer than 2 numbers returns `422 validation_error`; two forms of one person's number return `422 insufficient_participants`.
>
> ```bash cURL theme={null}
> # One key per message: generate it once, and reuse it if you retry.
> IDEMPOTENCY_KEY=$(uuidgen)
>
> curl -X POST https://api.ibubbl.com/v1/messages \
>   -H "Authorization: Bearer $IBUBBL_API_KEY" \
>   -H "Content-Type: application/json" \
>   -d "{
>     \"participant_phones\": [\"+14155551234\", \"+14155557654\"],
>     \"group_name\": \"Kitchen remodel\",
>     \"body\": \"Hi both, starting a thread for the remodel.\",
>     \"idempotency_key\": \"$IDEMPOTENCY_KEY\"
>   }"
> ```
>
> ```typescript TypeScript theme={null}
> import { randomUUID } from "node:crypto";
>
> // One key per message: generate it once, and reuse it if you retry.
> const idempotencyKey = randomUUID();
>
> const res = await fetch("https://api.ibubbl.com/v1/messages", {
>   method: "POST",
>   headers: {
>     Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`,
>     "Content-Type": "application/json",
>   },
>   body: JSON.stringify({
>     participant_phones: ["+14155551234", "+14155557654"],
>     group_name: "Kitchen remodel",
>     body: "Hi both, starting a thread for the remodel.",
>     idempotency_key: idempotencyKey,
>   }),
>   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.status, data.chat_guid); // chat_guid is null at first
> ```
>
> ```python Python theme={null}
> import os
> import uuid
>
> import requests
>
> API_KEY = os.environ["IBUBBL_API_KEY"]
>
> # One key per message: generate it once, and reuse it if you retry.
> idempotency_key = str(uuid.uuid4())
>
> res = requests.post(
>     "https://api.ibubbl.com/v1/messages",
>     headers={"Authorization": f"Bearer {API_KEY}"},
>     json={
>         "participant_phones": ["+14155551234", "+14155557654"],
>         "group_name": "Kitchen remodel",
>         "body": "Hi both, starting a thread for the remodel.",
>         "idempotency_key": idempotency_key,
>     },
>     timeout=10,
> )
> data = res.json()
> if not res.ok:
>     raise RuntimeError(f"{res.status_code} {data['detail']['error']}")
> print(data["id"], data["status"], data["chat_guid"])  # chat_guid is None at first
> ```
>
> The new group's `chat_guid` is `null` in the `202`. It fills in once the group exists in iMessage, which happens when the first message goes out; poll [`GET /v1/messages/{id}`](https://docs.ibubbl.com/api-reference/messages/retrieve) to read it.
>
> > [!WARNING]
> >
> > **Don't auto-retry a timed-out new-group send.** If the request times out, a retry with the same key can create a second group. Check with [`GET /v1/messages/{id}`](https://docs.ibubbl.com/api-reference/messages/retrieve) or the dashboard first. See [Duplicates and retries](https://docs.ibubbl.com/concepts/message-lifecycle.md#duplicates-and-retries).

## Reply to a message and send with effects

Two optional fields make a send look like one typed in Messages:

* `reply_to_message_id` sends the message as an **inline reply** that quotes an earlier message. Pass a message `id`, or the `message_id` from a [`message.inbound`](https://docs.ibubbl.com/webhooks/events) webhook to reply to something the contact sent.
* `effect` adds an **iMessage effect**, such as confetti or a slam. The values are in the table below.

Use either or both, with `to`, `contact_id` or `chat_guid`. Neither can be combined with `attachments` (`422 validation_error`): send the files and the reply or effect as two messages. This example replies to an inbound message with confetti:

```bash cURL theme={null}
# One key per message: generate it once, and reuse it if you retry.
IDEMPOTENCY_KEY=$(uuidgen)

# The message_id from the message.inbound webhook you're replying to.
REPLY_TO="5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c"

curl -X POST https://api.ibubbl.com/v1/messages \
  -H "Authorization: Bearer $IBUBBL_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"to\": \"+14155551234\",
    \"body\": \"Congrats on the new place, Sam!\",
    \"reply_to_message_id\": \"$REPLY_TO\",
    \"effect\": \"confetti\",
    \"idempotency_key\": \"$IDEMPOTENCY_KEY\"
  }"
```

```typescript TypeScript theme={null}
import { randomUUID } from "node:crypto";

// One key per message: generate it once, and reuse it if you retry.
const idempotencyKey = randomUUID();

// The message_id from the message.inbound webhook you're replying to.
const replyTo = "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c";

const res = await fetch("https://api.ibubbl.com/v1/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    to: "+14155551234",
    body: "Congrats on the new place, Sam!",
    reply_to_message_id: replyTo,
    effect: "confetti",
    idempotency_key: idempotencyKey,
  }),
  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.status, data.effect, data.reply_to_message_id);
```

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

import requests

API_KEY = os.environ["IBUBBL_API_KEY"]

# One key per message: generate it once, and reuse it if you retry.
idempotency_key = str(uuid.uuid4())

# The message_id from the message.inbound webhook you're replying to.
reply_to = "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c"

res = requests.post(
    "https://api.ibubbl.com/v1/messages",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
        "to": "+14155551234",
        "body": "Congrats on the new place, Sam!",
        "reply_to_message_id": reply_to,
        "effect": "confetti",
        "idempotency_key": idempotency_key,
    },
    timeout=10,
)
data = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {data['detail']['error']}")
print(data["id"], data["status"], data["effect"], data["reply_to_message_id"])
```

The `202` response echoes both fields; they are `null` on a send without them.

### Effects

Send the value in the `effect` column. Values are lowercase and exact: anything else returns `422 validation_error`.

| `effect` | In Messages | Kind |
| - | - | - |
| `slam` | Slam | Bubble |
| `loud` | Loud | Bubble |
| `gentle` | Gentle | Bubble |
| `invisible` | Invisible Ink | Bubble |
| `echo` | Echo | Screen |
| `spotlight` | Spotlight | Screen |
| `balloons` | Balloons | Screen |
| `confetti` | Confetti | Screen |
| `love` | Love | Screen |
| `lasers` | Lasers | Screen |
| `fireworks` | Fireworks | Screen |
| `shooting_star` | Shooting Star | Screen |
| `celebration` | Celebration | Screen |

A bubble effect animates the message bubble; a screen effect fills the recipient's screen.

> [!WARNING]
>
> **iMessage only.** If the message goes out as SMS (RCS included), it is sent as plain text: the quote and the effect are dropped, and the API doesn't refuse the send. The `message.sent` webhook carries `channel` (`imessage` or `sms`), so you can see which way it went; [`GET /v1/messages/{id}`](https://docs.ibubbl.com/api-reference/messages/retrieve) returns it too. See [Lines and routing](https://docs.ibubbl.com/concepts/lines-and-routing.md).

> [!NOTE]
>
> **The reply must be in the same conversation.** For a `to` or `contact_id` send, `reply_to_message_id` must be a message in your one-to-one conversation with that contact; for a `chat_guid` send, a message in that group. Anything else, including an ID from another account, returns `422 reply_target_not_found`. A message the contact unsent returns `422 reply_target_unsent`, and one they unsent part of returns `422 reply_target_partly_unsent`.

> [!NOTE]
>
> **Not when creating a group.** Neither field is allowed with `participant_phones` (`422 validation_error`): a new group has nothing to reply to. Send them on a later message to the group's `chat_guid`.

> [!NOTE]
>
> **Both fields are part of the idempotency payload when you set them.** A retry with the same `idempotency_key` must send the same `effect` and `reply_to_message_id`, or it returns `409 idempotency_conflict`. See [Idempotency](https://docs.ibubbl.com/idempotency.md).

## Send files

Add `attachments`, a list of up to 10 `https` URLs, to send photos, GIFs, videos, PDFs or audio to one contact. `body` becomes optional and, when set, is the caption; the first message to a contact still needs a `body`. iBubbl downloads the files before it answers, so allow 60 seconds. [Send attachments](https://docs.ibubbl.com/guides/attachments.md) has the examples, the types and limits, and how each route delivers them.

## Read the `202` response

```json theme={null}
{
  "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
  "status": "pending",
  "to": "+14155551234",
  "body": "Hi Sam, your order shipped today.",
  "contact_id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f",
  "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
  "conversation_kind": "one_to_one",
  "chat_guid": null,
  "channel": null,
  "sent_at": "2026-11-10T15:04:10.112Z",
  "held_until": null,
  "held_reason": null,
  "effect": null,
  "reply_to_message_id": null,
  "attachments": []
}
```

`202` means **accepted, not sent**. Save the `id`. `status` is one of:

| `status` | `held_reason` | What it means |
| - | - | - |
| `pending` | `null` | On its way: your line sends it shortly. |
| `pending` | `working_hours` | Waiting for the contact's [sending hours](https://docs.ibubbl.com/concepts/working-hours.md). `held_until` says when it goes out. |
| `pending` | `hourly_limit` | Waiting for the line's hourly limit on new contacts. `held_until` is an estimate. See [Limits and queues](https://docs.ibubbl.com/concepts/limits.md). |
| `queued` | `null` | One-to-one only: the line's Mac is offline. It goes out when the Mac reconnects, or expires after about an hour, which fires `message.failed` with `status: "expired"`. See [Offline lines](https://docs.ibubbl.com/concepts/limits.md#offline-lines). |

A waiting message is not an error, so don't resend it: a new key would send a second message once the first goes out. For a group send, `to` is `null` and `conversation_kind` is `"group"`. `channel` stays `null` until the message is sent.

## Track the message

You have two ways to learn what happened next:

* **Webhooks (recommended).** Subscribe to `message.sent`, `message.delivered`, `message.read`, `message.failed` and `message.cancelled`; each carries the `message_id` from the `202`, and `channel` once the message has gone out. See [Webhook events](https://docs.ibubbl.com/webhooks/events.md) and [Receive messages](https://docs.ibubbl.com/guides/receive-messages.md).
* **Polling.** [`GET /v1/messages/{id}`](https://docs.ibubbl.com/api-reference/messages/retrieve) returns the current `status`, `channel` (`imessage` or `sms`) and `held_*` fields.

Use both where it matters. A few changes fire no webhook, such as a `queued` message moving on to `pending`. [Message lifecycle](https://docs.ibubbl.com/concepts/message-lifecycle.md) has every status, which ones are final, and which webhook each fires.

## Errors when sending

A refused send creates no message, so its `idempotency_key` stays unused and you can reuse it once you've fixed the request. Branch on `detail.error`, never on `detail.message`. The [Errors](https://docs.ibubbl.com/errors.md) page has the full catalog and the retry rules.

| `detail.error` | HTTP | What to do |
| - | - | - |
| `validation_error` | 422 | Fix the request: zero or two addressing fields, a number not in E.164, an empty `body`, `group_name` without `participant_phones`, fewer than 2 `participant_phones`, an unknown `effect`, a `reply_to_message_id` that isn't a UUID, either of those with `participant_phones` or `attachments`, or `attachments` that aren't 1–10 `https` URLs or are sent to a group. |
| `not_found` | 404 | The `contact_id` doesn't exist on this account. |
| `group_not_found` | 404 | No group with that `chat_guid`. It may have changed when someone joined: read it again from `GET /v1/messages/{id}`. |
| `idempotency_conflict` | 409 | You reused a key with a different payload. Send the original payload, or use a new key for a new message. |
| `contact_opted_out` | 422 | Final. Never message this contact again. See [Suppression](https://docs.ibubbl.com/concepts/suppression.md). |
| `contact_blocked` | 422 | The contact is blocked in your dashboard. |
| `unresponsive_contact` | 422 | 6 messages in a row without a reply. Wait until the contact replies. |
| `cold_cap_exhausted` | 422 | The line reached 50 new contacts today. Try again after midnight ET. |
| `daily_cap_exhausted` | 422 | The line reached 1,000 messages today. Try again after midnight ET. |
| `no_eligible_line` | 422 | No line can take a new contact right now (for example, its Mac is offline). |
| `group_outbound_blocked_suppression` | 422 | Someone in the group has opted out; a new-group send also lists them in `blocked_phones`. |
| `insufficient_participants` | 422 | The new group names fewer than 2 different people. |
| `reply_target_not_found` | 422 | `reply_to_message_id` isn't a message in this conversation. Check the ID, and that you're sending to the same contact or group. |
| `reply_target_unsent` / `reply_target_partly_unsent` | 422 | The contact unsent the message (or part of it). Send without `reply_to_message_id`. |
| `first_message_needs_text`, `attachment_fetch_failed`, `attachment_too_large`, `unsupported_attachment`, `attachment_combination_not_supported` | 422 | A problem with `attachments`. See [Send attachments](https://docs.ibubbl.com/guides/attachments.md#errors). |
| `internal_error` | 500 | Retry with the **same** `idempotency_key` and payload, with backoff. |

`401`, `403` and `500 no_owner_rep` are account problems, not send problems; see [Errors](https://docs.ibubbl.com/errors.md).

## Next

> [card] [Receive messages](https://docs.ibubbl.com/guides/receive-messages.md)
>
> Handle replies, attachments and opt-outs, and auto-reply without loops.

> [card] [Message lifecycle](https://docs.ibubbl.com/concepts/message-lifecycle.md)
>
> Every status, which ones are final and which webhook fires.

> [card] [Limits and queues](https://docs.ibubbl.com/concepts/limits.md)
>
> The per-line limits, and how to pace sends.

> [card] [Idempotency](https://docs.ibubbl.com/idempotency.md)
>
> Retry safely without sending twice.


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