# 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`). ## 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 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": "Thanks for letting me know!" }, "replyTo": "msg_550e8400-e29b-41d4-a716-446655440000" }' ``` ## 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"] } }' ``` ## 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" - `fallback` — try the next channel if the first fails (default: true) ## 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. ## Group Chats — POST /v1/messages with conversationId Send into an existing group chat (or any pinned conversation) by passing `conversationId` instead of `to`. The `conversationId` is returned from `POST /v1/chats/groups`. Exactly one of `to` or `conversationId` 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 '{ "conversationId": "550e8400-e29b-41d4-a716-446655440000", "content": { "text": "Hey team" } }' ``` When `conversationId` is used: - The channel is pinned to whatever the conversation was created on; `routing` and `fallback` are ignored. - 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: `{ chatId, conversationId, participants, name, createdAt }`. Use `conversationId` for sends; `chatId` for chat-level endpoints (rename, add/remove participant, leave). ## 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 conversationId is provided. | | conversationId | string | Conditionally | UUID of an existing conversation for group or pinned sends. Required unless to is provided. | | from | string | No | Sender address (must be authorized; uses default if omitted) | | 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"]) | | routing.fallback | boolean | No | Enable fallback (default: true) | | replyTo | string | No | Message ID to reply to (iMessage only) | | attachments | object[] | No | File attachments (iMessage only) | | attachments[].type | string | No | Attachment type: "url" or "base64" | | attachments[].url | string | No | URL of 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 iMessage voice-memo bubble (inline player) instead of a generic file attachment. Accepts any audio format — the server normalizes non-compatible inputs (anything except MP3 or Opus-in-CAF) to Opus-in-CAF before forwarding to iMessage. iMessage only. | | 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. iMessage only. ```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 | Heart | | like | thumbs up | Thumbs up | | dislike | thumbs down | Thumbs down | | laugh | laughing | Laughing | | emphasize | exclamation marks | Exclamation marks | | question | question mark | 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. ## Screen Effects | Effect | Description | |--------|-------------| | fireworks | Fireworks burst across the screen | | balloons | Colorful balloons float up | | confetti | Confetti rains down | | lasers | Laser light show | | spotlight | Spotlight highlights the message | ## Bubble Effects | 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 | | echo | Message echoes across the screen | | love | Heart animation on the message bubble | --- # 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. Construct the signed payload: `{timestamp}.{raw_request_body}` 3. Compute HMAC-SHA256 using your webhook secret 4. Compare signatures using constant-time comparison 5. Reject requests older than 5 minutes to prevent replay attacks ### 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 Failed webhook deliveries are retried with 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) | Your endpoint must respond with a 2xx status code within 30 seconds. ## 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) | ### 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 contact performs a chat-state action (iMessage group chats). | 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 | ## 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", "from": "+14155551234", "to": "+19876543210", "text": "Check out this photo", "channel": "imessage", "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 | Unique message identifier | | from | string | Sender's phone number (E.164) or email | | 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) | | 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 | ## 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 | love, like, dislike, laugh, emphasize, question, emoji, remove | | reaction.emoji | Emoji character (only when type is "emoji") | | reaction.targetMessageId | Internal message ID (null if not found) | | reaction.targetExternalId | Provider-assigned GUID of original message | | reaction.content | Human-readable description (null for emoji) | ## 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 four chat events share the same envelope: `externalMessageId` (provider-assigned GUID of the underlying system event), `chatId` (provider chat identifier), `actor` (who performed the action), `to` (instance phone), `channel: "imessage"`. ### 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": "iMessage;+;chat1234567890", "participant": "+14155551235", "actor": "+14155551234", "to": "+13105550199", "channel": "imessage" } } ``` | Field | Description | |-------|-------------| | chatId | Provider chat identifier | | participant | Contact added to (or removed from) the chat | | actor | 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": "iMessage;+;chat1234567890", "title": "Weekend plans", "actor": "+14155551234", "to": "+13105550199", "channel": "imessage" } } ``` | Field | Description | |-------|-------------| | title | The new chat title | | actor | Contact who changed the title | ### chat.photo.changed ```json { "id": "evt_photo_changed", "type": "chat.photo.changed", "timestamp": "2026-04-21T10:10:00.000Z", "data": { "externalMessageId": "", "chatId": "iMessage;+;chat1234567890", "actor": "+14155551234", "to": "+13105550199", "channel": "imessage" } } ``` The event does not include the new photo — retrieve it through the chat resource. --- # 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 | Status | Meaning | |--------|---------| | healthy | Instance is reachable and fully functional | | degraded | Instance is reachable but has issues | | unhealthy | Instance is unreachable or health check failed | | unknown | Instance has not been checked yet | --- # 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 | | NOT_FOUND | 404 | Resource not found | | REPLY_NOT_FOUND | 404 | Reply target message not found | | CHANNEL_NOT_SUPPORTED | 400 | Operation not supported on channel | | NO_CHANNEL_AVAILABLE | 400 | Recipient not reachable | | INVALID_RECIPIENT | 400 | Must provide exactly one of `to` or `conversationId` on send | | CONVERSATION_NOT_FOUND | 404 | `conversationId` 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 | | 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 ## 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) ## 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. - 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