# TextBubbles API — Complete Documentation > A unified REST API for sending iMessages with automatic SMS fallback. Base URL: https://api.textbubbles.com Authentication: Bearer token in Authorization header Rate Limit: 100 requests/minute per API key --- # Getting Started Get up and running with TextBubbles in under 5 minutes. ## 1. Get Your API Key Contact your account administrator to obtain an API key. Keys follow the format `tb_xxxxxxxxxxxxx`. Each API key is tied to a specific customer account, which determines your authorized sender addresses and data isolation. API keys are hashed on the server — save your key immediately, as it cannot be retrieved later. ## 2. Send Your First Message ```bash 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 from TextBubbles!" } }' ``` Response (202 Accepted): ```json { "success": true, "data": { "id": "msg_550e8400-e29b-41d4-a716-446655440000", "status": "queued", "to": "+14155551234", "createdAt": "2026-03-28T10:00:00.000Z" }, "requestId": "req_550e8400-e29b-41d4-a716-446655440000" } ``` ## 3. Check Message Status ```bash curl https://api.textbubbles.com/v1/messages/msg_550e8400-e29b-41d4-a716-446655440000 \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```json { "success": true, "data": { "id": "msg_550e8400-e29b-41d4-a716-446655440000", "status": "delivered", "to": "+14155551234", "from": "+19876543210", "channel": "imessage", "content": { "text": "Hello from TextBubbles!" }, "timeline": [ { "status": "queued", "at": "2026-03-28T10:00:00Z", "channel": null }, { "status": "sent", "at": "2026-03-28T10:00:01Z", "channel": "imessage" }, { "status": "delivered", "at": "2026-03-28T10:00:03Z", "channel": "imessage" } ], "fallbackTriggered": false, "createdAt": "2026-03-28T10:00:00Z" } } ``` ## 4. Check Capabilities Before using iMessage-specific features, verify the recipient supports iMessage: ```bash curl https://api.textbubbles.com/v1/capabilities/+14155551234 \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```json { "success": true, "data": { "phoneNumber": "+14155551234", "capabilities": { "imessage": true, "sms": true, "facetime": true }, "focused": false, "recommendedChannel": "imessage", "lastChecked": "2026-03-28T09:55:00.000Z", "cached": true } } ``` ## 5. Set Up Webhooks Register one or more webhooks to receive real-time delivery updates and inbound messages. Each has its own URL, event subscription list, and signing secret: ```bash curl -X POST https://api.textbubbles.com/v1/webhooks \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-server.com/webhooks/textbubbles", "events": ["message.sent", "message.delivered", "message.inbound"] }' ``` If you omit `secret`, the API generates one and returns it in the response — store it immediately, it cannot be retrieved later. --- # Authentication All TextBubbles API requests require a Bearer token in the Authorization header. Each API key is associated with a specific customer account, providing full data segregation between customers. ## Bearer Token ``` Authorization: Bearer tb_xxxxxxxxxxxxx ``` Example request: ```bash curl https://api.textbubbles.com/v1/messages \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## Customer-Scoped API Keys Every API key is tied to a customer account. When you authenticate, the API automatically scopes all operations to your customer: - Messages — you can only see and send messages belonging to your customer - Webhooks — each customer has their own webhook configuration - Addresses — you can only send from phone numbers authorized for your customer If an API key has no customer association, all endpoints return 403 Forbidden. ## Address Authorization When sending messages, the `from` parameter must be a phone number authorized for your customer account. If omitted, the default authorized address is used. Sending from an unauthorized address returns a 403: ```json { "success": false, "error": { "code": "ADDRESS_NOT_AUTHORIZED", "message": "The 'from' address is not authorized for this customer" }, "requestId": "req_xyz" } ``` ## API Key Security API keys are hashed with bcrypt before storage. The plaintext key is returned only once at creation time and cannot be retrieved later. If you lose your key, contact your account administrator to generate a new one. API keys use the prefix `tb_` followed by a unique identifier. ## Error Responses 401 Unauthorized — Missing or invalid token: ```json { "success": false, "error": { "code": "UNAUTHORIZED", "message": "Missing or invalid Authorization header" }, "requestId": "req_xyz" } ``` 403 Forbidden — Valid key but no customer association: ```json { "success": false, "error": { "code": "FORBIDDEN", "message": "API key has no associated customer" }, "requestId": "req_xyz" } ``` ## Rate Limiting Each API key has its own rate limits. When exceeded, you'll receive a 429 Too Many Requests response with a `Retry-After` header: ```json { "success": false, "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests. Please retry after 60 seconds." }, "requestId": "req_xyz" } ``` ### Rate Limit Headers Every API response includes rate limit information: | Header | Description | |--------|-------------| | X-RateLimit-Limit | Maximum requests per window (100) | | X-RateLimit-Remaining | Requests remaining in current window | | X-RateLimit-Reset | Unix timestamp when the window resets | | Retry-After | Seconds to wait (only on 429 responses) | Implement exponential backoff when handling rate limits: ```javascript async function sendWithRetry(payload, maxRetries = 3) { for (let attempt = 0; attempt <= maxRetries; attempt++) { const response = await fetch('https://api.textbubbles.com/v1/messages', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); if (response.status !== 429) { return response.json(); } const retryAfter = response.headers.get('Retry-After') || Math.pow(2, attempt) * 10; await new Promise(resolve => setTimeout(resolve, retryAfter * 1000)); } throw new Error('Max retries exceeded'); } ``` ## Request IDs Every API response includes a `requestId` field. You can also pass your own via the `X-Request-Id` header. Log these for debugging. --- # Send Messages Send iMessages with automatic SMS fallback via POST /v1/messages. ## Basic Message ```bash 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`). To send into an existing chat instead, pass `chatId` (a conversation UUID) — see "Sending to a Chat" below. ## Sender Address (from) By default, messages are sent from your customer's default authorized address. Specify a different authorized address with the `from` field: ```bash 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. ## With Effects (iMessage Only) ```bash 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" }' ``` Available screen effects: fireworks, balloons, confetti, lasers, spotlight Available bubble effects: slam, loud, gentle, invisibleInk, echo, love ## Reply to a Message Create a threaded reply using the `replyTo` field (iMessage and WhatsApp): ```bash 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? Text is optional when sending 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. | 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: 1. Need a non-image file (PDF, video, audio, vCard, etc.) or raw bytes (`type: "base64"`)? Use `attachments[]`. SMS/MMS cannot carry arbitrary file types in all carrier networks — check capabilities first. 2. Want 2+ images to arrive as a single grouped bubble with the text caption inline? Use `content.mediaUrls`. Multiple `attachments[]` also arrives as a grouped gallery on iMessage, but the text is sent as a separate bubble before the gallery. 3. Everything else (single image with an HTTPS URL): either works; prefer `attachments[]` for explicit control over `mimeType` and `filename`. 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, producing different payloads depending on which channel the router picks — almost never what you want. ## With Attachments URL attachment (iMessage): ```bash 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 URLs (SMS/MMS): ```bash 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): - iMessage: MP3 or Opus-in-CAF - WhatsApp: Opus-in-OGG Any other audio format is transcoded server-side to the channel's preferred format before send. ```bash 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): - iMessage: any common container (MP4, MOV, M4V) — no server-side transcoding - WhatsApp: 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): ```bash 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: ```bash 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: ```json { "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 the Per-Channel Reliability matrix below before relying on a specific event for a given channel. ### WhatsApp Routing Include `"whatsapp"` in the preference list. The WhatsApp send uses the same phone number as your iMessage/SMS line — pairing is per-number and managed via the admin panel or the `/v1/whatsapp/**` API. ```json { "to": "+14155551234", "content": { "text": "Hello from WhatsApp!" }, "routing": { "preference": ["whatsapp"] } } ``` Fall back from WhatsApp to iMessage/SMS: ```json { "routing": { "preference": ["whatsapp", "imessage", "sms"] } } ``` Send to an existing WhatsApp group by passing the group JID as `to`: ```json { "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. ## Unsend a Message Retract a sent iMessage: ```bash curl -X POST https://api.textbubbles.com/v1/messages/msg_abc123/unsend \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## Retry a Failed Message — POST /v1/messages/:id/retry Re-send a message that previously landed in `status: failed` (for example, after a transient delivery failure). Only `failed` messages can be retried — `queued`, `sent`, or any other status returns `400 INVALID_STATUS`. ```bash curl -X POST https://api.textbubbles.com/v1/messages/msg_abc123/retry \ -H "Authorization: Bearer YOUR_API_KEY" ``` Response (`202 Accepted`): ```json { "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. This endpoint is idempotent to call multiple times; `retryCount` increments on each invocation. Standard `message.queued` → `message.sent` / `message.failed` webhooks fire for the outcome. ## Edit a Message Update sent message content (iMessage only): ```bash 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, max 10,000 chars) - `backwardsCompatibilityMessage` — fallback text shown on older devices (optional) ## Idempotency Include an `idempotencyKey` to prevent duplicate sends during retries: ```json { "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. ## Sending to a Chat — POST /v1/messages with chatId Send into an existing chat (group or 1:1) by passing `chatId` instead of `to`. The `chatId` value is the `chat.chatId` UUID returned from `POST /v1/chats/groups` or `GET /v1/chats/:chatId`. Exactly one of `to` or `chatId` must be provided. ```bash 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): ```json { "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 `chatId` is used: - The 202 response carries `conversationId` (same UUID) in place of `to`. - The channel and sender instance are pinned to the chat; `routing.preference` and `routing.fallback` are ignored. - If `from` is supplied, it must resolve to the chat's pinned instance — a mismatch returns 400 FROM_INSTANCE_CANNOT_REACH. - An unknown or foreign `chatId` returns 404 CONVERSATION_NOT_FOUND. - Supplying both `to` and `chatId`, or neither, returns 400 INVALID_REQUEST. - Group send on SMS is not supported; returns 400 GROUP_SMS_UNSUPPORTED. - `createContact` is ignored (no single recipient). Create a group: ```bash curl -X POST https://api.textbubbles.com/v1/chats/groups \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "participants": ["+14155551234", "+14155559876"], "name": "Launch Team" }' ``` Response data: `{ chat: ChatFragment, createdAt }`. `chat.chatId` is the conversation UUID — pass it as the `chatId` field on `POST /v1/messages`. `chat.providerChatGuid` is the provider chat identifier — use it as the path parameter on `/v1/chats/:chatId/*` (rename, add/remove participant, leave). See "The chat fragment" below for the full field list. ## Full Request Schema — POST /v1/messages Every request must include at least one of `content.text` (non-empty), `content.mediaUrls`, or `attachments[]`. A request with no text, no mediaUrls, and no attachments returns 400. | 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` from 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; 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 or attachments[] is provided. May be omitted or empty for attachment-only messages (voice notes, images with no caption). | | 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"]). Allowed values: "imessage", "sms", "whatsapp" | | 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 attachment (when type is "url") | | attachments[].data | string | No | Base64-encoded file data (when type is "base64") | | attachments[].mimeType | string | No | MIME type (e.g., image/jpeg) | | attachments[].filename | string | No | Original filename | | attachments[].isAudioMessage | boolean | No | Render as a native voice-memo bubble (inline player) instead of a generic file attachment. Supported on iMessage and WhatsApp; the server normalizes non-compatible inputs to the channel's preferred format (Opus-in-CAF for iMessage, Opus-in-OGG for WhatsApp) before forwarding. Other channels deliver the file as a generic attachment. | | effect | string | No | Message effect (iMessage only): slam, loud, gentle, invisibleInk, confetti, fireworks, lasers, love, balloons, spotlight, echo | | scheduledAt | string | No | ISO 8601 datetime for scheduled delivery (max 30 days ahead) | | idempotencyKey | string | No | Unique key for deduplication (max 255 chars) | | callbackUrl | string | No | Per-message webhook URL | | metadata | object | No | Custom key-value data | ## Message Statuses | Status | Description | |--------|-------------| | queued | Message accepted, waiting for delivery | | pending | Being processed for delivery | | scheduled | Queued for future delivery | | sent | Sent to the carrier/provider | | delivered | Confirmed delivered to recipient | | read | Read receipt received (iMessage only) | | failed | Delivery failed | | unsent | Message was unsent/retracted | --- # List Messages Retrieve messages and check delivery status. ## List All Messages ```bash curl https://api.textbubbles.com/v1/messages \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Query Parameters | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | limit | integer | No | 50 | Results per page (1-100) | | cursor | string | No | — | Pagination cursor from previous response | | status | string | No | — | Filter by status: queued, pending, sent, delivered, read, failed, unsent, scheduled | | to | string | No | — | Filter by recipient (E.164 format) | Response: ```json { "success": true, "data": { "messages": [], "pagination": { "hasMore": true, "nextCursor": "eyJ..." } }, "requestId": "req_abc123" } ``` Pass `pagination.nextCursor` as the `cursor` parameter to fetch the next page. ## Get Message Status ```bash curl https://api.textbubbles.com/v1/messages/msg_550e8400-e29b-41d4-a716-446655440000 \ -H "Authorization: Bearer YOUR_API_KEY" ``` Response: ```json { "success": true, "data": { "id": "msg_550e8400-e29b-41d4-a716-446655440000", "status": "delivered", "to": "+14155551234", "from": "+19876543210", "channel": "imessage", "content": { "text": "Hello from TextBubbles!" }, "timeline": [ { "status": "queued", "at": "2026-03-28T10:00:00Z", "channel": null }, { "status": "sent", "at": "2026-03-28T10:00:01Z", "channel": "imessage" }, { "status": "delivered", "at": "2026-03-28T10:00:03Z", "channel": "imessage" } ], "fallbackTriggered": false, "metadata": null, "externalId": "external-guid-123", "errorCode": null, "errorMessage": null, "createdAt": "2026-03-28T10:00:00Z" }, "requestId": "req_abc123" } ``` ## Inbound Messages Inbound messages are delivered via the `message.inbound` webhook event. Subscribe to `message.inbound` in your webhook configuration. ### Verification / OTP codes are suppressed Inbound messages detected as one-time passcodes, 2FA codes, password-reset codes, or other verification codes (e.g. Apple ID, Google, bank account codes) are NOT surfaced through the API: - not dispatched as `message.inbound` webhooks - not delivered via the `/v1/events` stream - not returned by `GET /v1/messages` or `GET /v1/messages/:id` ## Message Endpoints | Method | Path | Description | |--------|------|-------------| | GET | /v1/messages | List messages (including inbound) | | GET | /v1/messages/:id | Get message status | | DELETE | /v1/messages/:id | Soft-delete a message | --- # Scheduled Messages Schedule messages for future delivery, up to 30 days ahead. The scheduler checks every minute for messages ready to send. ## Schedule a Message Include `scheduledAt` with an ISO 8601 datetime when sending: ```bash 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": "Happy Birthday!" }, "scheduledAt": "2026-04-01T09:00:00Z" }' ``` The response returns `status: "scheduled"` instead of `"queued"`. ## List Scheduled Messages ```bash curl https://api.textbubbles.com/v1/messages/scheduled \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## Cancel a Scheduled Message ```bash curl -X DELETE https://api.textbubbles.com/v1/messages/msg_abc123/schedule \ -H "Authorization: Bearer YOUR_API_KEY" ``` Only messages with status "scheduled" can be cancelled. ## Constraints - scheduledAt must be a valid ISO 8601 datetime in the future - Maximum scheduling window is 30 days - All other message options (effects, attachments, etc.) work with scheduled messages --- # Tapback Reactions Send emoji reactions to messages via POST /v1/messages/:id/reactions. Tapbacks are an iMessage-only feature; reactions on WhatsApp use the same endpoint but map differently (see WhatsApp section). The `:id` in the URL is the internal TextBubbles message ID (e.g. `msg_abc123`) — not the provider GUID. For outbound messages this is the ID returned from POST /v1/messages. For inbound messages received via the `message.inbound` webhook, use the `messageId` field in the webhook payload (not `externalMessageId`). ```bash curl -X POST https://api.textbubbles.com/v1/messages/msg_abc123/reactions \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "love"}' ``` Response: ```json { "success": true, "data": { "messageId": "msg_abc123", "reaction": "love" }, "requestId": "req_xyz" } ``` ## Available Reactions | Type | Emoji | Description | |------|-------|-------------| | love | ❤️ | Heart | | like | 👍 | Thumbs up | | dislike | 👎 | Thumbs down | | laugh | 😂 | Laughing | | emphasize | ‼️ | Exclamation marks | | question | ❓ | Question mark | ## Remove a Reaction To remove a reaction you previously sent, POST to the same endpoint with the negative form of the type: `-love`, `-like`, `-dislike`, `-laugh`, `-emphasize`, `-question`. ``` curl -X POST https://api.textbubbles.com/v1/messages/msg_abc123/reactions \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "-love"}' ``` You can only remove a reaction that you previously added via the API. Notes: - iMessage only — returns 400 CHANNEL_NOT_SUPPORTED for SMS - One reaction per message per sender - To remove, send the negative form (e.g. -love) - Emoji/sticker reactions require iOS 17+ or macOS 14+ --- # Message Effects Add visual effects to iMessage messages using the `effect` field. Effects are silently ignored if the message falls back to SMS. ## Bubble Effects Bubble effects apply to the message bubble itself. | Effect | Description | |--------|-------------| | slam | Message slams down onto the screen | | loud | Message appears large and shakes | | gentle | Message fades in softly | | invisibleInk | Message is hidden until swiped | ## Screen Effects Screen effects animate the entire conversation view. | Effect | Description | |--------|-------------| | echo | Message echoes across the screen in copies | | spotlight | Spotlight highlights the message | | balloons | Colorful balloons float up | | confetti | Confetti rains down | | love | Giant heart animation | | lasers | Laser light show | | fireworks | Fireworks burst across the screen | --- # Webhook Configuration Register one or more webhook endpoints to receive real-time delivery updates and inbound messages. Each customer can register any number of webhooks; each has its own URL, event subscription list, optional name, and own signing secret. A single event is fanned out to every webhook that subscribed to that event type. Webhooks are unique per `(URL, event set)` for a given customer — creating a second webhook with the same URL and exact same event list returns `409 WEBHOOK_DUPLICATE`. Different URLs or different event sets are always allowed. ## Register a Webhook ```bash curl -X POST https://api.textbubbles.com/v1/webhooks \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "TextBubbles Events", "url": "https://your-server.com/webhooks/textbubbles", "events": [ "message.queued", "message.sent", "message.delivered", "message.failed", "message.inbound" ] }' ``` Response: ```json { "success": true, "data": { "id": "b1a9e4e6-...-9f21", "name": "TextBubbles Events", "url": "https://your-server.com/webhooks/textbubbles", "events": ["message.delivered", "message.failed"], "active": true, "secret": "whsec_generated_signing_secret_value", "createdAt": "2026-04-21T10:00:00.000Z", "updatedAt": "2026-04-21T10:00:00.000Z" } } ``` The `secret` field is returned only on creation and only when you did not supply one. Store it immediately — it cannot be retrieved later. Body fields: - `url` (required) — HTTPS URL to receive webhook POST requests - `events` (required) — array of event types; use `["*"]` to subscribe to all - `name` (optional) — human-readable label, up to 100 characters - `secret` (optional) — your own HMAC-SHA256 signing secret. If omitted one is generated and returned once ## Wildcard Events Subscribe to every event type with `*`: ```bash curl -X POST https://api.textbubbles.com/v1/webhooks \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-server.com/webhooks/textbubbles", "events": ["*"] }' ``` ## List Webhooks ```bash curl https://api.textbubbles.com/v1/webhooks/list \ -H "Authorization: Bearer YOUR_API_KEY" ``` Returns an array of your registered webhooks. Secrets are never returned in list or get responses. ## Get a Single Webhook ```bash curl https://api.textbubbles.com/v1/webhooks/{id} \ -H "Authorization: Bearer YOUR_API_KEY" ``` Returns `404 WEBHOOK_NOT_FOUND` if the webhook does not exist or does not belong to your customer. ## Update a Webhook ```bash curl -X PATCH https://api.textbubbles.com/v1/webhooks/{id} \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "events": ["message.delivered", "message.failed"] }' ``` Updatable fields: `url`, `events`, `secret`, `name`, `active`. Setting `active: false` pauses the webhook without deleting it. Returns `409 WEBHOOK_DUPLICATE` on collision, `404 WEBHOOK_NOT_FOUND` if not yours. ## Delete a Webhook ```bash curl -X DELETE https://api.textbubbles.com/v1/webhooks/{id} \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## Send a Test Event to a Webhook ```bash curl -X POST https://api.textbubbles.com/v1/webhooks/{id}/test \ -H "Authorization: Bearer YOUR_API_KEY" ``` Delivers a synthetic `webhook.test` event to one specific webhook, signed with its own secret. ## Rotate a Webhook's Signing Secret ```bash curl -X POST https://api.textbubbles.com/v1/webhooks/{id}/rotate-secret \ -H "Authorization: Bearer YOUR_API_KEY" ``` Returns the new secret plaintext in the response. It cannot be retrieved later — store it immediately. ## Legacy Single-Webhook Endpoints (deprecated) These operate against your first active webhook (oldest `createdAt`) and are kept for backward compatibility: - `GET /v1/webhooks` — returns the first active webhook or `null` - `PUT /v1/webhooks` — deactivates all your active webhooks and creates a single new one - `DELETE /v1/webhooks` — deactivates all your active webhooks - `POST /v1/webhooks/test` — tests the first active webhook - `POST /v1/webhooks/rotate-secret` — rotates the first active webhook's secret New integrations should use the id-scoped endpoints above. ## Signature Verification Every webhook request includes two headers: - X-Signature — `sha256={hmac_hex}` HMAC-SHA256 signature - X-Timestamp — Unix timestamp (seconds) when the webhook was sent Each webhook signs with its own secret — the one you supplied on create, or the one the API generated and returned once. If you have multiple webhooks, each endpoint must verify using that specific webhook's secret. ### Correlation Header Every webhook delivery also carries: - X-Correlation-Id — `cor_{32 hex chars}` correlation id that ties the delivery back to the originating message lifecycle. The same id is accepted on every API request (you can supply your own; one is minted otherwise) and echoed on every API response, so you can stitch your logs to ours. Treat it as opaque. If you supply one it must match `cor_<32 hex chars>`; anything else is replaced with a freshly minted id. ### Verification Steps 1. Extract the timestamp and signature from the headers 2. Read the exact raw request body bytes — do NOT parse and re-serialize JSON, as key order or whitespace differences will break the signature 3. Construct the signed payload string: `{timestamp}.{raw_request_body}` (a literal `.` between the timestamp and body) 4. Compute `HMAC-SHA256(secret, signedPayload)` and hex-encode the digest 5. Prepend `sha256=` and compare to `X-Signature` using constant-time comparison 6. Reject requests where `|now - timestamp| > 300` seconds to prevent replay attacks ### Algorithm Reference ``` signedPayload = timestamp + "." + rawRequestBody expected = "sha256=" + lowerHex(HMAC_SHA256(secret, signedPayload)) valid = constantTimeEqual(expected, X-Signature) AND abs(now - timestamp) <= 300 ``` ### Unit Test Fixture Use this known-good fixture to verify your implementation. If your code produces the expected signature below, your verifier is correct. | Field | Value | |-------|-------| | secret | `whsec_test_secret_do_not_use_in_production` | | X-Timestamp | `1774699203` | | Raw body | `{"id":"evt_550e8400-e29b-41d4-a716-446655440000","type":"message.delivered","timestamp":"2026-03-28T10:00:03.000Z","data":{"messageId":"msg_xyz","externalMessageId":"external-guid","from":"+19876543210","to":"+14155551234","text":"Hello!","channel":"imessage","status":"delivered"}}` | | Expected X-Signature | `sha256=d055c034071c12e906654f864c1e5a03fbdea2399444cdf4448f35bf81218977` | ### Common Verification Pitfalls - Re-serializing parsed JSON. `JSON.stringify(req.body)` or `json.dumps(request.json())` will almost never reproduce the exact bytes the API signed. Always verify against the raw body. - Wrong separator. The signed string is `{timestamp}.{body}` — a literal ASCII period between timestamp and body, with no spaces. - Millisecond vs second timestamp. `X-Timestamp` is Unix seconds. Dividing `Date.now()` by 1000 and flooring gives the right unit. - Forgetting the `sha256=` prefix in the comparison string. - Using the wrong webhook's secret. If you register multiple webhooks that point at different endpoints, each one has its own secret. Verify each endpoint using the secret you received when creating (or rotating) that specific webhook. ### Node.js Example ```javascript import crypto from 'crypto'; import express from 'express'; const app = express(); app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })); const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; function verifySignature(req) { const signature = req.headers['x-signature']; const timestamp = req.headers['x-timestamp']; if (!signature || !timestamp) return false; // Reject requests older than 5 minutes const age = Math.abs(Date.now() / 1000 - parseInt(timestamp)); if (age > 300) return false; const signedPayload = `${timestamp}.${req.rawBody}`; const expected = 'sha256=' + crypto .createHmac('sha256', WEBHOOK_SECRET) .update(signedPayload) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) ); } app.post('/webhooks/textbubbles', (req, res) => { if (!verifySignature(req)) { return res.status(401).json({ error: 'Invalid signature' }); } const { type, data } = req.body; switch (type) { case 'message.delivered': console.log(`Message ${data.messageId} delivered via ${data.channel}`); break; case 'message.failed': console.log(`Message ${data.messageId} failed: ${data.status}`); break; case 'message.inbound': console.log(`Inbound from ${data.from}: ${data.text}`); break; } res.status(200).json({ received: true }); }); app.listen(3000); ``` ### Python Example ```python import hmac import hashlib import time def verify_webhook(payload_body, signature, timestamp, secret): age = abs(time.time() - int(timestamp)) if age > 300: return False signed_payload = f"{timestamp}.{payload_body}" expected = "sha256=" + hmac.new( secret.encode(), signed_payload.encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(signature, expected) ``` ## Retry Policy Your endpoint must respond with a 2xx status code within 10 seconds. Other responses are classified as either retryable or terminal: | Response | Treated as | Behavior | |---|---|---| | 2xx | Success | Delivery recorded as `delivered`. Resets the consecutive-failure streak. | | 408 Request Timeout, 429 Too Many Requests | Retryable | Retried with exponential backoff (see table). Counts toward auto-deactivation if retries exhaust. | | Any other 4xx (400, 401, 403, 404, 410, 422, …) | Terminal | Recorded once as `failed`. Not retried. Does not count toward auto-deactivation. | | 5xx, network errors (timeout, DNS, TCP reset) | Retryable | Same as 408/429. | To acknowledge an event you intentionally skip (e.g. unknown resource, ignored event type), return 200 with a body like `{"success": true, "message": "ignored"}`. Returning 4xx will drop the event from this webhook's queue. Retryable retry schedule (exponential backoff): | Attempt | Delay | |---------|-------| | 1 | 30 seconds | | 2 | 5 minutes | | 3 | 30 minutes | | 4 | 2 hours | | 5 | 8 hours | | 6 | 24 hours | | 7 | 24 hours (final) | Auto-deactivation: when three consecutive deliveries to the same webhook each exhaust the full retry chain, the webhook is automatically deactivated (`active=false`). Any 2xx delivery resets the counter. Terminal 4xx responses do not contribute to the counter. ## Best Practices - Return 200 quickly — process events asynchronously - Handle duplicates — use the `id` field to deduplicate events - Verify signatures — always validate X-Signature before processing - Use per-message callbacks — use `callbackUrl` field for routing to different systems --- # Webhook Event Reference All webhook event types and their payloads. ## Payload Structure Every webhook event follows this structure: ```json { "id": "evt_550e8400-e29b-41d4-a716-446655440000", "type": "message.delivered", "timestamp": "2026-03-28T10:00:03.000Z", "data": { "messageId": "msg_xyz", "externalMessageId": "external-guid", "from": "+19876543210", "to": "+14155551234", "text": "Hello!", "channel": "imessage", "metadata": { "customKey": "customValue" }, "status": "delivered" } } ``` ## Message Events | Event | Description | |-------|-------------| | message.queued | Message accepted and queued for delivery | | message.sent | Message sent to the carrier/provider | | message.delivered | Delivery confirmed by the recipient's device (iMessage, WhatsApp) | | message.read | Read receipt received (iMessage and WhatsApp — WhatsApp only when the recipient has read receipts enabled) | | message.failed | Message delivery failed | | message.fallback | Fallback triggered (e.g., iMessage to SMS) | | message.inbound | Inbound message received (verification/OTP codes are suppressed — see Inbound Messages section) | | message.reaction | Tapback or emoji reaction added or removed | | message.audio_kept | Contact tapped "Keep" on a voice note you sent (iMessage only) | | message.unsent | Message was unsent/retracted | | message.edited | Message was successfully edited (iMessage only) | | message.edit_failed | Message edit failed (iMessage only) | | message.deleted | Message was soft-deleted | | typing.indicator | Recipient is typing (WhatsApp only — see Typing Indicator section) | | whatsapp.status | WhatsApp session changed pairing state (`qr_pending` / `connecting` / `connected` / `disconnected`). Realtime-only — not sent to webhook URLs; subscribe via the Realtime/SSE stream | ## Status Event Payloads Delivery-status events (`message.sent`, `message.delivered`, `message.read`, `message.failed`, etc.) all share the same shape: ```json { "id": "evt_abc123", "type": "message.read", "timestamp": "2026-03-28T10:00:07.000Z", "data": { "messageId": "msg_xyz789", "externalMessageId": "AB12CD34-5678-90EF-1234-567890ABCDEF", "channel": "imessage", "readAt": "2026-03-28T10:00:07.000Z" } } ``` | Field | Description | |-------|-------------| | messageId | Internal TextBubbles message ID | | externalMessageId | Provider-level GUID | | channel | `imessage`, `sms`, or `whatsapp` | | readAt | ISO-8601 timestamp (present on `message.read` only) | `message.read` fires for iMessage and WhatsApp when the recipient has read receipts enabled on their device. It is a distinct event from `message.delivered` — do not collapse them when tracking delivery state. SMS does not emit `message.read`. ## Per-Channel Reliability Not every event fires for every channel. Use this matrix when branching on channel — do not assume parity. | Event | iMessage | SMS | WhatsApp | |---|---|---|---| | message.sent | reliable | reliable | reliable | | message.delivered | reliable | best-effort (carrier-dependent) | reliable | | message.read | when recipient has read receipts enabled | not emitted | when recipient has read receipts enabled | | message.failed | reliable | reliable | reliable | | message.fallback | reliable (iMessage → SMS) | n/a | n/a | | message.inbound | reliable | reliable | reliable | | message.reaction (from contact) | reliable | not native — arrives as inbound text "Loved 'X'" | reliable | | typing.indicator | not emitted | not emitted | reliable | Notes: - SMS message.delivered depends on the carrier returning a delivery receipt; some carriers and message types never produce one. Treat message.sent as the only reliable success signal for SMS; rely on message.failed for explicit failures. - message.read requires the recipient to have read receipts enabled. iMessage allows per-conversation toggling; WhatsApp is global. - SMS has no native reaction protocol. When an SMS user reacts on iOS, their phone sends the reaction as a plain text message; TextBubbles surfaces it as a normal message.inbound. Sending a reaction targeting an SMS message via POST /v1/messages/:id/reactions returns CHANNEL_NOT_SUPPORTED. ## Chat Events Fired when a chat-state action occurs on an iMessage or WhatsApp group chat. | Event | Description | |-------|-------------| | chat.participant.added | Participant added to a group chat | | chat.participant.left | Participant removed from (or left) a group chat | | chat.title.changed | Group chat title set or changed | | chat.photo.changed | Group chat photo updated (iMessage only) | Single-emit dedup: when a customer initiates a rename / add / remove via /v1/chats/* AND the upstream provider fires its own state-change event for the same change, the corresponding webhook (chat.title.changed / chat.participant.added / chat.participant.left) is emitted exactly once. Dedup window is 60 seconds. Implement handlers to be idempotent on `eventId` regardless. ## Scheduled Message Events | Event | Description | |-------|-------------| | message.scheduled | Message scheduled for future delivery | | message.schedule_cancelled | Scheduled message was cancelled | ## FaceTime Events | Event | Description | |-------|-------------| | facetime.incoming | Incoming FaceTime call detected | | facetime.status_changed | Call status update (answered, disconnected, etc.) | ### facetime.incoming Fired when an incoming FaceTime call is detected on an instance. ```json { "type": "facetime.incoming", "eventId": "ft-event-uuid", "callUuid": "call-uuid-123", "caller": "+14155551234", "to": "+19876543210", "isAudio": false, "isVideo": true, "timestamp": 1711584000000 } ``` ### facetime.status_changed ```json { "type": "facetime.status_changed", "eventId": "ft-event-uuid", "callUuid": "call-uuid-123", "address": "+14155551234", "to": "+19876543210", "status": "answered", "isAudio": false, "isVideo": true, "isOutgoing": false } ``` ### FaceTime Field Reference | Field | Description | |-------|-------------| | callUuid | Unique identifier for the call session | | caller / address | Phone number or email of the other party. facetime.incoming uses caller; facetime.status_changed uses address | | to | Phone number or email of the receiving iMessage instance | | isAudio / isVideo | FaceTime Audio: isAudio=true, isVideo=false. FaceTime Video: isAudio=false, isVideo=true | | status | Call status: incoming, answered, disconnected, missed, rejected, failed | | isOutgoing | Whether the call was initiated by the instance (true) or received (false) | | timestamp | Unix epoch milliseconds when the call was detected. Present on facetime.incoming events | ## Inbound Message Payload ```json { "id": "evt_abc123", "type": "message.inbound", "timestamp": "2026-03-28T10:00:00.000Z", "data": { "messageId": "msg_xyz789", "externalMessageId": "AB12CD34-5678-90EF-1234-567890ABCDEF", "from": "+14155551234", "to": "+19876543210", "text": "Check out this photo", "channel": "imessage", "parentMessageId": null, "attachments": [ { "guid": "att_550e8400-e29b-41d4-a716-446655440000", "mimeType": "image/jpeg", "filename": "photo.jpg", "totalBytes": 248000, "downloadUrl": "https://api.textbubbles.com/v1/attachments/eyJpbnN0YW5jZUlkIjoiLi4uIn0.HMAC_SIGNATURE" } ] } } ``` ### Inbound Message Fields | Field | Type | Description | |-------|------|-------------| | messageId | string | Internal TextBubbles message ID. Use this when calling endpoints like `POST /v1/messages/:id/reactions` or `PUT /v1/messages/:id` in response to the inbound message | | externalMessageId | string | Provider-level identifier (iMessage GUID or WhatsApp message id) — use for correlating with provider logs | | from | string | Sender's phone number in E.164 (or email for iMessage email addresses). For WhatsApp senders on WA's LID privacy mode, TextBubbles resolves the LID to the real E.164 number before emitting the webhook | | to | string | Receiving phone number or email (identifies which instance received the message) | | text | string | Message text content | | channel | string | Channel the message was received on: `imessage`, `sms`, or `whatsapp` | | parentMessageId | string \| null | Internal `messageId` this inbound is replying to. Set for iMessage thread replies and WhatsApp quoted replies — a consumer that reads only `parentMessageId` works on both channels | | replyTo | object \| absent | WhatsApp only. Provider-level detail about the quoted parent — same internal `messageId` as `parentMessageId`, plus the WhatsApp `externalMessageId` for correlating with WA-indexed stores. Omit if the inbound was not a quoted reply | | attachments | array | List of file attachments (empty array if none). Same shape across all channels — your inbox code does not need to branch on `channel` to fetch media | | attachments[].guid | string \| undefined | Unique identifier for the attachment. Present for iMessage; omitted on other channels | | attachments[].mimeType | string | MIME type (e.g., image/jpeg, video/mp4, audio/aac) | | attachments[].filename | string \| undefined | Original filename when the sender supplied one | | attachments[].totalBytes | number | File size in bytes | | attachments[].downloadUrl | string | Pre-signed URL (1 hour TTL, no API key required) | | attachments[].note | string \| undefined | Present only on rare failure cases when the attachment metadata reached the platform but the bytes couldn't be persisted. When present, `downloadUrl` will be absent | | otp | object \| absent | Present when TextBubbles detected a 2FA / verification code in the inbound body (WhatsApp signup, bank OTP, etc.). See OTP detection on inbound messages below | | chat | object \| absent | Present when the inbound was attributed to a known chat. See Chat and sender fragments below | | sender | object \| absent | Present alongside `chat`. Resolves the sender to a contact when one matches | Replying to inbound messages: always use `messageId` (internal), not `externalMessageId`, when invoking TextBubbles API endpoints. For example, to react to the inbound message above, `POST /v1/messages/msg_xyz789/reactions`. ### Chat and sender fragments `message.inbound` and outbound lifecycle events (`message.sent`, `message.delivered`, `message.read`, `message.failed`) include `chat` and `sender` objects when the message is attributed to a known chat. Top-level `from`/`to` remain populated — consumers that only read those keys continue to work. 1:1 inbound: ```json { "id": "evt_abc123", "type": "message.inbound", "timestamp": "2026-04-21T10:00:00.000Z", "data": { "messageId": "msg_xyz789", "externalMessageId": "AB12CD34-5678-90EF-1234-567890ABCDEF", "from": "+14155551234", "to": "+19876543210", "text": "Hey", "channel": "imessage", "chat": { "chatId": "550e8400-e29b-41d4-a716-446655440000", "providerChatGuid": "iMessage;-;+14155551234", "channel": "imessage", "isGroup": false, "name": null, "participants": [ { "handle": "+14155551234", "contactId": "9e1c8f63-1c7d-4a4b-b9a3-2f31b3a72e10", "role": "member", "isPhoneResolved": true }, { "handle": "self", "contactId": null, "role": "self", "isPhoneResolved": true } ] }, "sender": { "contactId": "9e1c8f63-1c7d-4a4b-b9a3-2f31b3a72e10", "handle": "+14155551234" } } } ``` Group inbound: ```json { "id": "evt_def456", "type": "message.inbound", "timestamp": "2026-04-21T10:05:00.000Z", "data": { "messageId": "msg_abc999", "externalMessageId": "11AA22BB-3344-5566-7788-99AABBCCDDEE", "from": "+14155551234", "to": "+19876543210", "text": "Anyone free Saturday?", "channel": "imessage", "chat": { "chatId": "660e8400-e29b-41d4-a716-446655440000", "providerChatGuid": "iMessage;+;chat1234567890", "channel": "imessage", "isGroup": true, "name": "Family Chat", "participants": [ { "handle": "+14155551234", "contactId": "9e1c8f63-1c7d-4a4b-b9a3-2f31b3a72e10", "role": "member", "isPhoneResolved": true }, { "handle": "+14155559876", "contactId": null, "role": "member", "isPhoneResolved": true }, { "handle": "self", "contactId": null, "role": "self", "isPhoneResolved": true } ] }, "sender": { "contactId": "9e1c8f63-1c7d-4a4b-b9a3-2f31b3a72e10", "handle": "+14155551234" } } } ``` `chat` fields: | Field | Type | Description | |-------|------|-------------| | chatId | string | Stable UUID for the conversation. Pass as `chatId` on POST /v1/messages to send into the chat | | providerChatGuid | string \| null | Provider chat identifier (same value surfaced as the path parameter on /v1/chats/:chatId/*) | | channel | string | imessage, sms, or whatsapp | | isGroup | boolean | true for group chats, false for 1:1 | | name | string \| null | Group display name. null for 1:1 chats | | participants | array | Active roster. Each entry: { handle, contactId, role, isPhoneResolved }. `role` is `member`, `owner`, or `self`. `isPhoneResolved` is true when `handle` is phone-based or `"self"`, false when only a `@lid` is known. Branch on `isPhoneResolved`, not on the suffix of `handle` | `sender` fields: | Field | Type | Description | |-------|------|-------------| | contactId | string \| null | UUID of the matching contact when the sender's address resolves to a contact owned by the customer; null otherwise | | handle | string \| null | The sender's address as the provider supplied it (E.164 phone number for SMS/WhatsApp, phone or Apple ID for iMessage) | Outbound lifecycle events (`message.sent`, `message.delivered`, `message.read`, `message.failed`) include the same `chat` and `sender` objects when the originating outbound message was associated with a chat. Status events for messages without a stored chat omit both fields. ### Example: WhatsApp quoted reply When a contact quote-replies to a message you sent, the webhook surfaces the parent reference so you don't need to index `externalMessageId`s yourself: ```json { "id": "evt_abc123", "type": "message.inbound", "timestamp": "2026-04-20T20:44:43.000Z", "data": { "messageId": "msg_cad993d1-13f4-47e5-af60-b926d142e8f9", "externalMessageId": "3A7D053CB47169B2734B", "from": "+14155551234", "to": "+16282895642", "text": "Reply", "channel": "whatsapp", "customerId": "4b02f4ab-35d9-444a-b0ee-4c2a3edd347c", "replyTo": { "messageId": "msg_6d8ef255-7e5a-40ad-8cc2-4816cf9b039d", "externalMessageId": "3EB0E921E1F353BB23A1B8" } } } ``` ## Typing Indicator WhatsApp contacts emit a typing event while composing a message. The webhook surfaces each emission as a single `typing.indicator` event. WhatsApp does NOT emit a corresponding "stopped typing" event — the stream simply goes quiet. ```json { "id": "evt_abc123", "type": "typing.indicator", "timestamp": "2026-04-20T20:31:16.000Z", "data": { "state": "composing", "from": "+14155551234", "channel": "whatsapp", "customerId": "4b02f4ab-35d9-444a-b0ee-4c2a3edd347c" } } ``` | Field | Type | Description | |-------|------|-------------| | state | string | Always `"composing"` today. Future states (`paused`, etc.) may be added | | from | string | E.164 phone number of the contact who is typing | | channel | string | Always `"whatsapp"` — iMessage typing indicators are not surfaced as webhooks | | customerId | string | Customer the event belongs to | Recommended consumer behavior: treat each `typing.indicator` event as "show a typing bubble for ~5 seconds." WhatsApp re-emits the event every few seconds while the contact keeps typing, so the bubble stays on as long as they continue. When emissions stop, the bubble times out naturally. ## WhatsApp Session Status `whatsapp.status` fires every time a WhatsApp session's pairing state changes — useful for driving a live pairing UI without polling `GET /v1/whatsapp/numbers/:phoneNumber/status`. Delivered on the Realtime/SSE stream only (not to webhook URLs). ```json { "id": "evt_abc123", "type": "whatsapp.status", "timestamp": "2026-04-20T20:40:00.000Z", "data": { "phoneNumber": "+16282895642", "status": "connected", "jid": "16282895642:2@s.whatsapp.net", "customerId": "4b02f4ab-35d9-444a-b0ee-4c2a3edd347c" } } ``` | Field | Type | Description | |-------|------|-------------| | phoneNumber | string | The TextBubbles number whose session changed | | status | string | `qr_pending` / `connecting` / `connected` / `disconnected` | | qrCode | string \| null | Present when `status` is `qr_pending` — raw QR string, render with any library | | jid | string \| null | Present when `status` is `connected` — the paired WhatsApp JID (usually `:@s.whatsapp.net`) | | disconnectReason | string \| null | Present when `status` is `disconnected` — `auth_revoked`, `phone_number_mismatch`, or `close_` | | customerId | string | Customer the session belongs to | Subscribe from the textbubbles UI or from any SSE-capable client via `GET /v1/events?events=whatsapp.status`. ## Reaction Events ```json { "id": "evt_abc123", "type": "message.reaction", "timestamp": "2026-03-28T10:00:00.000Z", "data": { "reaction": { "type": "love", "targetMessageId": "msg_xyz789", "targetExternalId": "guid-of-original-message", "content": "Loved \"Hello!\"" }, "from": "+14155551234", "channel": "imessage" } } ``` Emoji reactions (iOS 17+): ```json { "id": "evt_def456", "type": "message.reaction", "data": { "reaction": { "type": "emoji", "emoji": "bacon emoji", "targetMessageId": "msg_xyz789", "targetExternalId": "guid-of-original-message", "content": null }, "from": "+14155551234", "channel": "imessage" } } ``` ### Reaction Fields | Field | Description | |-------|-------------| | reaction.type | Reaction type. iMessage: `love`, `like`, `dislike`, `laugh`, `emphasize`, `question`, `emoji`, `remove`. WhatsApp: the raw emoji character (e.g. `❤️`, `🔥`), or `remove` when the reaction was cleared. | | reaction.emoji | The emoji character. Present on iMessage when `type` is `emoji` (iOS 17+), and on WhatsApp whenever a non-empty emoji was sent | | reaction.targetMessageId | Internal `messageId` the reaction was applied to (null if not found) | | reaction.targetExternalId | Provider-assigned GUID of the original message | | reaction.content | Human-readable description or preview of the target message text (null for emoji-only reactions on iMessage) | ## Audio Kept Events Fired when a contact taps "Keep" on a voice note you sent. iMessage auto-deletes voice notes after two minutes unless the recipient keeps them — tapping Keep is an explicit retention signal, useful as a lightweight engagement event. ```json { "id": "evt_audio_kept_123", "type": "message.audio_kept", "timestamp": "2026-04-21T09:17:46.007Z", "data": { "externalMessageId": "D6ADF04F-EF82-45F6-BF46-CF7F2B20C9AE", "targetMessageId": "msg_cdf628ad-3ed3-49aa-a10f-a0ea19c9220c", "targetExternalId": "4DE4DB87-1681-4DC7-A77B-3967BCA74BF7", "from": "+14155551234", "to": "+13105550199", "channel": "imessage" } } ``` | Field | Description | |-------|-------------| | externalMessageId | Provider-assigned GUID of the Keep system event itself | | targetMessageId | Internal message ID of the kept audio. null if the audio predates our retention or was sent through a different iMessage instance | | targetExternalId | Provider-assigned GUID of the kept audio — always populated | | from | Contact who tapped Keep | | to | Instance phone number the audio was sent from | ## Chat Event Payloads All chat events share the same envelope: - `chatId` — the conversation UUID (channel-independent) - `chat` — the canonical chat fragment with `providerChatGuid`, channel, roster, etc. (see Chat and sender fragments above) - `externalMessageId` — provider-assigned GUID of the underlying system event - `actor` — handle of the contact who performed the action - `to` — instance phone - `channel` — "imessage" or "whatsapp" ### chat.participant.added / chat.participant.left ```json { "id": "evt_participant_added", "type": "chat.participant.added", "timestamp": "2026-04-21T10:00:00.000Z", "data": { "externalMessageId": "", "chatId": "550e8400-e29b-41d4-a716-446655440000", "chat": { "chatId": "550e8400-e29b-41d4-a716-446655440000", "providerChatGuid": "iMessage;+;chat1234567890", "channel": "imessage", "isGroup": true, "name": "Family Chat", "participants": [ { "handle": "+14155551235", "contactId": null, "role": "member", "isPhoneResolved": true } ] }, "participant": { "handle": "+14155551235", "contactId": null, "role": "member", "isPhoneResolved": true }, "actor": "+14155551234", "to": "+13105550199", "channel": "imessage" } } ``` | Field | Description | |-------|-------------| | chatId | Conversation UUID | | chat | Canonical chat fragment after the change | | participant | The added (or removed) participant — same shape as entries in `chat.participants[]` | | actor | Handle of the contact who performed the action | ### chat.title.changed ```json { "id": "evt_title_changed", "type": "chat.title.changed", "timestamp": "2026-04-21T10:05:00.000Z", "data": { "externalMessageId": "", "chatId": "550e8400-e29b-41d4-a716-446655440000", "chat": { "chatId": "550e8400-e29b-41d4-a716-446655440000", "providerChatGuid": "iMessage;+;chat1234567890", "channel": "imessage", "isGroup": true, "name": "Weekend plans", "participants": [] }, "name": "Weekend plans", "actor": "+14155551234", "to": "+13105550199", "channel": "imessage" } } ``` | Field | Description | |-------|-------------| | chatId | Conversation UUID | | chat | Canonical chat fragment after the rename | | name | The new chat name | | actor | Handle of the contact who changed the name | ### chat.photo.changed ```json { "id": "evt_photo_changed", "type": "chat.photo.changed", "timestamp": "2026-04-21T10:10:00.000Z", "data": { "externalMessageId": "", "chatId": "550e8400-e29b-41d4-a716-446655440000", "chat": { "chatId": "550e8400-e29b-41d4-a716-446655440000", "providerChatGuid": "iMessage;+;chat1234567890", "channel": "imessage", "isGroup": true, "name": "Family Chat", "participants": [] }, "actor": "+14155551234", "to": "+13105550199", "channel": "imessage" } } ``` The event does not include the new photo — retrieve it through the chat resource. --- # Chats Manage iMessage and WhatsApp group chats and per-chat state. Responses return a canonical `chat` object (see "The chat fragment" below). The `chatId` path parameter on these endpoints is the provider chat identifier (`iMessage;+;` for iMessage chats, `@g.us` for WhatsApp groups). The `chat.chatId` UUID returned in responses is the channel-independent identifier — pass it as `chatId` on POST /v1/messages. Channel asymmetry: POST /v1/chats/:chatId/read and POST /v1/chats/:chatId/unread are iMessage-only. They return 400 CHANNEL_NOT_SUPPORTED on WhatsApp chats — WhatsApp has no chat-level read ack. All other routes (GET, PUT /name, POST/DELETE /participants, POST /leave, POST /typing) work on both channels. ## The chat fragment Every /v1/chats/* response embeds `data.chat`, a single canonical object that also appears on `message.*` and `chat.*` webhook events: | Field | Type | Description | |-------|------|-------------| | chatId | string (UUID) | Stable identifier for the conversation. The same value across channels. Pass as `chatId` on POST /v1/messages | | providerChatGuid | string \| null | Provider chat identifier — `iMessage;+;` or `@g.us`. The path-parameter `chatId` on /v1/chats/:chatId/* | | channel | string | imessage, sms, or whatsapp | | isGroup | boolean | true for group chats, false for 1:1 | | name | string \| null | Group display name. null for 1:1 chats | | participants | object[] | Active roster. Each entry: { handle, contactId, role, isPhoneResolved }. `role` is `member`, `owner`, or `self`. `isPhoneResolved` is true when `handle` is a phone-based identifier (E.164 or `@s.whatsapp.net`) or the `"self"` sentinel; false when only a WhatsApp Linked Device ID (`@lid`) is known | ## POST /v1/chats/groups Create a new group chat. Defaults to iMessage; pass `channel: "whatsapp"` to create a WhatsApp group. ```bash curl -X POST https://api.textbubbles.com/v1/chats/groups \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "participants": ["+14155551234", "+14155559876"], "name": "Launch Team" }' ``` WhatsApp variant: ```bash curl -X POST https://api.textbubbles.com/v1/chats/groups \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "whatsapp", "name": "Launch Team", "participants": ["+14155551234", "+14155559876"] }' ``` Body: | Field | Type | Required | Description | |-------|------|----------|-------------| | participants | string[] | Yes | One or more phone numbers (E.164). iMessage also accepts Apple IDs. WhatsApp normalizes to JID server-side | | name | string | Conditional | Required when channel is "whatsapp" (returns 400 NAME_REQUIRED if omitted). Optional for iMessage | | channel | string | No | "imessage" (default) or "whatsapp" | | from | string | No | Sender address (E.164). When supplied, the group is created from that specific instance and pinned to it; otherwise the customer's default instance for the chosen channel is used | Response (201) — iMessage: ```json { "success": true, "data": { "chat": { "chatId": "550e8400-e29b-41d4-a716-446655440000", "providerChatGuid": "iMessage;+;chat1234567890", "channel": "imessage", "isGroup": true, "name": "Launch Team", "participants": [ { "handle": "+14155551234", "contactId": null, "role": "member", "isPhoneResolved": true }, { "handle": "+14155559876", "contactId": null, "role": "member", "isPhoneResolved": true }, { "handle": "self", "contactId": null, "role": "self", "isPhoneResolved": true } ] }, "createdAt": "2026-04-21T10:00:00.000Z" } } ``` Response (201) — WhatsApp: ```json { "success": true, "data": { "chat": { "chatId": "550e8400-e29b-41d4-a716-446655440000", "providerChatGuid": "120363025123456789@g.us", "channel": "whatsapp", "isGroup": true, "name": "Launch Team", "participants": [ { "handle": "14155551234@s.whatsapp.net", "contactId": null, "role": "member", "isPhoneResolved": true }, { "handle": "self", "contactId": null, "role": "self", "isPhoneResolved": true } ] }, "createdAt": "2026-05-25T17:00:00.000Z" } } ``` `createdAt` is the ISO-8601 creation timestamp. See "The chat fragment" above for the `chat` field reference. ## GET /v1/chats/:chatId Retrieve metadata for a specific chat. ```bash curl https://api.textbubbles.com/v1/chats/iMessage%3B%2B%3Bchat1234567890 \ -H "Authorization: Bearer YOUR_API_KEY" ``` URL-encode `chatId`; it contains `;` characters. Response (200): ```json { "success": true, "data": { "chat": { "chatId": "550e8400-e29b-41d4-a716-446655440000", "providerChatGuid": "iMessage;+;chat1234567890", "channel": "imessage", "isGroup": true, "name": "Launch Team", "participants": [ { "handle": "+14155551234", "contactId": null, "role": "member", "isPhoneResolved": true } ] } } } ``` See "The chat fragment" above for the field reference. ## PUT /v1/chats/:chatId/name Rename a group chat. Works on both channels. Fires `chat.title.changed`. ```bash curl -X PUT https://api.textbubbles.com/v1/chats/iMessage%3B%2B%3Bchat1234567890/name \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Weekend plans" }' ``` Body: `{ "name": string }` (min length 1). Response: `{ "success": true, "data": { "chat": ChatFragment, "name": "Weekend plans" } }`. ## POST /v1/chats/:chatId/participants Add a single participant. Fires `chat.participant.added`. ```bash curl -X POST https://api.textbubbles.com/v1/chats/iMessage%3B%2B%3Bchat1234567890/participants \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "participant": "+14155550100" }' ``` Body: `{ "participant": string }` (E.164 phone or Apple ID). Response: `{ "success": true, "data": { "chat": ChatFragment, "participant": { "handle": "+14155550100", "contactId": null, "role": "member", "isPhoneResolved": true } } }`. ## DELETE /v1/chats/:chatId/participants/:participantId Remove a participant by phone number or Apple ID. Fires `chat.participant.left`. ```bash curl -X DELETE \ "https://api.textbubbles.com/v1/chats/iMessage%3B%2B%3Bchat1234567890/participants/+14155550100" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Response: `{ "success": true, "data": { "chat": ChatFragment, "removedParticipant": { "handle": "+14155550100", ... } } }`. ## POST /v1/chats/:chatId/leave Leave a group chat. ```bash curl -X POST https://api.textbubbles.com/v1/chats/iMessage%3B%2B%3Bchat1234567890/leave \ -H "Authorization: Bearer YOUR_API_KEY" ``` Response: `{ "success": true, "data": { "chatId": "", "left": true } }`. ## POST /v1/chats/:chatId/read iMessage only. Mark all messages in the chat as read. Returns 400 CHANNEL_NOT_SUPPORTED on WhatsApp chats — WhatsApp has no chat-level read ack; acknowledge per-message instead. ```bash curl -X POST https://api.textbubbles.com/v1/chats/iMessage%3B%2B%3Bchat1234567890/read \ -H "Authorization: Bearer YOUR_API_KEY" ``` Response: `{ "success": true, "data": { "chatId": "", "read": true } }`. ## POST /v1/chats/:chatId/unread iMessage only. Set the unread indicator on the chat. Requires macOS 13 or later on the sender's instance. Returns 400 CHANNEL_NOT_SUPPORTED on WhatsApp chats. ```bash curl -X POST https://api.textbubbles.com/v1/chats/iMessage%3B%2B%3Bchat1234567890/unread \ -H "Authorization: Bearer YOUR_API_KEY" ``` Response: `{ "success": true, "data": { "chatId": "", "unread": true } }`. ## POST /v1/chats/:chatId/typing Start or stop a typing indicator. Works on both iMessage and WhatsApp. On WhatsApp, "start" maps to the `composing` presence state and "stop" maps to `paused`. ```bash curl -X POST https://api.textbubbles.com/v1/chats/iMessage%3B%2B%3Bchat1234567890/typing \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "start" }' ``` Body: `{ "status": "start" | "stop" }`. Response: `{ "success": true, "data": { "chatId": "", "typing": true } }`. --- # Numbers List available sender addresses for your account. ## GET /v1/numbers ```bash curl -H "Authorization: Bearer tb_xxxxx" https://api.textbubbles.com/v1/numbers ``` Response: ```json { "success": true, "data": [ { "phoneNumber": "+14155551234", "email": "user@icloud.com", "instanceName": "TextBubbles Service 001", "isDefault": true, "healthStatus": "healthy" }, { "phoneNumber": "+19876543210", "email": null, "instanceName": "TextBubbles Service 002", "isDefault": false, "healthStatus": "healthy" } ] } ``` | Field | Type | Description | |-------|------|-------------| | phoneNumber | string | E.164 phone number | | email | string or null | iMessage email or null | | instanceName | string | Friendly name of the instance | | isDefault | boolean | Whether this is the default sender | | healthStatus | string | healthy, degraded, unhealthy, or unknown | --- # Realtime Events (SSE) For realtime delivery without hosting a public webhook endpoint, open a Server-Sent Events stream. The SSE stream emits the same events, with the same payload envelope, as webhooks. ## GET /v1/events ```bash curl -N -H "Authorization: Bearer tb_xxxxx" \ "https://api.textbubbles.com/v1/events?events=message.inbound,message.delivered" ``` The `events` query parameter is an optional comma-separated filter. Omit it or pass `*` to receive every event. Each message in the stream is a standard SSE frame: ``` id: evt_550e8400-e29b-41d4-a716-446655440000 event: message.inbound data: {"id":"evt_...","type":"message.inbound","timestamp":"2026-04-14T10:00:00.000Z","data":{"messageId":"msg_...","from":"+14155551234","to":"+19876543210","text":"Hi","channel":"imessage"}} ``` Notes: - Heartbeats (`: heartbeat`) are sent every 15 seconds so clients can detect dead connections. - Multiple subscribers may connect with the same API key; each receives every event. - SSE is fire-and-forget. Events emitted while no client is connected are **not** replayed. Use webhooks (with retry) for at-least-once delivery. - The event envelope and `type` values are identical to the webhook payloads — see [Webhook Event Reference](#webhook-event-reference). --- # Capabilities ## GET /v1/capabilities/:phone Check iMessage, SMS, and FaceTime support for a phone number. ```bash curl https://api.textbubbles.com/v1/capabilities/+14155551234 \ -H "Authorization: Bearer YOUR_API_KEY" ``` Response: ```json { "success": true, "data": { "phoneNumber": "+14155551234", "capabilities": { "imessage": true, "sms": true, "facetime": true }, "focused": false, "recommendedChannel": "imessage", "lastChecked": "2026-03-28T09:55:00.000Z", "cached": true } } ``` | Field | Type | Description | |-------|------|-------------| | phoneNumber | string | The queried phone number | | capabilities.imessage | boolean | Whether recipient supports iMessage | | capabilities.sms | boolean | Whether recipient can receive SMS | | capabilities.facetime | boolean | Whether recipient supports FaceTime | | focused | boolean | Whether recipient has a Focus mode active | | focusMode | string | Name of the Focus mode (only present when focused is true) | | recommendedChannel | string | "imessage" or "sms" — the best channel to use | | lastChecked | string | ISO 8601 timestamp of the last capability check | | cached | boolean | Whether this result was served from cache | --- # Health Check ## GET /health No authentication required. Not rate-limited. ```json { "status": "ok", "timestamp": "2026-04-10T12:00:00.000Z", "checks": { "database": { "status": "ok" }, "redis": { "status": "ok" }, "imessage": { "status": "ok" }, "whatsapp": { "status": "not_enabled", "experimental": true } } } ``` Top-level status is "ok" when database and Redis are reachable, "degraded" if either is down. SMS fallback is handled natively by the iMessage provider (falls back via a connected iPhone when iMessage delivery fails), so there is no separate SMS provider to monitor. ## Instance Health Statuses Each iMessage provider instance is independently monitored every 2 minutes. Instance health is stored as one of four values: | Status | Meaning | |--------|---------| | healthy | Instance is reachable and fully functional | | degraded | Instance is reachable but has issues (see warnings below) | | unhealthy | Instance is unreachable or health check failed | | unknown | Instance has not been checked yet | ## Instance Health Warnings When an instance has `degraded` status, the response includes an array of warning strings describing what is wrong. The following conditions trigger warnings: | Condition | Warning | Impact | |-----------|---------|--------| | Private API disabled | "Private API is disabled" | Typing indicators, reactions, read receipts, and FaceTime events will not work | | Private API helper disconnected | "Private API helper is not connected" | Same features as above are unavailable; requires operator intervention to resolve | | No iMessage account detected | "No iMessage account detected" | iMessage and FaceTime will not work on this instance | | No iCloud account detected | "No iCloud account detected" | iMessage and FaceTime will not work on this instance; requires operator intervention to resolve | An instance can have multiple warnings at the same time. Warnings are cleared automatically when the next health check finds the condition resolved. --- # Complete Error Code Reference | Code | HTTP Status | Description | |------|-------------|-------------| | UNAUTHORIZED | 401 | Missing or invalid Bearer token | | FORBIDDEN | 403 | API key has no associated customer | | ADDRESS_NOT_AUTHORIZED | 403 | Sender address not authorized for customer | | NO_DEFAULT_ADDRESS | 400 | No authorized address configured | | VALIDATION_ERROR | 400 | Request body failed validation | | INVALID_PHONE_NUMBER | 400 | Phone number not in E.164 format | | NAME_REQUIRED | 400 | POST /v1/chats/groups with channel: "whatsapp" and no name | | NOT_FOUND | 404 | Resource not found | | REPLY_NOT_FOUND | 404 | Reply target message not found | | CHANNEL_NOT_SUPPORTED | 400 | Operation not supported on the conversation's channel (e.g. /v1/chats/:chatId/read or /unread on a WhatsApp chat) | | NO_CHANNEL_AVAILABLE | 400 | Recipient not reachable | | FROM_INSTANCE_CANNOT_REACH | 400 | The specified `from` address cannot reach the recipient on any of the requested channels, or — for `chatId` sends — does not match the instance the chat is pinned to | | INVALID_REQUEST | 400 | Request shape is invalid. On POST /v1/messages: `to` and `chatId` were both supplied, or neither was supplied (exactly one is required) | | CONVERSATION_NOT_FOUND | 404 | `chatId` does not match any conversation for this customer | | CONVERSATION_NOT_READY | 409 | Conversation has no provider chat yet; retry after the chat is created | | GROUP_SMS_UNSUPPORTED | 400 | Group send is not supported on SMS; send to participants individually | | INVALID_STATUS | 400 | Operation not valid for current status | | MISSING_EXTERNAL_ID | 400 | Message has no external provider ID | | ALREADY_UNSENT | 409 | Message already unsent | | ALREADY_DELETED | 409 | Message already deleted | | WEBHOOK_NOT_FOUND | 404 | Webhook does not exist or does not belong to your customer | | WEBHOOK_DUPLICATE | 409 | A webhook with this URL and event set already exists | | WEBHOOK_NOT_CONFIGURED | 404 | No webhook registered (legacy single-webhook endpoints only) | | NOT_SCHEDULED | 400 | Message is not in scheduled status | | INVALID_SCHEDULED_AT | 400 | Invalid ISO 8601 datetime format | | SCHEDULED_IN_PAST | 400 | Scheduled time is in the past | | SCHEDULED_TOO_FAR | 400 | Scheduled time is more than 30 days away | | DEPRECATED | 410 | Endpoint has been deprecated | | RATE_LIMIT_EXCEEDED | 429 | Too many requests | | PROVIDER_TIMEOUT | 504 | Upstream iMessage service timed out | | EDIT_FAILED | 502 | Message edit could not be completed | | IMESSAGE_SEND_FAILED | 500 | iMessage delivery error | | INTERNAL_ERROR | 500 | Unexpected server error | --- # Complete Endpoint Reference Base URL: https://api.textbubbles.com ## Messages - POST /v1/messages — Send a message (text, attachments, carousel, effects, scheduled) - GET /v1/messages — List messages - GET /v1/messages/:id — Get message status - POST /v1/messages/:id/unsend — Unsend a message (iMessage only) - POST /v1/messages/:id/retry — Retry a failed message (new provider ID, same TextBubbles ID) - PUT /v1/messages/:id — Edit a message (iMessage only) - POST /v1/messages/:id/reactions — Send tapback reaction - DELETE /v1/messages/:id — Soft-delete a message - GET /v1/messages/scheduled — List scheduled messages - DELETE /v1/messages/:id/schedule — Cancel scheduled message ## Capabilities - GET /v1/capabilities/:phone — Check iMessage/SMS/FaceTime support ## Chats - POST /v1/chats/groups — Create a group chat (iMessage default; pass channel: "whatsapp" for a WA group — name is required) - GET /v1/chats/:chatId — Get chat info (participants, name, isGroup) - PUT /v1/chats/:chatId/name — Rename a group chat (iMessage + WhatsApp) - POST /v1/chats/:chatId/participants — Add a participant (iMessage + WhatsApp) - DELETE /v1/chats/:chatId/participants/:participantId — Remove a participant (iMessage + WhatsApp) - POST /v1/chats/:chatId/leave — Leave a group chat (iMessage + WhatsApp) - POST /v1/chats/:chatId/read — Mark a chat as read (iMessage only — 400 CHANNEL_NOT_SUPPORTED on WhatsApp) - POST /v1/chats/:chatId/unread — Mark a chat as unread (iMessage only — 400 CHANNEL_NOT_SUPPORTED on WhatsApp) - POST /v1/chats/:chatId/typing — Send a typing indicator (start/stop; iMessage + WhatsApp) ## Numbers - GET /v1/numbers — List available sender addresses ## Webhooks - POST /v1/webhooks — Register a webhook (url, events, optional secret/name) - GET /v1/webhooks/list — List all registered webhooks - GET /v1/webhooks/{id} — Get a single webhook - PATCH /v1/webhooks/{id} — Update url/events/secret/name or pause with active=false - DELETE /v1/webhooks/{id} — Remove a webhook - POST /v1/webhooks/{id}/test — Deliver a synthetic webhook.test event to one webhook - POST /v1/webhooks/{id}/rotate-secret — Rotate the signing secret for one webhook - GET /v1/webhooks, PUT /v1/webhooks, DELETE /v1/webhooks, POST /v1/webhooks/test, POST /v1/webhooks/rotate-secret — deprecated single-webhook variants ## System - GET /health — Health check (no auth required) # WhatsApp WhatsApp is a first-class send/receive channel using the same phone number as the iMessage/SMS line. Pairing is per-number via QR code or pair-by-code. ## Routing Include `"whatsapp"` in `routing.preference`: ```json { "routing": { "preference": ["whatsapp"] } } ``` Or fall back: `["whatsapp", "imessage", "sms"]`. Group sends use `to: "...@g.us"`. ## Capabilities (outbound / inbound) - text: yes / yes - image (≤16 MB): yes / yes - video (≤64 MB): yes / yes - voice notes (PTT) via `attachments[].isAudioMessage: true`: yes / yes - documents (≤100 MB): yes / yes - reactions: yes (POST /v1/messages/:id/reactions, type → emoji map) / yes (message.reaction webhook) - replies (quoted via replyTo): yes / yes - mentions (groups only): yes / yes - typing indicators inbound: yes (typing.indicator webhook) ## Outbound media formats Video preferred (sent as-is): MP4 with H.264 video and AAC audio (or no audio track). Other H.264 containers (.mov, .mkv, etc.) are 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. Voice notes preferred (sent as-is): Opus in OGG. Any other audio format sent with `attachments[].isAudioMessage: true` is transcoded to Opus-in-OGG before send. ## Reaction type → emoji | type | emoji | |---|---| | love | ❤️ | | like | 👍 | | dislike | 👎 | | laugh | 😂 | | emphasize | ‼️ | | question | ❓ | | any other string | passed through verbatim | | `-love` etc. | removes the reaction | ## Customer endpoints (per-number pairing & status) All endpoints are scoped to numbers the caller's API key owns. Requests for a number not linked to the caller's customer return 403 NUMBER_NOT_OWNED. Phone numbers in the path accept E.164 with or without leading `+`. - GET /v1/whatsapp — list all owned numbers with { phoneNumber, whatsapp: { status, jid?, lastConnectedAt?, lastDisconnectedAt?, disconnectReason? } }. whatsapp.status is one of not_enabled, disconnected, connecting, qr_pending, connected. - POST /v1/whatsapp/numbers/:phoneNumber/enable — create session, start QR flow - GET /v1/whatsapp/numbers/:phoneNumber/status — { status, jid, lastConnectedAt, ... } - GET /v1/whatsapp/numbers/:phoneNumber/qr — { qrCode } raw string, render with any QR lib - POST /v1/whatsapp/numbers/:phoneNumber/pairing-code — 8-char alternative to scanning a QR. Optional body { phoneNumber } to pair a different device; defaults to the owned number. - POST /v1/whatsapp/numbers/:phoneNumber/disconnect — drop live session, keep credentials (quick reconnect) - DELETE /v1/whatsapp/numbers/:phoneNumber — remove session entirely; next pair requires a fresh QR - GET /v1/whatsapp/numbers/:phoneNumber/check?phone=+1... — is the recipient on WhatsApp? - GET /v1/whatsapp/numbers/:phoneNumber/signup-codes — surface any WhatsApp verification SMS codes received in the last 15 minutes; used by the UI during WhatsApp account signup. Returns { windowMinutes, codes: [{ code, messageId, from, channel, receivedAt }] }. ## Realtime status events whatsapp.status fires on every session transition (qr_pending / connecting / connected / disconnected). Delivered on the Realtime/SSE stream only — not to webhook URLs. Subscribe via Supabase Realtime (textbubbles UI) or `GET /v1/events?events=whatsapp.status`. Event data: { phoneNumber, status, qrCode? (when qr_pending), jid? (when connected), disconnectReason? (when disconnected), customerId }. ## OTP detection on inbound messages Every inbound message (iMessage / SMS / WhatsApp) is scanned for 2FA / verification codes. When detected, a top-level `otp` object is added to the `message.inbound` webhook payload, and a matching `otp` bucket is persisted to `messages.metadata`: { "otp": { "provider": "whatsapp" | "generic", "code": "123456", // digits-only, normalized from XXX-XXX / XXX XXX "rawMatch": "Your WhatsApp code: 123-456", // use for highlighting / masking "confidence": "high" | "medium" } } Detector rules (deliberately conservative): - provider "whatsapp" / high confidence: matches WhatsApp's canonical "Your WhatsApp code: XXX-XXX" signup SMS shape. - provider "generic" / medium confidence: 4–8 digit codes (flat or XXX-XXX / XXX XXX) PLUS a context keyword — one of `code`, `verification`, `verify`, `otp`, `one-time`, `security code`, `passcode`, `pin`, `do not share`. - NOT flagged: bare numbers without context ("2026", "my address is 4th", "I am 34"), or keywords without a digit group ("what's the code?"). Use cases: auto-populate a user's 2FA field, forward codes to a secure audit channel, render a reveal-on-click chip in a UI. ## Phone-number guarantee The number on the scanned WhatsApp account MUST match the registered TextBubbles number. Mismatched scans are rejected automatically. ## Inbound media Inbound media (photos, videos, voice notes, documents, stickers) is surfaced as `attachments[]` with a pre-signed `downloadUrl` — the same shape iMessage attachments use. Fetch the URL directly (no API key required, 1-hour TTL). See the [Inbound Message Fields](#inbound-message-fields) table above for the full attachment shape. ## Errors Send-path error codes surface on the message as `errorCode` (GET /v1/messages/:id) and on message.failed webhook events. - NO_CHANNEL_AVAILABLE — none of the channels in routing.preference are available for this recipient (e.g. {"preference":["whatsapp"], "fallback":false} to a number not on WhatsApp). Include "imessage" or "sms" in the preference with fallback:true for graceful degradation. - FROM_INSTANCE_CANNOT_REACH — an explicit `from` was supplied and that specific sender cannot reach the recipient on any of the requested channels (e.g. the `from` number is not paired with WhatsApp), or — when sending with `chatId` — the `from` resolves to a different instance than the one the chat is pinned to. Pair the sender, pick a `from` that supports the channel, or omit `from` to let the chat's pinned instance handle delivery. - WHATSAPP_NOT_CONNECTED — no paired WhatsApp session for this number. Pair via Settings → WhatsApp in the textbubbles UI or the /v1/whatsapp/** API. - WHATSAPP_SEND_FAILED — generic send failure (transient upstream, unfetchable attachment URL, etc). Safe to retry. - WHATSAPP_MEDIA_TOO_LARGE — attachment exceeds WhatsApp's size limits (16 MB image / 64 MB video / 100 MB document) - WHATSAPP_REACTION_FAILED — reaction could not be delivered - MISSING_WHATSAPP_JID — POST /v1/messages/:id/reactions target has no stored WhatsApp JID; should be rare for messages sent through the API Session-level disconnect reasons (surfaced on GET /v1/whatsapp/numbers/:phoneNumber/status as disconnectReason, NOT on individual sends): - phone_number_mismatch — the scanned account's number doesn't match the TextBubbles number this instance is registered to; auto-logged-out, re-pair with the correct account - auth_revoked — the customer removed the textbubbles link from WhatsApp → Linked Devices; re-enable for a fresh QR - close_ — transient close; the service reconnects automatically # WhatsApp Groups WhatsApp groups are managed through the same /v1/chats/* surface as iMessage groups. The Chats section above documents the full request/response shape; this section consolidates the WhatsApp-specific quirks. ## Quirks - name is required on create. POST /v1/chats/groups with channel: "whatsapp" and no name returns 400 NAME_REQUIRED. WhatsApp groups must have a subject up-front. - No chat-level read ack. POST /v1/chats/:chatId/read and POST /v1/chats/:chatId/unread return 400 CHANNEL_NOT_SUPPORTED on WhatsApp chats. Acknowledge reads per-message instead. - LID vs PN participants. A participant may have handle ending in `@lid` (Linked Device ID) when their phone number is not yet known. Use isPhoneResolved: true|false as the source of truth — do not parse the handle suffix. isPhoneResolved appears in chat.participants[] on both REST responses and webhook payloads. - Path chatId format: WhatsApp groups are `@g.us`. iMessage groups are `iMessage;+;`. The chat.chatId UUID returned in responses is the channel-independent identifier for sends. - from on create: when supplied, the group is created from that specific instance and the conversation is pinned to it. Without from, the customer's default WhatsApp instance is used. - Typing: POST /v1/chats/:chatId/typing works on WhatsApp. "start" maps to the `composing` presence state; "stop" maps to `paused`. ## Create ```bash curl -X POST https://api.textbubbles.com/v1/chats/groups \ -H "Authorization: Bearer $TB_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "whatsapp", "name": "Launch Team", "participants": ["+14155551234", "+14155559876"] }' ``` Response data shape: ```json { "chat": { "chatId": "550e8400-e29b-41d4-a716-446655440000", "providerChatGuid": "120363025123456789@g.us", "channel": "whatsapp", "isGroup": true, "name": "Launch Team", "participants": [ { "handle": "14155551234@s.whatsapp.net", "contactId": null, "role": "member", "isPhoneResolved": true }, { "handle": "self", "contactId": null, "role": "self", "isPhoneResolved": true } ] }, "createdAt": "2026-05-25T17:00:00.000Z" } ``` ## Rename / add / remove / leave / typing ```bash # Rename curl -X PUT "https://api.textbubbles.com/v1/chats/120363025123456789@g.us/name" \ -H "Authorization: Bearer $TB_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Launch Team — Q3" }' # Add participant curl -X POST "https://api.textbubbles.com/v1/chats/120363025123456789@g.us/participants" \ -H "Authorization: Bearer $TB_KEY" -H "Content-Type: application/json" \ -d '{ "participant": "+14155550100" }' # Remove participant curl -X DELETE "https://api.textbubbles.com/v1/chats/120363025123456789@g.us/participants/+14155550100" \ -H "Authorization: Bearer $TB_KEY" # Leave curl -X POST "https://api.textbubbles.com/v1/chats/120363025123456789@g.us/leave" \ -H "Authorization: Bearer $TB_KEY" # Typing curl -X POST "https://api.textbubbles.com/v1/chats/120363025123456789@g.us/typing" \ -H "Authorization: Bearer $TB_KEY" -H "Content-Type: application/json" \ -d '{ "status": "start" }' ``` ## Webhook idempotency chat.title.changed, chat.participant.added, and chat.participant.left can fire from two paths: a customer-initiated REST call, and a provider state-change event observed shortly after. For changes initiated through /v1/chats/*, the webhook is emitted exactly once within a 60-second dedup window. Implement handlers to be idempotent on `eventId` regardless. ## Participant fields (WhatsApp) WhatsApp participants follow the canonical chat.participants[] shape — see "The chat fragment" in the Chats section. WhatsApp-specific notes: - `handle` is the phone JID (`@s.whatsapp.net`) when the phone is known, or `@lid` when only the Linked Device ID is known. - `isPhoneResolved` is true for phone JIDs (and the `"self"` sentinel), false for `@lid`. Branch on this rather than parsing `handle`. - Group admins surface as `role: "owner"` (creator/superadmin) or `role: "member"`. Promotion/demotion changes are not currently emitted as chat.* webhooks — re-fetch GET /v1/chats/:chatId to reconcile. ## Error codes (WhatsApp group operations) | Code | HTTP | When | |------|------|------| | NAME_REQUIRED | 400 | POST /v1/chats/groups (channel: "whatsapp") with missing/empty name | | CHANNEL_NOT_SUPPORTED | 400 | POST /v1/chats/:chatId/read or /unread on a WhatsApp chat | | FROM_INSTANCE_CANNOT_REACH | 400 | Supplied `from` does not match the instance the conversation is pinned to | | CONVERSATION_NOT_FOUND | 404 | chatId does not match any conversation for this customer | | WHATSAPP_NOT_CONNECTED | 400 | The pinned WhatsApp instance is not currently paired |