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).
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 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": "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) over iMessage | attachments[] | iMessage only | type: "url" (HTTPS) or type: "base64" | Full iMessage attachment semantics. Required for non-image files. |
| Send an image over SMS/MMS | content.mediaUrls | SMS/MMS (and iMessage as image carousel when 2+ URLs) | HTTPS image URLs only | The carrier fetches the URL and attaches the image as MMS. |
| Send an image carousel over iMessage | content.mediaUrls (2–20 URLs) | iMessage | HTTPS image URLs | Automatically detected as carousel when 2+ URLs are provided. |
Decision tree
- Is the recipient on SMS only (no iMessage)? Use
content.mediaUrls.attachments[]is ignored on the SMS path. - Do you need to send a non-image file (PDF, video, audio, vCard, etc.)? Use
attachments[]withtype: "url"ortype: "base64". SMS/MMS cannot carry arbitrary file types in all carrier networks — check capabilities first. - Do you want to send 2+ images as a carousel? Use
content.mediaUrls. Do not useattachments[]for this — onlymediaUrlstriggers carousel messageType. - Do you have raw bytes (not a public URL) and need to send over iMessage? Use
attachments[]withtype: "base64". - Everything else (single image over iMessage 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[] wins on the iMessage path and mediaUrls wins on the SMS path, which results in 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 iMessage 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.
Accepted formats: any audio (MP3, Opus/AAC/PCM in CAF, webm, m4a, ogg, wav, …). The server normalizes non-compatible inputs to Opus-in-CAF — the format iMessage’s voice-memo UI can read. If you can, encode client-side to Opus-in-CAF to skip the extra server transcode.
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.
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 | Yes | Recipient phone number (E.164) or iMessage email address |
from | string | No | Sender address (must be authorized for your customer; uses default if omitted) |
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 only) |
attachments | object[] | No | File attachments (iMessage only) |
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 iMessage voice-memo bubble (inline player). Accepts any audio format — the server transcodes non-compatible inputs (anything except Opus-in-CAF) to Opus-in-CAF. iMessage only. |
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 |