MessagesSend Messages

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, or attachments[]. You can send an attachment-only message — voice note, image with no caption, etc. — by omitting content.text or passing an empty string. Sending a request with no text, no mediaUrls, and no attachments[] returns 400.

UseFieldChannelsSource typesNotes
Send a file (photo, video, PDF, audio, any MIME type) over iMessageattachments[]iMessage onlytype: "url" (HTTPS) or type: "base64"Full iMessage attachment semantics. Required for non-image files.
Send an image over SMS/MMScontent.mediaUrlsSMS/MMS (and iMessage as image carousel when 2+ URLs)HTTPS image URLs onlyThe carrier fetches the URL and attaches the image as MMS.
Send an image carousel over iMessagecontent.mediaUrls (2–20 URLs)iMessageHTTPS image URLsAutomatically detected as carousel when 2+ URLs are provided.

Decision tree

  1. Is the recipient on SMS only (no iMessage)? Use content.mediaUrls. attachments[] is ignored on the SMS path.
  2. Do you need to send a non-image file (PDF, video, audio, vCard, etc.)? Use attachments[] with type: "url" or type: "base64". SMS/MMS cannot carry arbitrary file types in all carrier networks — check capabilities first.
  3. Do you want to send 2+ images as a carousel? Use content.mediaUrls. Do not use attachments[] for this — only mediaUrls triggers carousel messageType.
  4. Do you have raw bytes (not a public URL) and need to send over iMessage? Use attachments[] with type: "base64".
  5. Everything else (single image over iMessage with an HTTPS URL): either works; prefer attachments[] for explicit control over mimeType and filename.

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"
      }
    ]
  }'

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 emits message.delivered on a best-effort basis (depends on the carrier returning a receipt), and SMS “reactions” arrive as plain message.inbound text rather than message.reaction. See Per-Channel Reliability for the full matrix before relying on a specific event for a given channel.

WhatsApp

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

FieldTypeRequiredDescription
tostringYesRecipient phone number (E.164) or iMessage email address
fromstringNoSender address (must be authorized for your customer; uses default if omitted)
content.textstringNoMessage text (up to 10,000 chars). Optional if content.mediaUrls is provided
content.mediaUrlsstring[]NoArray of 1-20 HTTPS image URLs. When 2+ URLs are provided, sends as an image carousel
routing.preferencestring[]NoChannel priority (default: ["imessage", "sms"])
routing.fallbackbooleanNoEnable fallback (default: true)
replyTostringNoMessage ID to reply to (iMessage only)
attachmentsobject[]NoFile attachments (iMessage only)
attachments[].typestringNoAttachment type: "url" or "base64"
attachments[].urlstringNoURL of the attachment (when type is url)
attachments[].datastringNoBase64-encoded file data (when type is base64)
attachments[].mimeTypestringNoMIME type of the attachment
attachments[].filenamestringNoOriginal filename
attachments[].isAudioMessagebooleanNoRender 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.
effectstringNoMessage effect (iMessage only)
scheduledAtstringNoISO 8601 datetime for scheduled delivery
idempotencyKeystringNoUnique key for deduplication
callbackUrlstringNoPer-message webhook URL
metadataobjectNoCustom key-value data