Send Messages
Send iMessages with automatic SMS fallback via POST /v1/messages.
Basic Message
curl -X POST https://api.textbubbles.com/v1/messages \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"content": {
"text": "Hello!"
}
}'The to field accepts phone numbers in E.164 format (e.g., +14155551234) or iMessage email addresses (e.g., user@example.com).
Sending to a Chat (chatId)
To send into an existing chat (group or 1:1), pass chatId instead of to. The chatId value is the chat.chatId UUID returned from POST /v1/chats/groups or GET /v1/chats/:chatId.
toandchatIdare mutually exclusive — provide exactly one. Supplying both, or neither, returns400 INVALID_REQUEST.- The channel and sender instance are pinned to the chat;
routing.preferenceandrouting.fallbackare ignored. - If
fromis supplied, it must resolve to the same instance the chat is pinned to. A mismatch returns400 FROM_INSTANCE_CANNOT_REACH. - An unknown or foreign
chatIdreturns404 CONVERSATION_NOT_FOUND. - Group send on SMS is not supported; returns
400 GROUP_SMS_UNSUPPORTED. createContactis ignored (no single recipient).
curl -X POST https://api.textbubbles.com/v1/messages \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "550e8400-e29b-41d4-a716-446655440000",
"content": { "text": "Hey team" }
}'Response (202 Accepted):
{
"success": true,
"data": {
"id": "msg_abc123",
"status": "queued",
"conversationId": "550e8400-e29b-41d4-a716-446655440000",
"from": "+14155551234",
"createdAt": "2026-05-25T10:00:00.000Z"
},
"requestId": "req_abc123"
}When the send is keyed by chatId, the 202 response carries conversationId (the same UUID) in place of to. The matching message.* lifecycle webhooks for this send carry the full chat fragment — see Chat and sender fragments.
Sender Address (from)
By default, messages are sent from your customer’s default authorized address. You can specify a different authorized address with the from field:
curl -X POST https://api.textbubbles.com/v1/messages \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"from": "+19876543210",
"content": { "text": "Hello from my other number!" }
}'The from address must be in your customer’s authorized address list. Using an unauthorized address returns 403 ADDRESS_NOT_AUTHORIZED. See Authentication for details.
With Effects (iMessage Only)
curl -X POST https://api.textbubbles.com/v1/messages \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"content": { "text": "Congratulations!" },
"effect": "confetti"
}'See Message Effects for all available effects.
Reply to a Message
Create a threaded reply using the replyTo field (iMessage and WhatsApp):
curl -X POST https://api.textbubbles.com/v1/messages \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"content": { "text": "Thanks for letting me know!" },
"replyTo": "msg_550e8400-e29b-41d4-a716-446655440000"
}'Attachments vs Media URLs — Which Field Do I Use?
TextBubbles exposes two ways to send non-text content on a message, and they map to different channels. Picking the wrong one is the most common source of “my image didn’t send” bugs, so here is the decision rule.
Text is optional when you send media. Every request must include at least one of
content.text(non-empty),content.mediaUrls, orattachments[]. You can send an attachment-only message — voice note, image with no caption, etc. — by omittingcontent.textor passing an empty string. Sending a request with no text, nomediaUrls, and noattachments[]returns400.
| Use | Field | Channels | Source types | Notes |
|---|---|---|---|---|
| Send a file (photo, video, PDF, audio, any MIME type) | attachments[] | iMessage and SMS/MMS | type: "url" (HTTPS) or type: "base64" | Required for non-image files and for base64 payloads. On iMessage, 2+ items arrive as a grouped photo gallery delivered after the text bubble. On SMS, each attachment is delivered as a separate MMS; carrier delivery of non-image MIME types is not guaranteed. |
| Send an image (or 2+ images as a carousel) | content.mediaUrls | iMessage and SMS/MMS | HTTPS image URLs only | On iMessage, 2+ URLs arrive as a single grouped carousel bubble with the text caption inlined. On SMS, the carrier fetches each URL and attaches it as MMS. |
Decision tree
- Do you need a non-image file (PDF, video, audio, vCard, etc.) or to send raw bytes (
type: "base64")? Useattachments[]. SMS/MMS cannot carry arbitrary file types in all carrier networks — check capabilities first. - Do you want 2+ images to arrive as a single grouped bubble with the text caption inline? Use
content.mediaUrls. Multipleattachments[]also arrives as a grouped gallery on iMessage, but the text is sent as a separate bubble before the gallery. - Everything else (single image with an HTTPS URL): either works; prefer
attachments[]for explicit control overmimeTypeandfilename.
Do not combine
Do not set both attachments[] and content.mediaUrls on the same request. If you do, attachments[] is sent on the iMessage path and mediaUrls is sent on the SMS path, which produces different payloads depending on which channel the router picks — almost never what you want.
With Attachments
URL attachment (iMessage):
curl -X POST https://api.textbubbles.com/v1/messages \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"content": { "text": "Check out this image" },
"attachments": [
{
"type": "url",
"url": "https://example.com/photo.jpg",
"mimeType": "image/jpeg",
"filename": "photo.jpg"
}
]
}'Media URL (SMS/MMS):
curl -X POST https://api.textbubbles.com/v1/messages \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"content": {
"text": "Check out this image",
"mediaUrls": ["https://example.com/photo.jpg"]
},
"routing": { "preference": ["sms"] }
}'Voice Memos
To send an audio file as a native voice-memo bubble (inline player with waveform) instead of a generic file attachment, set isAudioMessage: true on the attachment. Voice memos are attachment-only — either omit content.text (send "content": {}) or pass "text": "". Both are accepted. Supported on iMessage and WhatsApp.
Preferred audio formats by channel (sent as-is):
| Channel | Preferred format |
|---|---|
| iMessage | MP3 or Opus-in-CAF |
| Opus-in-OGG |
Any other audio format is transcoded server-side to the channel’s preferred format before send.
curl -X POST https://api.textbubbles.com/v1/messages \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"content": {},
"attachments": [
{
"type": "url",
"url": "https://example.com/voicenote.caf",
"mimeType": "audio/x-caf",
"filename": "voicenote.caf",
"isAudioMessage": true
}
]
}'Without isAudioMessage: true, the audio file arrives as a generic file attachment that the recipient must tap to download.
Video
Send video by including a video/* attachment in attachments[]. Supported on iMessage, SMS/MMS (carrier-dependent), and WhatsApp.
Preferred video formats by channel (sent as-is):
| Channel | Preferred format |
|---|---|
| iMessage | Any common container (MP4, MOV, M4V) — no server-side transcoding |
| MP4 with H.264 video and AAC audio |
WhatsApp specifically: H.264 in other containers (.mov, .mkv, etc.) is repackaged into MP4 without re-encoding. Any other format is transcoded to H.264/AAC in MP4 before send; transcoding adds latency proportional to input size and codec.
Base64 Attachments
Base64 attachment (iMessage):
curl -X POST https://api.textbubbles.com/v1/messages \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"content": { "text": "Here is the file" },
"attachments": [
{
"type": "base64",
"data": "iVBORw0KGgoAAAANSUhEUg...",
"mimeType": "image/png",
"filename": "chart.png"
}
]
}'Image Carousel
Send 2 or more image URLs in content.mediaUrls to send an image carousel:
curl -X POST https://api.textbubbles.com/v1/messages \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"content": {
"text": "Check out these photos!",
"mediaUrls": [
"https://cdn.example.com/photo1.jpg",
"https://cdn.example.com/photo2.png",
"https://cdn.example.com/photo3.webp"
]
},
"effect": "fireworks"
}'Images must be HTTPS URLs pointing to image files (jpg, png, gif, webp). Minimum 2 images, maximum 20 per message.
Channel Routing
By default, messages try iMessage first and fall back to SMS. Customize with the routing field:
{
"to": "+14155551234",
"content": { "text": "Hello!" },
"routing": {
"preference": ["imessage", "sms"],
"fallback": true
}
}preference— ordered list of channels:"imessage","sms","whatsapp"fallback— try the next channel if the first fails (default:true)
Webhook event reliability varies by channel. SMS in particular does not emit
message.read, only emitsmessage.deliveredon a best-effort basis (depends on the carrier returning a receipt), and SMS “reactions” arrive as plainmessage.inboundtext rather thanmessage.reaction. See Per-Channel Reliability for the full matrix before relying on a specific event for a given channel.
Include "whatsapp" in the preference list. The WhatsApp send uses the same phone number as your iMessage/SMS line — pairing is per-instance and managed via the admin panel.
{
"to": "+14155551234",
"content": { "text": "Hello from WhatsApp!" },
"routing": { "preference": ["whatsapp"] }
}You can also fall back from WhatsApp to iMessage/SMS:
{ "routing": { "preference": ["whatsapp", "imessage", "sms"] } }Send to an existing WhatsApp group by passing the group JID as to:
{
"to": "120363012345678901@g.us",
"content": { "text": "Hi team" },
"routing": { "preference": ["whatsapp"] },
"mentions": [{ "address": "+14155551234", "start": 0, "length": 3 }]
}Mentions are honoured on group sends only and silently dropped on individual chats. iMessage-only fields (effect, messageType: "carousel") are dropped on a WhatsApp send. See the WhatsApp reference for capabilities, limitations, and pairing.
Unsend a Message
Retract a sent iMessage:
curl -X POST https://api.textbubbles.com/v1/messages/msg_abc123/unsend \
-H "Authorization: Bearer YOUR_API_KEY"Retry a Failed Message
Re-send a message that previously landed in status: failed (for example, because of a transient delivery failure). Only messages currently in status: failed can be retried — messages that are still queued, sent, or in any other state return 400 INVALID_STATUS.
curl -X POST https://api.textbubbles.com/v1/messages/msg_abc123/retry \
-H "Authorization: Bearer YOUR_API_KEY"Response (202 Accepted):
{
"success": true,
"data": {
"id": "msg_abc123",
"status": "queued",
"retryCount": 1
}
}The retry preserves the original recipient, content, attachments, routing preferences, and instance. A fresh provider message ID is issued on the new send — from iMessage’s perspective this is a new send that carries the same TextBubbles message ID. You can call this endpoint multiple times; retryCount increments on each attempt. Watch the usual message.queued → message.sent / message.failed webhooks to track the outcome.
Edit a Message
Update sent message content (iMessage only):
curl -X PUT https://api.textbubbles.com/v1/messages/msg_abc123 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Updated message text",
"backwardsCompatibilityMessage": "* Updated message text"
}'text— the new message text (required)backwardsCompatibilityMessage— fallback text shown on older devices that don’t support message editing (optional)
Idempotency
Include an idempotencyKey to prevent duplicate sends during retries:
{
"to": "+14155551234",
"content": { "text": "Your order #12345 has shipped!" },
"idempotencyKey": "shipment-notification-12345"
}If a message with the same key already exists, the API returns 200 OK with the existing message.
Full Request Schema
| Field | Type | Required | Description |
|---|---|---|---|
to | string | Conditionally | Recipient phone number (E.164) or iMessage email address. Required unless chatId is provided; mutually exclusive with chatId |
chatId | string (UUID) | Conditionally | Conversation UUID (the chat.chatId returned by POST /v1/chats/groups). Sends into the existing chat; channel and instance are pinned. Required unless to is provided; mutually exclusive with to |
from | string | No | Sender address (must be authorized for your customer; uses default if omitted). When chatId is supplied, must resolve to the chat’s pinned instance |
content.text | string | No | Message text (up to 10,000 chars). Optional if content.mediaUrls is provided |
content.mediaUrls | string[] | No | Array of 1-20 HTTPS image URLs. When 2+ URLs are provided, sends as an image carousel |
routing.preference | string[] | No | Channel priority (default: ["imessage", "sms"]) |
routing.fallback | boolean | No | Enable fallback (default: true) |
replyTo | string | No | Message ID to reply to (iMessage and WhatsApp) |
attachments | object[] | No | File attachments. iMessage and SMS/MMS; on SMS each attachment is delivered as a separate MMS, carrier-dependent for non-image MIME types. |
attachments[].type | string | No | Attachment type: "url" or "base64" |
attachments[].url | string | No | URL of the attachment (when type is url) |
attachments[].data | string | No | Base64-encoded file data (when type is base64) |
attachments[].mimeType | string | No | MIME type of the attachment |
attachments[].filename | string | No | Original filename |
attachments[].isAudioMessage | boolean | No | Render as a native voice-memo bubble (inline player). Supported on iMessage and WhatsApp; the server transcodes non-compatible inputs to the channel’s preferred format (Opus-in-CAF for iMessage, Opus-in-OGG for WhatsApp). Other channels deliver the file as a generic attachment. |
effect | string | No | Message effect (iMessage only) |
scheduledAt | string | No | ISO 8601 datetime for scheduled delivery |
idempotencyKey | string | No | Unique key for deduplication |
callbackUrl | string | No | Per-message webhook URL |
metadata | object | No | Custom key-value data |