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
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
| Field | Type | Notes |
|---|---|---|
to | string | Recipient — E.164 phone number or iMessage email. Required (with from_handle) unless conversation_id is set — and must be omitted when it is. |
from_handle | string | One of your numbers (see GET /v1/numbers). Required (with to) unless conversation_id is set — and must be omitted when it is. |
conversation_id | uuid | Send 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. |
text | string | Message body. text or attachment_ids (or both) must be present. |
attachment_ids | uuid[] | Up to 10 staged attachments — see Attachments. |
reply_to | uuid | Send as a threaded reply to one of your message ids. |
effect | string | iMessage 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_memo | boolean | Send 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
{
"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
queuedand 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 markedfailed. - 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).
curl https://api.msgbubbles.com/v1/handles/availability?handle=+15555550123 \
-H "Authorization: Bearer sk_live_…"{
"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
{ "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.