msgbubblesDocs

Messages

One endpoint sends everything. Address a recipient with to + from_handle, or send into an existing thread with conversation_id — pick exactly one of the two modes. Sending conversation_id together with to or from_handle is rejected with 400 invalid_request. Whether the recipient gets an iMessage or an SMS is decided per recipient automatically; WhatsApp is opted into per message with channel.

Send a message

POST/v1/messages

Send a text
curl -X POST https://api.msgbubbles.com/v1/messages \
  -H "Authorization: Bearer sk_live_…" \
  -H "content-type: application/json" \
  -H "Idempotency-Key: order-1042-confirmation" \
  -d '{
    "to": "+15555550123",
    "from_handle": "+18005551111",
    "text": "your order shipped!"
  }'

Body parameters

FieldTypeNotes
tostringRecipient — E.164 phone number or iMessage email. Required (with from_handle) unless conversation_id is set — and must be omitted when it is.
from_handlestringOne of your numbers (see GET /v1/numbers). Required (with to) unless conversation_id is set — and must be omitted when it is.
conversation_iduuidSend into an existing thread instead of to + from_handle — mutually exclusive with them; including either alongside conversation_id returns 400 invalid_request. Required for group chats.
textstringMessage body. text or attachment_ids (or both) must be present.
attachment_idsuuid[]Up to 10 staged attachments — see Attachments.
reply_touuidSend as a threaded reply to one of your message ids.
effectstringiMessage bubble/screen effect id (e.g. confetti).
channel"whatsapp"Force the WhatsApp channel. Needed when the from number serves both iMessage/SMS and WhatsApp — iMessage/SMS wins by default.
voice_memobooleanSend audio attachments as native voice-note bubbles instead of file attachments.

Idempotency

Pass an Idempotency-Key header to make retries safe. A repeated key returns the original message with 200 instead of sending twice; a first send returns 202.

The message object

202 Accepted
{
  "data": {
    "id": "0d4b1f3a-…",
    "conversation_id": "7f2c9e1b-…",
    "direction": "outbound",
    "status": "queued",
    "channel": null,
    "text": "your order shipped!",
    "effect": null,
    "reply_to": null,
    "error": null,
    "metadata": null,
    "attachments": [],
    "sent_at": null,
    "delivered_at": null,
    "read_at": null,
    "edited_at": null,
    "created_at": "2026-06-11T18:21:04.000Z"
  },
  "request_id": "req_…"
}

Message lifecycle

queued → sent → delivered → read, with failed as the terminal error state and unsent after a successful unsend. Each transition also fires a webhook, so you rarely need to poll. channel resolves once the message is sent — imessage, sms, or whatsapp.

Automatic send retries

A 202 means msgbubbles owns delivery from there — never re-POST a message that is still queued. What happens after a failed attempt depends on why it failed:

  • Transient errors — the sending number was online but the send errored. Retried automatically about every 30 seconds, up to 5 attempts, then the message is marked failed.
  • Sending number offline — the device behind your number is unreachable. The message stays queued and retries with backoff, starting at 30 seconds and doubling to a ceiling of 5 minutes. These waits don’t count against the 5-attempt cap — sends queue and retry until the number comes back. If it is still offline 24 hours after the first attempt, the message is marked failed.
  • Permanent errors — retrying can’t change the outcome, so the message fails immediately: an attachment that can’t fit the channel’s size limit even after transcoding, or a WhatsApp number whose session needs re-linking.

Whenever a message is marked failed, its error field says why and a message.failed webhook fires — re-queue it with POST /v1/messages/:id/retry (below). Reactions, edits, and unsends follow the same retry policy, except a permanent failure drops the action: the message itself is already delivered, so it is never marked failed.

List and fetch

GET/v1/messages

Paginates newest-first. Query params: conversation_id (filter to a thread), limit (1–100, default 50), and before (ISO timestamp cursor). The response is { data, has_more, request_id } — pass the oldest created_at as the next before.

GET/v1/messages/:id

Check reachability

GET/v1/handles/availability

Look up whether a recipient is reachable on iMessage — whether a send will go blue (iMessage) or fall back to green (SMS) — before you send. Pass the recipient as a handle query param (E.164 number or iMessage email).

Check a handle
curl https://api.msgbubbles.com/v1/handles/availability?handle=+15555550123 \
  -H "Authorization: Bearer sk_live_…"
200 OK
{
  "data": { "handle": "+15555550123", "imessage": true, "channel": "imessage" },
  "request_id": "req_…"
}

imessage is whether the handle is registered with iMessage; channel is the channel a send would use (imessage or sms). "Not on iMessage" usually means Android, but also de-registered or never-activated Apple numbers — we can only see iMessage registration.

Actions on a sent message

React

POST/v1/messages/:id/react

Tapback or emoji
{ "reaction": "love" }          // tapbacks: love, like, dislike, laugh, emphasize, question
{ "reaction": "🔥" }            // or any single emoji
{ "reaction": "love", "remove": true }

Reactions target a delivered message (409 before then) and work on iMessage and WhatsApp.

Edit and unsend (iMessage only)

POST/v1/messages/:id/edit

POST/v1/messages/:id/unsend

Body for edit: { "text": "updated body" }. Both return 409 for SMS — carriers offer no edit or recall — and before the message has actually sent.

Edits land on the device asynchronously — when one does, the message’s edited_at is stamped and a message.edited webhook fires with the new text, so you get a confirmation without polling. A contact editing a message they sent you (iMessage or WhatsApp) fires the same event, and the stored message always holds the latest text.

Retry a failed send

POST/v1/messages/:id/retry

Re-queues a message in status failed (for example, sent while the number was offline past the 24-hour retry window). Parts that already went out are never re-sent.