# Blooio v4 API (v4.0.0-beta) The Blooio v4 API is a multi-channel messaging API for iMessage, SMS, RCS, WhatsApp, and Apple Messages for Business. It exposes channels, contacts and identities, chats, messages, reactions, polls, groups, webhooks, and a unified event feed. Base URL: https://api.blooio.com/v4 Authentication: Bearer API key (Authorization: Bearer bl_live_...). --- ## Account Authenticated account, sender numbers, and contact cards. ### GET /me — Get the authenticated account operationId: getMe Returns the authenticated organization, linked devices/numbers, usage, and API key validity. Responses: 200, 401 ### GET /me/priority — Get the key's default priority operationId: getMyDefaultPriority Returns the calling API key's default channel priority, or `{ "data": null }` when none is configured (in which case the agnostic send falls back to the implicit Blooio pool of the key's own numbers). Responses: 200, 400, 401 ### GET /me/numbers — List sender numbers operationId: listNumbers Lists the phone numbers / sender addresses available to this API key. Banned and suspended numbers stay in the list. `status` is `banned`, `suspended`, or `active`; a ban takes precedence when both apply. `is_active` is false when the line is banned, suspended, or the underlying account is blocked. Responses: 200, 401 ### GET /me/numbers/{number}/contact-card — Get a number's contact card operationId: getContactCard Parameters: - number (path, required): string — Sender phone number in E.164 format, e.g. `+15551234567`. Responses: 200, 401, 404 ### PUT /me/numbers/{number}/contact-card — Update a number's contact card operationId: updateContactCard Updates the contact card (name, avatar, sharing settings) shared from this number. At least one field is required. Parameters: - number (path, required): string — Sender phone number in E.164 format, e.g. `+15551234567`. Request body (application/json): - first_name: string - last_name: string - avatar: string | null — Base64 image or data URI (`data:image/png;base64,...`). `null` removes the photo; lines that cannot clear a photo show a plain black image instead. - sharing: object Responses: 200, 401, 422 ### POST /me/numbers/{number}/contact-card/avatar — Upload a contact card avatar operationId: uploadContactCardAvatar Uploads an avatar image for the number's contact card. Max 10MB. Parameters: - number (path, required): string — Sender phone number in E.164 format, e.g. `+15551234567`. Responses: 200, 400, 401 ### POST /me/numbers/{number}/call-forwarding — Request call forwarding operationId: requestCallForwarding Opens a support request to forward calls from an owned dedicated or inbound Blooio number. The destination must be a US E.164 number. Shared or foreign source numbers are rejected. Parameters: - number (path, required): string — Sender phone number in E.164 format, e.g. `+15551234567`. Request body (application/json): - forward_to (required): string — US E.164 number to forward calls to. Responses: 200, 401, 403, 422 ## Location Find My friend locations (device passthrough). ### GET /location/contacts — List Find My contacts operationId: listLocationContacts Lists Find My friends sharing their location with the linked Apple account. Responses: 200, 401 ### GET /location/contacts/refresh — Refresh Find My locations (GET) operationId: refreshLocationContactsGet Convenience GET form of the refresh endpoint. Forces a refresh of Find My location data from the device. Responses: 200, 401 ### POST /location/contacts/refresh — Refresh Find My locations operationId: refreshLocationContacts Forces a refresh of Find My location data from the device. Responses: 200, 401 ### GET /location/contacts/{handle} — Get one Find My contact operationId: getLocationContact Parameters: - handle (path, required): string — Contact handle to resolve — a phone number in E.164 format, an email address, or an Apple ID. URL-encode the value. Responses: 200, 401, 404 ## Channels Sending surfaces (iMessage/Blooio, Twilio, WhatsApp, RCS, Apple Messages for Business) and their capabilities. ### GET /channels — List channels operationId: listChannels Lists messaging channels for the organization. Ordinary sends do not require a channel id; use this endpoint to inspect capabilities or obtain an exact channel id for advanced integrations. Parameters: - type (query): "blooio" | "twilio" | "whatsapp" | "whatsapp_business" | "rcs_business" | "amb" — Filter by channel type. - status (query): "pending_verification" | "active" | "suspended" | "banned" | "deprecated" — Filter by channel status. - limit (query): integer — Maximum number of items to return per page. Must be between 1 and 200; defaults to 50. Combine with `cursor` to paginate. - cursor (query): string — Opaque pagination cursor. Pass the `next_cursor` value from a previous response to fetch the next page; omit it on the first request. Responses: 200, 400, 401 ### GET /channels/blooio/available — List available Blooio numbers operationId: listAvailableBlooioNumbers Browse Blooio number inventory available to purchase. `type=shared` returns available shared lines; `type=dedicated` returns unassigned dedicated inventory. When one or more `area_code` values are supplied with `type=dedicated` or `type=inbound`, the response is an area-code quote: each requested code is reported as either in stock (provisioned free) or requiring a custom order (a custom area-code fee applies and a provisioning ticket is opened at purchase). Requires the number purchase API feature to be enabled for the organization, and the `numbers:read` scope for OAuth apps. Parameters: - type (query): "shared" | "dedicated" | "inbound" — Plan family to browse. - area_code (query): string — A desired 3-digit US area code. Repeat the parameter (or pass a comma-separated `area_codes`) to quote several. Only meaningful for `dedicated`/`inbound`. - country (query): string — Filter inventory by ISO country code (e.g. `US`). - limit (query): integer — Maximum number of items to return per page. Must be between 1 and 200; defaults to 50. Combine with `cursor` to paginate. Responses: 200, 400, 401, 403 ### POST /channels/blooio/purchases — Purchase Blooio numbers operationId: purchaseBlooioNumbers Purchase one or more Blooio lines. Purchases are asynchronous and billable: the call charges the organization's default payment method and returns a `purchase_id`; the lines are provisioned when Stripe confirms the invoice, at which point a `number.purchase.completed` webhook fires (or `number.purchase.failed`). A card requiring 3DS/SCA returns `status: action_required` with an `action_url`, and also emits `number.purchase.action_required`. **Idempotency is required** — send a unique `Idempotency-Key` header; replaying the same key returns the same purchase rather than charging twice. Rules enforced server-side (identically to the dashboard): the organization must have a saved payment method (else `402 no_payment_method`); shared plans are limited to one line per organization (`409 shared_number_limit`); and a single order may not exceed the per-order line cap (`400 line_limit_exceeded`, default 10 — apply for an increase in the dashboard Limits tab). Requested area codes that are out of stock incur a custom area-code fee and open a provisioning ticket; in-stock codes are assigned immediately with no custom fee. Requires the number purchase API feature to be enabled, and the `numbers:manage` scope for OAuth apps. Parameters: - Idempotency-Key (header, required): string — Unique key that makes the billable purchase safe to retry. Request body (application/json): - plan (required): "shared_nc" | "shared_com" | "dedicated_com" | "dedicated_ent" | "inbound_basic" — Plan id to purchase (determines shared / dedicated / inbound). Use the specific id, not the tier name — e.g. `dedicated_com` or `dedicated_ent`, not `dedicated` (which returns `invalid_plan`). - quantity: integer — Number of new lines (dedicated/inbound). Shared is always 1. - area_codes: string[] — Preferred 3-digit US area codes (dedicated/inbound). Out-of-stock codes incur a custom fee and open a ticket. - zip_codes: string[] — Preferred ZIP codes (dedicated/inbound), used when an area code is not specified. Responses: 202, 400, 401, 402, 403, 409 ### GET /channels/blooio/purchases/{purchaseId} — Get purchase status operationId: getBlooioPurchase Poll the status of an asynchronous number purchase by its `purchase_id`. Returns `pending`/`provisioning`, `completed` (with the provisioned allocations), or `failed`. Requires the `numbers:read` scope for OAuth apps. Parameters: - purchaseId (path, required): string Responses: 200, 400, 401, 403, 404 ### GET /channels/{channel} — Get a channel operationId: getChannel Parameters: - channel (path, required): string — The channel reference — the same three forms `from` accepts: a phone number (URL-encode `+` as `%2B`), a channel alias, or the exact channel id (`ch_...`). Responses: 200, 401, 404 ### DELETE /channels/{channel} — Remove (unsubscribe) a Blooio number operationId: removeBlooioNumber Removes/unsubscribes an owned Blooio line, cancelling its subscription — the same flow as the dashboard unsubscribe button. Accepts either the E.164 number or the `ch_` channel id as `{channel}`. A `reasons` array (why the customer is unsubscribing) is required; the same reason handling updates the CRM, posts to Slack, and emits a `number.removed` webhook. Requires the `numbers:manage` scope for OAuth apps. Parameters: - channel (path, required): string — The channel reference — the same three forms `from` accepts: a phone number (URL-encode `+` as `%2B`), a channel alias, or the exact channel id (`ch_...`). Request body (application/json): - reasons (required): string[] — One or more churn reason codes/labels for the removal. Responses: 200, 400, 401, 403, 404 ### GET /channels/{channel}/capabilities — Get channel capabilities operationId: getChannelCapabilities Returns supported protocols, content types, actions, and interactive features for a channel. Parameters: - channel (path, required): string — The channel reference — the same three forms `from` accepts: a phone number (URL-encode `+` as `%2B`), a channel alias, or the exact channel id (`ch_...`). Responses: 200, 401, 404 ### GET /channels/{channel}/health — Get line health operationId: getChannelHealth Messaging health for one dedicated or inbound Blooio line: its live safety state plus reply, delivery, and risk metrics over the trailing `days`. Built for polling. **Freshness.** `safety` is read live on every request, so a new safety action shows on the next poll. The metrics (`replies`, `delivery`, `risk`, `daily`) are recomputed at most every 5 minutes and may be served up to 30 minutes old while a recompute runs in the background. Read `computed_at` (equal to `window.end`) rather than assuming freshness. Polling more often than every 5 minutes returns the same metrics; polling by the `ch_` channel id is cheapest, because a phone number or alias is resolved first. **First-reply rate.** `replies.first_reply_rate` is the share of conversations this line started that got a written reply (reactions don't count). At the default `days=7` it is the same number the safety system's one-way check uses: with more than 10 new conversations a day, a rate below 0.4 slows the line, and it pauses new conversations after 48 hours in that pattern or once the rate falls below 0.2. **Risk reply rate.** `risk.factors.reply_rate` uses a different definition: a conversation counts as replied once it has 3 or more inbound messages, whoever started it. **Delivery comparison.** The safety system compares the last 72 hours of delivery against a 7-day baseline. `days=3` approximates the recent sample, and subtracting its counts from `days=7` approximates the baseline. **Days.** Day-bucketed values (`replies.new_conversations`, `replies.first_replies`, `daily`) use UTC days. Shared and trial lines return `403 health_unsupported_for_line_type`; non-Blooio channels return `403 health_unsupported_for_channel_type`. Parameters: - channel (path, required): string — The channel reference — the same three forms `from` accepts: a phone number (URL-encode `+` as `%2B`), a channel alias, or the exact channel id (`ch_...`). - days (query): integer — Trailing window in days, 1 to 30. Anything else returns `400 invalid_window`. Responses: 200, 400, 401, 403, 404 ### GET /channels/{channel}/settings — Get channel settings operationId: getChannelSettings Per-channel settings for channel types that support them. `settings` and `available` are per channel type; the Blooio (P2P numbers) provider exposes `auto_mark_read` and `auto_share_contact`. `available[key]` says whether THIS channel may turn `key` on (type-gated). A channel whose type has no settings yet returns `403 settings_unsupported_for_channel_type`. Parameters: - channel (path, required): string — The channel reference — the same three forms `from` accepts: a phone number (URL-encode `+` as `%2B`), a channel alias, or the exact channel id (`ch_...`). Responses: 200, 401, 403, 404 ### PATCH /channels/{channel}/settings — Update channel settings operationId: updateChannelSettings Partial update of a channel's settings (send only the keys you want to change). Keys are per channel type. Blooio (P2P numbers): `auto_mark_read` is available on dedicated and inbound lines; `auto_share_contact` is dedicated-only AND the line must have a contact card (name or photo) set up. Turning a setting OFF is always allowed. Errors return a machine-readable `code`: `settings_unsupported_for_channel_type` (403, non-Blooio channel), `auto_mark_read_not_allowed` / `auto_share_contact_not_allowed` (403, line type can't enable it), `contact_card_not_set_up` (403, enable auto_share_contact with no contact card), `no_settings_provided` (422, empty update), `invalid_settings` (422, unknown key or non-boolean value). A malformed channel selector or non-object body is `400`. Parameters: - channel (path, required): string — The channel reference — the same three forms `from` accepts: a phone number (URL-encode `+` as `%2B`), a channel alias, or the exact channel id (`ch_...`). Request body (application/json): - auto_mark_read: boolean — Blooio: mark a chat read on the device as soon as a message arrives. Dedicated + inbound lines only. - auto_share_contact: boolean — Blooio: share the number's Name & Photo card with people who message it. Dedicated lines only, and the line must have a contact card set up. Responses: 200, 400, 401, 403, 404, 422 ### PUT /channels/{channel}/profile — Update channel profile operationId: updateChannelProfile Partial update of the channel profile (`display_name`, `about`, `email`, `avatar`). Stored under `metadata.profile` and returned on `GET /channels/{channel}`. Null or empty string clears a field. Unknown keys are rejected. This writes Blooio metadata only; it does not push a display name to the device. Parameters: - channel (path, required): string — The channel reference — the same three forms `from` accepts: a phone number (URL-encode `+` as `%2B`), a channel alias, or the exact channel id (`ch_...`). Request body (application/json): - display_name: string | null - about: string | null - email: string | null - avatar: string | null Responses: 200, 400, 401, 404, 422 ### POST /channels/{channel}/messages — Send a message on a specific channel operationId: sendMessageToChannel Channel-scoped equivalent of `POST /messages` with an explicit `from`: `:channel` accepts the same phone number (URL-encode `+` as `%2B`), alias, or `ch_...` value `from` accepts, and the body omits `from`. Multiple recipients on a group-capable channel (blooio/whatsapp) form a single group thread; on other channels they fan out to individual sends (max 100). Parameters: - channel (path, required): string — The channel reference — the same three forms `from` accepts: a phone number (URL-encode `+` as `%2B`), a channel alias, or the exact channel id (`ch_...`). - Idempotency-Key (header): string — Optional key for safe retries. Replaying the same key with an identical body returns the original result; a different body returns 409. Use a unique value (such as a UUID) per logical request. Request body (application/json): - text: string — Plain text message. May be combined with `attachments` (sent as media with a caption) — except a lone audio file, which is sent as a standalone voice memo and cannot carry a caption. Set `format: "markdown"` to interpret Markdown styling in this field (and in `parts[].text`). - format: "plain" | "markdown" — How to interpret `text` (and each `parts[].text`). Defaults to `plain`, which sends the string exactly as given. With `markdown`, four constructs are parsed and delivered as real iMessage rich text — the recipient sees styled text, not delimiters: | Construct | Syntax | | --- | --- | | Bold | `**bold**` or `__bold__` | | Italic | `*italic*` or `_italic_` | | Underline | `++underline++` | | Strikethrough | `~~strike~~` | They nest freely (`**bold and _italic_**`). Everything else Markdown can express — headings, lists, links, code spans, blockquotes, images — is NOT styling iMessage can carry, so it is passed through as literal characters: `[Blooio](https://blooio.com)` is delivered with its brackets and URL intact, and `# Heading` keeps its `#`. Escape a delimiter with a backslash (`\*not italic\*`) to send it literally. The styling travels in the message's attributed body, so the stored `text` and the `text` returned on reads and webhooks is always the plain string the recipient sees, with the delimiters removed. The Markdown itself comes back as `formatted_text`, re-serialized into a normalized spelling rather than echoed verbatim (`__bold__` returns as `**bold**`). Only valid on Blooio iMessage channels — `422 format_unsupported_for_channel_type` on any other channel type, since no other channel type has a rich-text equivalent and would otherwise deliver your delimiters as literal text. Rich text also requires the message to be delivered over iMessage: a Blooio send that falls back to SMS arrives as unstyled plain text (the `text` string), because SMS cannot carry styling. Rejected with `422 invalid_content` when set without `text` or `parts`, when the Markdown source exceeds 20000 characters, or when it compiles to more than 256 distinct formatting ranges. - attachments: string[] — Public https media URLs. May be combined with `text`. On Blooio iMessage, two or more image/video URLs are grouped into one photo/video carousel/collage bubble by default; set `carousel: false` to send them as separate bubbles. **Voice memos:** a single audio file (`.mp3`, `.m4a`, `.wav`, `.aac`, `.opus`, `.ogg`) is automatically sent as a voice memo (the native waveform/scrubber bubble), not a plain audio-file attachment — no extra field is needed. A voice memo is a standalone bubble, so it cannot be combined with `text` or any other attachment; do that and the send is rejected with `422 invalid_content`. To send audio with a message, send the voice memo and the text as two separate requests. - rich_link: object — A tappable rich link bubble (Blooio / Apple Messages for Business). - app_clip: object — An App Clip bubble (Blooio p2p iMessage only). Sends an App Clip launch URL that Messages renders as a native App Clip card (app icon + name + Open button); tapping it launches the instant App Clip experience without an install. Provide either a ready-made `url` (Apple's `https://appclip.apple.com/id?p=` launcher, or an app's own associated-domain App Clip link) or the `bundle_id` convenience field, which is expanded into the canonical Apple launch URL. Tip: App-Clip availability is region/OS gated by Apple; an unavailable clip renders a card that falls back to the App Store. To deep-link into an already-installed app instead (e.g. open a specific screen in a downloaded app), send that app's universal link via `rich_link`. - imessage_app: object — Bring-your-own iMessage App Extension bubble (Blooio p2p iMessage only). Blooio is the transport, NOT the renderer: you point at YOUR OWN signed iMessage app extension and we assemble the MSMessage app balloon and send it. Recipients who have your app installed render its interactive overlay (its `MSMessagesAppViewController` reads `url` on tap); recipients who don't see the native template card (from `caption`/`image_url`) plus Apple's built-in App Store fallback. Identify your extension with `bundle_id` + `team_id` (used to form the `com.apple.messages.MSMessageExtensionBalloonPlugin::` plugin id) and pass the app-state `url` your extension understands. This does NOT ship or host a renderer — the recipient must have your app for the interactive experience. (For instant, no-install experiences use `app_clip`; to deep-link into an already-installed app use `rich_link`.) - poll: object — A native Blooio poll. Routed sends automatically consider only poll-capable channels. - interactive: object — Interactive content. On Apple Messages for Business this renders as a native interactive message: a quick reply, list picker, time picker, dynamic form, Apple Pay request, authentication (Sign in) request, or a custom iMessage app. Select the variant with `kind` and populate that kind's required fields; fields not relevant to the chosen kind are ignored. Each `kind` is validated against the minimum shape Apple needs to render it BEFORE the message is sent: Apple's gateway accepts malformed payloads and then shows an empty bubble on device, so Blooio rejects them up front with a `422` and a kind-specific `code` (`invalid_quick_reply`, `invalid_list_picker`, `invalid_time_picker`, `invalid_form`, `invalid_apple_pay`, `invalid_authenticate`, `invalid_imessage_app`) whose `message` names the exact field/index that is wrong. Free-form objects (`dynamic`, `payment`, `authenticate`) are otherwise forwarded to Apple verbatim. Before sending, the chosen `kind` is also checked against the device capability-list Apple stamps on the conversation's latest inbound message: when a list is known and does not advertise the required capability, the send is rejected with `422 amb_capability_unavailable` (naming the missing Apple capability) rather than delivering a bubble the device can't render. - invitation: object — Apple Messages for Business invitation. Sends an Apple-managed notification template (Live Layout) that invites the customer into a business chat. Addressed to a phone number (`tel:+E164`) rather than an opaque Apple Messages for Business id. Requires prior Invitations approval from Apple and explicit customer opt-in. - template: object — Template content (WhatsApp Business). - parts: object[] — Ordered text/media parts sent as one multipart message (Blooio only). When every part is an image/video (two or more, no text parts) Messages renders them as a photo/video carousel/collage by default; set `carousel: false` to opt out. With `format: "markdown"`, each part's `text` is parsed for styling independently. - reply_to: string — Message id to reply to. Applies to the whole outgoing message. - effect: string — iMessage screen/bubble effect (Blooio, text only). - link_preview: object - badge: "sent_with_siri" | "sent_with_facetime" — Optional attribution badge shown under the bubble: `sent_with_siri` renders "Sent with Siri" and `sent_with_facetime` renders "Sent with FaceTime", the way a message dictated to Siri or sent during a FaceTime call appears. Omit for no badge. Any other value is rejected with `422 invalid_content`. Applies to `text`, `attachments` (media), `parts` (multipart), `rich_link`, and `app_clip` sends on Blooio iMessage channels only; rejected with `422 invalid_content` on other content types. No effect on non-Blooio channels. - carousel: boolean — Group image/video attachments into ONE message that Messages renders as a swipeable photo/video carousel/collage, instead of separate bubbles (Blooio p2p iMessage only). DEFAULTS ON: a Blooio send whose attachments (via `attachments` or `parts`) are two or more image/video URLs is grouped automatically — set `carousel: false` to send them as independent bubbles instead. Set `carousel: true` explicitly to also enforce the visual-only rule (a known non-visual extension like `.pdf`, a text part, or fewer than two attachments then returns `422 invalid_carousel`); left unset, a non-qualifying send simply isn't grouped. Image extensions: jpg/jpeg/png/gif/heic/heif/webp/bmp/tiff/avif; video: mov/mp4/m4v/qt/3gp/avi/mkv/webm; a URL with no extension is allowed (its real type resolves on the device). An optional `text` is carried as the carousel's caption. Only valid on Blooio iMessage channels (`422 carousel_unsupported_for_channel_type` if set on another channel type). - to (required): string | string[] | object | object — A recipient: a raw identifier string (phone/email/opaque provider id), an array of identifier strings (multiple recipients), an existing `{ group_id }` with an exact or hybrid owner sender, or (for routed sends) a `{ contact_id }`. A `contact_id` enables cross-channel-type routing via the contact's identities; a bare identifier reaches phone/email-addressable channels only. - dry_run: boolean — Validate and route only; do not send. Responses: 200, 201, 207, 401, 403, 404, 409, 422, 429, 501 ## Priorities Channel routing / number pools: ordered sets of channels walked by priority for the agnostic send. ### GET /priorities — List priorities operationId: listPriorities Lists channel priorities owned by the calling API key. Parameters: - limit (query): integer — Maximum number of items to return per page. Must be between 1 and 200; defaults to 50. Combine with `cursor` to paginate. - cursor (query): string — Opaque pagination cursor. Pass the `next_cursor` value from a previous response to fetch the next page; omit it on the first request. Responses: 200, 400, 401 ### POST /priorities — Create a priority operationId: createPriority Creates a channel priority. Channels sharing a `priority` (and type) form a pool; ascending priority is the send-time waterfall. The first priority created for a key becomes its default unless `is_default: false` is set. Request body (application/json): - name: string - is_default: boolean — Make this the key's default priority. Defaults to true for the key's first priority. - channels (required): string | object[] — Channels in the priority. Each item is a channel id string or `{ channel_id, priority }`. Channels sharing a priority must be the same type. Responses: 201, 400, 401, 404, 422 ### GET /priorities/{priorityId} — Get a priority operationId: getPriority Parameters: - priorityId (path, required): string — Unique identifier of the priority, prefixed with `priority_`. Returned when you create a priority or by the List priorities endpoint. Responses: 200, 401, 404 ### PATCH /priorities/{priorityId} — Update a priority operationId: updatePriority Updates a priority's name, default flag, and/or channel set. When `channels` is supplied it replaces the priority's channels wholesale. Parameters: - priorityId (path, required): string — Unique identifier of the priority, prefixed with `priority_`. Returned when you create a priority or by the List priorities endpoint. Request body (application/json): - name: string - is_default: boolean - channels: string | object[] Responses: 200, 401, 404, 422 ### DELETE /priorities/{priorityId} — Delete a priority operationId: deletePriority Parameters: - priorityId (path, required): string — Unique identifier of the priority, prefixed with `priority_`. Returned when you create a priority or by the List priorities endpoint. Responses: 200, 401, 404 ## Contacts Contacts, their channel identities, capabilities, timeline, and tags. ### GET /contacts — List contacts operationId: listContacts Lists contacts. Cursor pagination is the default. Supplying `offset`, `q`, `tag`, `sort`, or `filter` switches to offset pagination and includes `pagination.total`. Parameters: - identifier (query): string — Filter to the contact owning this phone/email. - q (query): string — Case-insensitive search across contact names, identities, and tags. - tag (query): string — Case-insensitive tag filter. - sort (query): "recent" | "oldest" | "name_asc" | "name_desc" - filter (query): "none" | "has_tags" | "no_tags" | "inbound_last" | "outbound_last" - offset (query): integer — Zero-based offset. Cannot be combined with cursor. - limit (query): integer — Maximum number of items to return per page. Must be between 1 and 200; defaults to 50. Combine with `cursor` to paginate. - cursor (query): string — Opaque pagination cursor. Pass the `next_cursor` value from a previous response to fetch the next page; omit it on the first request. Responses: 200, 400, 401 ### POST /contacts — Create a contact operationId: createContact Creates a contact, optionally with one identity (phone/email on a channel type). Request body (application/json): - name: string | null - identifier: string — Phone (E.164) or email to attach as the first identity. - channel_type: "blooio" | "twilio" | "whatsapp" | "whatsapp_business" | "rcs_business" | "amb" - channel_id: string — Required when channel_type is amb. Responses: 201, 400, 401, 409, 422 ### GET /contacts/{contactId} — Get a contact operationId: getContact Parameters: - contactId (path, required): string — Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint. Responses: 200, 400, 401, 404 ### PATCH /contacts/{contactId} — Update a contact operationId: updateContact Parameters: - contactId (path, required): string — Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint. Request body (application/json): - name (required): string Responses: 200, 401, 404, 422 ### DELETE /contacts/{contactId} — Delete a contact operationId: deleteContact Soft-deletes a contact and its identities. Parameters: - contactId (path, required): string — Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint. Responses: 200, 401, 404 ### POST /contacts/{contactId}/merge — Merge a contact operationId: mergeContact Collapses two auto-created contacts for the same person into one. The survivor is the `{contactId}` in the path; the `source_contact_id` in the body is absorbed into it. Every identity on the source is re-parented onto the survivor, tags are unioned, the survivor keeps its own name (falling back to the source's), and the source becomes a deprecated tombstone stamped with `merged_into`. Because chats and messages reference identities (not contacts), all conversations and timeline entries follow the moved identities automatically — nothing is rewritten or lost, and reads such as the survivor's timeline immediately reflect the merge. Idempotent: re-merging the same source into the same survivor returns 200; a source already merged into a different contact returns 409. Emits a `contact.merged` webhook event. Parameters: - contactId (path, required): string — Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint. Request body (application/json): - source_contact_id (required): string — The contact to merge into the survivor, prefixed with `ct_`. Must differ from the path contact. Responses: 200, 400, 401, 404, 409, 422 ### GET /contacts/{contactId}/identities — List contact identities operationId: listContactIdentities Parameters: - contactId (path, required): string — Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint. Responses: 200, 401, 404 ### POST /contacts/{contactId}/identities — Attach an identity operationId: attachContactIdentity Attaches a phone/email identity (on a channel type) to a contact. Parameters: - contactId (path, required): string — Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint. Request body (application/json): - identifier (required): string - channel_type: "blooio" | "twilio" | "whatsapp" | "whatsapp_business" | "rcs_business" | "amb" - channel_id: string — Required when channel_type is amb. Responses: 201, 401, 404, 409, 422 ### PATCH /contacts/{contactId}/identities/{identityId} — Update an identity operationId: updateContactIdentity Renames a contact identity in place. An optional name updates the parent contact in the same request. Parameters: - contactId (path, required): string — Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint. - identityId (path, required): string — Unique identifier of the contact identity, prefixed with `cid_`. Request body (application/json): - identifier (required): string - name: string | null Responses: 200, 400, 401, 404, 409, 422 ### DELETE /contacts/{contactId}/identities/{identityId} — Detach an identity operationId: detachContactIdentity Parameters: - contactId (path, required): string — Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint. - identityId (path, required): string — Unique identifier of the contact identity (a phone/email tied to the contact), prefixed with `cid_`. Returned by the List contact identities endpoint. Responses: 200, 401, 404 ### GET /contacts/{contactId}/capabilities — Get contact capabilities operationId: getContactCapabilities Returns per-identity reachability/capabilities (iMessage/SMS/RCS, etc.) for a contact. For iMessage (blooio) identities this is a LIVE per-number lookup performed by an active iMessage channel — the request is rejected with 409 if the API key has no active iMessage (blooio) channel provisioned. Parameters: - contactId (path, required): string — Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint. Responses: 200, 401, 404, 409 ### GET /contacts/{contactId}/timeline — Get contact timeline operationId: getContactTimeline A single, chronological cross-channel activity feed for a contact, unifying every conversation the contact takes part in — both 1:1 chats keyed on the contact's identities and group chats where the contact is a current member — across all channel types. After a merge, the survivor's timeline includes the absorbed contact's history automatically. Each entry is a polymorphic envelope tagged by `type`; ordered newest-first by `occurred_at`. By default only `message` entries are returned; pass `include_events=true` to interleave lifecycle and chat events. Parameters: - contactId (path, required): string — Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint. - limit (query): integer — Maximum number of items to return per page. Must be between 1 and 200; defaults to 50. Combine with `cursor` to paginate. - cursor (query): string — Opaque pagination cursor. Pass the `next_cursor` value from a previous response to fetch the next page; omit it on the first request. - include_events (query): boolean — When true, interleaves non-message activity (delivery/read receipts, reactions, chat/group/typing changes) from the events ledger into the feed as `message_event` / `chat_event` entries. Defaults to false (messages only). Responses: 200, 401, 404 ### GET /contacts/{contactId}/tags — List contact tags operationId: listContactTags Parameters: - contactId (path, required): string — Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint. Responses: 200, 401, 404 ### POST /contacts/{contactId}/tags — Add contact tags operationId: addContactTags Parameters: - contactId (path, required): string — Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint. Request body (application/json): - tags: string[] - tag: string Responses: 200, 401, 404, 422 ### DELETE /contacts/{contactId}/tags/{tag} — Remove a contact tag operationId: removeContactTag Parameters: - contactId (path, required): string — Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint. - tag (path, required): string — The tag to remove from the contact. URL-encode values that contain spaces or special characters. Responses: 200, 401, 404 ## Chats Conversations and chat-level actions (typing, read, contact card, background). ### GET /chats — List chats operationId: listChats Lists conversations, most recent first. Filter by channel type, state, channel, or contact. Parameters: - type (query): "blooio" | "twilio" | "whatsapp" | "whatsapp_business" | "rcs_business" | "amb" - state (query): "open" | "closed" | "blocked" | "deprecated" - channel_id (query): string - contact_id (query): string - limit (query): integer — Maximum number of items to return per page. Must be between 1 and 200; defaults to 50. Combine with `cursor` to paginate. - cursor (query): string — Opaque pagination cursor. Pass the `next_cursor` value from a previous response to fetch the next page; omit it on the first request. Responses: 200, 400, 401 ### POST /chats — Find or create a chat operationId: createChat Finds or creates a 1:1 chat with a recipient on a channel. Returns the chat (with `created` indicating whether it was newly created). Request body (application/json): - channel_id (required): string - to (required): string — Recipient identifier (phone/email/opaque provider id). Responses: 200, 201, 401, 422 ### GET /chats/{chatId} — Get a chat operationId: getChat The chat of a group that was merged into another group on its first send returns the surviving group's chat on the same number. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. Responses: 200, 401, 404 ### PATCH /chats/{chatId} — Update a chat operationId: updateChat Sets chat state (`open`/`closed`) on an Apple Messages for Business chat. Chats on every other channel return 422 `state_not_writable`: `closed` reflects the customer ending an Apple Messages for Business conversation, and no other protocol has an equivalent. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. Request body (application/json): - state: "open" | "closed" — Apple Messages for Business chats only. `closed` blocks further sends to the chat with 409 `chat_closed_by_user`; an Apple Messages for Business chat also closes itself when the customer ends the conversation, and reopens when they message again. Responses: 200, 401, 404, 422 ### POST /chats/{chatId}/typing — Start/stop typing indicator operationId: setTyping Shows or clears a business-to-customer typing indicator. Supported on Blooio channels and Apple Messages for Business. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. Request body (application/json): - state: "started" | "stopped" Responses: 200, 401, 404, 409, 422 ### DELETE /chats/{chatId}/typing — Stop typing indicator operationId: stopTyping Clears the business-to-customer typing indicator. Supported on Blooio channels and Apple Messages for Business. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. Responses: 200, 401, 404, 409 ### POST /chats/{chatId}/read — Mark chat as read operationId: markChatRead Sends a read receipt. Blooio channels only. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. Responses: 200, 401, 404, 409 ### POST /chats/{chatId}/contact-card — Share contact card operationId: shareContactCard Shares the sender's contact card into a chat. Blooio channels only. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. Responses: 200, 401, 404, 409 ### GET /chats/{chatId}/focus — Read Focus status operationId: getChatFocus Reads whether the other person in the chat has notifications silenced by a Focus, as their device shares it over iMessage. `silenced` means new messages arrive quietly; to break through for a message you sent, use Notify Anyway. `unknown` means the device has no status for this person (for example, a handle Messages has not seen yet, or someone who does not share Focus status). Apple does not expose a way to turn another person's Focus on or off. Returns 409 for group chats and non-Blooio channels. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. Responses: 200, 400, 401, 404, 409 ### GET /chats/{chatId}/background — Get chat background operationId: getChatBackground Reads the chat's background image (Blooio channels). Reads live from the device. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. Responses: 200, 401, 404 ### PUT /chats/{chatId}/background — Set chat background operationId: setChatBackground Uploads a chat background image. Max 10MB; image/jpeg|png|gif|webp|heic|heif. Blooio channels only. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. Responses: 200, 400, 401, 404 ### DELETE /chats/{chatId}/background — Remove chat background operationId: removeChatBackground Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. Responses: 200, 401, 404 ### GET /chats/{chatId}/participants — List chat participants operationId: listChatParticipants Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. Responses: 501 ### POST /chats/{chatId}/participants — Add a chat participant operationId: addChatParticipant Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. Responses: 501 ### DELETE /chats/{chatId}/participants/{identityId} — Remove a chat participant operationId: removeChatParticipant Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. - identityId (path, required): string — Unique identifier of the contact identity, prefixed with `cid_`. Returned by the List contact identities endpoint. Responses: 501 ## Messages Send messages and read message history, status, events, and reactions. ### GET /chats/{chatId}/messages — List chat messages operationId: listChatMessages Lists messages in a chat, newest first by default. Use `order=asc` for oldest first. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. - order (query): "asc" | "desc" - limit (query): integer — Maximum number of items to return per page. Must be between 1 and 200; defaults to 50. Combine with `cursor` to paginate. - cursor (query): string — Opaque pagination cursor. Pass the `next_cursor` value from a previous response to fetch the next page; omit it on the first request. Responses: 200, 401, 404 ### POST /chats/{chatId}/messages — Send a message to a chat operationId: sendMessageToChat Sends a message into an existing chat. Both the sender channel and recipient are inferred from the chat, so `from` and `to` must not be supplied. Returns the message object at the top level (not wrapped in `data`). Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. - Idempotency-Key (header): string — Optional key for safe retries. Replaying the same key with an identical body returns the original result; a different body returns 409. Use a unique value (such as a UUID) per logical request. Request body (application/json): - text: string — Plain text message. May be combined with `attachments` (sent as media with a caption) — except a lone audio file, which is sent as a standalone voice memo and cannot carry a caption. Set `format: "markdown"` to interpret Markdown styling in this field (and in `parts[].text`). - format: "plain" | "markdown" — How to interpret `text` (and each `parts[].text`). Defaults to `plain`, which sends the string exactly as given. With `markdown`, four constructs are parsed and delivered as real iMessage rich text — the recipient sees styled text, not delimiters: | Construct | Syntax | | --- | --- | | Bold | `**bold**` or `__bold__` | | Italic | `*italic*` or `_italic_` | | Underline | `++underline++` | | Strikethrough | `~~strike~~` | They nest freely (`**bold and _italic_**`). Everything else Markdown can express — headings, lists, links, code spans, blockquotes, images — is NOT styling iMessage can carry, so it is passed through as literal characters: `[Blooio](https://blooio.com)` is delivered with its brackets and URL intact, and `# Heading` keeps its `#`. Escape a delimiter with a backslash (`\*not italic\*`) to send it literally. The styling travels in the message's attributed body, so the stored `text` and the `text` returned on reads and webhooks is always the plain string the recipient sees, with the delimiters removed. The Markdown itself comes back as `formatted_text`, re-serialized into a normalized spelling rather than echoed verbatim (`__bold__` returns as `**bold**`). Only valid on Blooio iMessage channels — `422 format_unsupported_for_channel_type` on any other channel type, since no other channel type has a rich-text equivalent and would otherwise deliver your delimiters as literal text. Rich text also requires the message to be delivered over iMessage: a Blooio send that falls back to SMS arrives as unstyled plain text (the `text` string), because SMS cannot carry styling. Rejected with `422 invalid_content` when set without `text` or `parts`, when the Markdown source exceeds 20000 characters, or when it compiles to more than 256 distinct formatting ranges. - attachments: string[] — Public https media URLs. May be combined with `text`. On Blooio iMessage, two or more image/video URLs are grouped into one photo/video carousel/collage bubble by default; set `carousel: false` to send them as separate bubbles. **Voice memos:** a single audio file (`.mp3`, `.m4a`, `.wav`, `.aac`, `.opus`, `.ogg`) is automatically sent as a voice memo (the native waveform/scrubber bubble), not a plain audio-file attachment — no extra field is needed. A voice memo is a standalone bubble, so it cannot be combined with `text` or any other attachment; do that and the send is rejected with `422 invalid_content`. To send audio with a message, send the voice memo and the text as two separate requests. - rich_link: object — A tappable rich link bubble (Blooio / Apple Messages for Business). - app_clip: object — An App Clip bubble (Blooio p2p iMessage only). Sends an App Clip launch URL that Messages renders as a native App Clip card (app icon + name + Open button); tapping it launches the instant App Clip experience without an install. Provide either a ready-made `url` (Apple's `https://appclip.apple.com/id?p=` launcher, or an app's own associated-domain App Clip link) or the `bundle_id` convenience field, which is expanded into the canonical Apple launch URL. Tip: App-Clip availability is region/OS gated by Apple; an unavailable clip renders a card that falls back to the App Store. To deep-link into an already-installed app instead (e.g. open a specific screen in a downloaded app), send that app's universal link via `rich_link`. - imessage_app: object — Bring-your-own iMessage App Extension bubble (Blooio p2p iMessage only). Blooio is the transport, NOT the renderer: you point at YOUR OWN signed iMessage app extension and we assemble the MSMessage app balloon and send it. Recipients who have your app installed render its interactive overlay (its `MSMessagesAppViewController` reads `url` on tap); recipients who don't see the native template card (from `caption`/`image_url`) plus Apple's built-in App Store fallback. Identify your extension with `bundle_id` + `team_id` (used to form the `com.apple.messages.MSMessageExtensionBalloonPlugin::` plugin id) and pass the app-state `url` your extension understands. This does NOT ship or host a renderer — the recipient must have your app for the interactive experience. (For instant, no-install experiences use `app_clip`; to deep-link into an already-installed app use `rich_link`.) - poll: object — A native Blooio poll. Routed sends automatically consider only poll-capable channels. - interactive: object — Interactive content. On Apple Messages for Business this renders as a native interactive message: a quick reply, list picker, time picker, dynamic form, Apple Pay request, authentication (Sign in) request, or a custom iMessage app. Select the variant with `kind` and populate that kind's required fields; fields not relevant to the chosen kind are ignored. Each `kind` is validated against the minimum shape Apple needs to render it BEFORE the message is sent: Apple's gateway accepts malformed payloads and then shows an empty bubble on device, so Blooio rejects them up front with a `422` and a kind-specific `code` (`invalid_quick_reply`, `invalid_list_picker`, `invalid_time_picker`, `invalid_form`, `invalid_apple_pay`, `invalid_authenticate`, `invalid_imessage_app`) whose `message` names the exact field/index that is wrong. Free-form objects (`dynamic`, `payment`, `authenticate`) are otherwise forwarded to Apple verbatim. Before sending, the chosen `kind` is also checked against the device capability-list Apple stamps on the conversation's latest inbound message: when a list is known and does not advertise the required capability, the send is rejected with `422 amb_capability_unavailable` (naming the missing Apple capability) rather than delivering a bubble the device can't render. - invitation: object — Apple Messages for Business invitation. Sends an Apple-managed notification template (Live Layout) that invites the customer into a business chat. Addressed to a phone number (`tel:+E164`) rather than an opaque Apple Messages for Business id. Requires prior Invitations approval from Apple and explicit customer opt-in. - template: object — Template content (WhatsApp Business). - parts: object[] — Ordered text/media parts sent as one multipart message (Blooio only). When every part is an image/video (two or more, no text parts) Messages renders them as a photo/video carousel/collage by default; set `carousel: false` to opt out. With `format: "markdown"`, each part's `text` is parsed for styling independently. - reply_to: string — Message id to reply to. Applies to the whole outgoing message. - effect: string — iMessage screen/bubble effect (Blooio, text only). - link_preview: object - badge: "sent_with_siri" | "sent_with_facetime" — Optional attribution badge shown under the bubble: `sent_with_siri` renders "Sent with Siri" and `sent_with_facetime` renders "Sent with FaceTime", the way a message dictated to Siri or sent during a FaceTime call appears. Omit for no badge. Any other value is rejected with `422 invalid_content`. Applies to `text`, `attachments` (media), `parts` (multipart), `rich_link`, and `app_clip` sends on Blooio iMessage channels only; rejected with `422 invalid_content` on other content types. No effect on non-Blooio channels. - carousel: boolean — Group image/video attachments into ONE message that Messages renders as a swipeable photo/video carousel/collage, instead of separate bubbles (Blooio p2p iMessage only). DEFAULTS ON: a Blooio send whose attachments (via `attachments` or `parts`) are two or more image/video URLs is grouped automatically — set `carousel: false` to send them as independent bubbles instead. Set `carousel: true` explicitly to also enforce the visual-only rule (a known non-visual extension like `.pdf`, a text part, or fewer than two attachments then returns `422 invalid_carousel`); left unset, a non-qualifying send simply isn't grouped. Image extensions: jpg/jpeg/png/gif/heic/heif/webp/bmp/tiff/avif; video: mov/mp4/m4v/qt/3gp/avi/mkv/webm; a URL with no extension is allowed (its real type resolves on the device). An optional `text` is carried as the carousel's caption. Only valid on Blooio iMessage channels (`422 carousel_unsupported_for_channel_type` if set on another channel type). - dry_run: boolean Responses: 200, 201, 401, 403, 404, 409, 422, 429, 501 ### POST /messages — Send a message operationId: sendMessage The primary endpoint for starting a message. The common send is entirely scalar: `{ "from": "+15551230001", "to": "+15551234567", "text": "Hello" }`. `from` is one optional string: 1. **Phone number** — `"from": "+15551230001"` selects your exact numbered channel with that address. 2. **Alias** — `"from": "support"` selects the non-numbered channel (Apple Messages for Business / RCS Business) with that organization-wide alias. 3. **Channel id** — `"from": "ch_..."` is the advanced exact-channel form. 4. **Automatic** — omit `from` to use the key's default priority / automatic routing. Advanced routing controls (each mutually exclusive with `from`): `"priority_id": "priority_..."` routes through an explicit priority; `"channel_type": "blooio"` constrains automatic/priority routing to one type; `"hybrid": true` (or a representative phone number string) requests hybrid Twilio-first / Blooio-after-reply routing. Priority routing is history-first and content-aware. Poll content currently routes only through Blooio. Cross-type Apple Messages for Business routing requires a contact with an Apple Messages for Business identity. Existing groups require an exact sender whose channel owns the group or a hybrid sender that owns that channel. Returns the resolved scalar `from` plus the chosen `channel_id`, `chat_id`, and routing explanation. Parameters: - Idempotency-Key (header): string — Optional key for safe retries. Replaying the same key with an identical body returns the original result; a different body returns 409. Use a unique value (such as a UUID) per logical request. Request body (application/json): - text: string — Plain text message. May be combined with `attachments` (sent as media with a caption) — except a lone audio file, which is sent as a standalone voice memo and cannot carry a caption. Set `format: "markdown"` to interpret Markdown styling in this field (and in `parts[].text`). - format: "plain" | "markdown" — How to interpret `text` (and each `parts[].text`). Defaults to `plain`, which sends the string exactly as given. With `markdown`, four constructs are parsed and delivered as real iMessage rich text — the recipient sees styled text, not delimiters: | Construct | Syntax | | --- | --- | | Bold | `**bold**` or `__bold__` | | Italic | `*italic*` or `_italic_` | | Underline | `++underline++` | | Strikethrough | `~~strike~~` | They nest freely (`**bold and _italic_**`). Everything else Markdown can express — headings, lists, links, code spans, blockquotes, images — is NOT styling iMessage can carry, so it is passed through as literal characters: `[Blooio](https://blooio.com)` is delivered with its brackets and URL intact, and `# Heading` keeps its `#`. Escape a delimiter with a backslash (`\*not italic\*`) to send it literally. The styling travels in the message's attributed body, so the stored `text` and the `text` returned on reads and webhooks is always the plain string the recipient sees, with the delimiters removed. The Markdown itself comes back as `formatted_text`, re-serialized into a normalized spelling rather than echoed verbatim (`__bold__` returns as `**bold**`). Only valid on Blooio iMessage channels — `422 format_unsupported_for_channel_type` on any other channel type, since no other channel type has a rich-text equivalent and would otherwise deliver your delimiters as literal text. Rich text also requires the message to be delivered over iMessage: a Blooio send that falls back to SMS arrives as unstyled plain text (the `text` string), because SMS cannot carry styling. Rejected with `422 invalid_content` when set without `text` or `parts`, when the Markdown source exceeds 20000 characters, or when it compiles to more than 256 distinct formatting ranges. - attachments: string[] — Public https media URLs. May be combined with `text`. On Blooio iMessage, two or more image/video URLs are grouped into one photo/video carousel/collage bubble by default; set `carousel: false` to send them as separate bubbles. **Voice memos:** a single audio file (`.mp3`, `.m4a`, `.wav`, `.aac`, `.opus`, `.ogg`) is automatically sent as a voice memo (the native waveform/scrubber bubble), not a plain audio-file attachment — no extra field is needed. A voice memo is a standalone bubble, so it cannot be combined with `text` or any other attachment; do that and the send is rejected with `422 invalid_content`. To send audio with a message, send the voice memo and the text as two separate requests. - rich_link: object — A tappable rich link bubble (Blooio / Apple Messages for Business). - app_clip: object — An App Clip bubble (Blooio p2p iMessage only). Sends an App Clip launch URL that Messages renders as a native App Clip card (app icon + name + Open button); tapping it launches the instant App Clip experience without an install. Provide either a ready-made `url` (Apple's `https://appclip.apple.com/id?p=` launcher, or an app's own associated-domain App Clip link) or the `bundle_id` convenience field, which is expanded into the canonical Apple launch URL. Tip: App-Clip availability is region/OS gated by Apple; an unavailable clip renders a card that falls back to the App Store. To deep-link into an already-installed app instead (e.g. open a specific screen in a downloaded app), send that app's universal link via `rich_link`. - imessage_app: object — Bring-your-own iMessage App Extension bubble (Blooio p2p iMessage only). Blooio is the transport, NOT the renderer: you point at YOUR OWN signed iMessage app extension and we assemble the MSMessage app balloon and send it. Recipients who have your app installed render its interactive overlay (its `MSMessagesAppViewController` reads `url` on tap); recipients who don't see the native template card (from `caption`/`image_url`) plus Apple's built-in App Store fallback. Identify your extension with `bundle_id` + `team_id` (used to form the `com.apple.messages.MSMessageExtensionBalloonPlugin::` plugin id) and pass the app-state `url` your extension understands. This does NOT ship or host a renderer — the recipient must have your app for the interactive experience. (For instant, no-install experiences use `app_clip`; to deep-link into an already-installed app use `rich_link`.) - poll: object — A native Blooio poll. Routed sends automatically consider only poll-capable channels. - interactive: object — Interactive content. On Apple Messages for Business this renders as a native interactive message: a quick reply, list picker, time picker, dynamic form, Apple Pay request, authentication (Sign in) request, or a custom iMessage app. Select the variant with `kind` and populate that kind's required fields; fields not relevant to the chosen kind are ignored. Each `kind` is validated against the minimum shape Apple needs to render it BEFORE the message is sent: Apple's gateway accepts malformed payloads and then shows an empty bubble on device, so Blooio rejects them up front with a `422` and a kind-specific `code` (`invalid_quick_reply`, `invalid_list_picker`, `invalid_time_picker`, `invalid_form`, `invalid_apple_pay`, `invalid_authenticate`, `invalid_imessage_app`) whose `message` names the exact field/index that is wrong. Free-form objects (`dynamic`, `payment`, `authenticate`) are otherwise forwarded to Apple verbatim. Before sending, the chosen `kind` is also checked against the device capability-list Apple stamps on the conversation's latest inbound message: when a list is known and does not advertise the required capability, the send is rejected with `422 amb_capability_unavailable` (naming the missing Apple capability) rather than delivering a bubble the device can't render. - invitation: object — Apple Messages for Business invitation. Sends an Apple-managed notification template (Live Layout) that invites the customer into a business chat. Addressed to a phone number (`tel:+E164`) rather than an opaque Apple Messages for Business id. Requires prior Invitations approval from Apple and explicit customer opt-in. - template: object — Template content (WhatsApp Business). - parts: object[] — Ordered text/media parts sent as one multipart message (Blooio only). When every part is an image/video (two or more, no text parts) Messages renders them as a photo/video carousel/collage by default; set `carousel: false` to opt out. With `format: "markdown"`, each part's `text` is parsed for styling independently. - reply_to: string — Message id to reply to. Applies to the whole outgoing message. - effect: string — iMessage screen/bubble effect (Blooio, text only). - link_preview: object - badge: "sent_with_siri" | "sent_with_facetime" — Optional attribution badge shown under the bubble: `sent_with_siri` renders "Sent with Siri" and `sent_with_facetime` renders "Sent with FaceTime", the way a message dictated to Siri or sent during a FaceTime call appears. Omit for no badge. Any other value is rejected with `422 invalid_content`. Applies to `text`, `attachments` (media), `parts` (multipart), `rich_link`, and `app_clip` sends on Blooio iMessage channels only; rejected with `422 invalid_content` on other content types. No effect on non-Blooio channels. - carousel: boolean — Group image/video attachments into ONE message that Messages renders as a swipeable photo/video carousel/collage, instead of separate bubbles (Blooio p2p iMessage only). DEFAULTS ON: a Blooio send whose attachments (via `attachments` or `parts`) are two or more image/video URLs is grouped automatically — set `carousel: false` to send them as independent bubbles instead. Set `carousel: true` explicitly to also enforce the visual-only rule (a known non-visual extension like `.pdf`, a text part, or fewer than two attachments then returns `422 invalid_carousel`); left unset, a non-qualifying send simply isn't grouped. Image extensions: jpg/jpeg/png/gif/heic/heif/webp/bmp/tiff/avif; video: mov/mp4/m4v/qt/3gp/avi/mkv/webm; a URL with no extension is allowed (its real type resolves on the device). An optional `text` is carried as the carousel's caption. Only valid on Blooio iMessage channels (`422 carousel_unsupported_for_channel_type` if set on another channel type). - from: string — The sender as a single string: a phone number (any common format; matched to your exact numbered channel), a channel alias (organization-wide name of a non-numbered channel), or an exact channel id (ch_...). Omit it for automatic routing. If a phone number ever matches more than one active channel type, the send fails with `409 ambiguous_from` and the exact `ch_...` id must be used instead. - priority_id: string — Route through an explicit priority (priority_...). Mutually exclusive with `from`. - channel_type: "blooio" | "twilio" | "whatsapp" | "whatsapp_business" | "rcs_business" | "amb" - hybrid: boolean | string — Hybrid (Twilio-first / Blooio-after-reply) routing. `true` uses the calling key's hybrid configuration; a phone number string selects the hybrid owner of that representative number. Mutually exclusive with `from` and `priority_id`. - to (required): string | string[] | object | object — A recipient: a raw identifier string (phone/email/opaque provider id), an array of identifier strings (multiple recipients), an existing `{ group_id }` with an exact or hybrid owner sender, or (for routed sends) a `{ contact_id }`. A `contact_id` enables cross-channel-type routing via the contact's identities; a bare identifier reaches phone/email-addressable channels only. - dry_run: boolean — Validate and route only; do not send. Responses: 200, 201, 207, 401, 403, 404, 409, 422, 429, 501 ### GET /chats/{chatId}/messages/{messageId} — Get a message operationId: getChatMessage Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. - messageId (path, required): string — Unique identifier of the message — the `id` returned when you send a message or by the List messages endpoint. Outbound messages use a `msg_` id; inbound (received) messages use the id the List endpoint exposes for that row. Both are addressable. Responses: 200, 401, 404 ### GET /chats/{chatId}/messages/{messageId}/status — Get message status operationId: getMessageStatus Lightweight delivery status of a message. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. - messageId (path, required): string — Unique identifier of the message — the `id` returned when you send a message or by the List messages endpoint. Outbound messages use a `msg_` id; inbound (received) messages use the id the List endpoint exposes for that row. Both are addressable. Responses: 200, 401, 404 ### GET /chats/{chatId}/messages/{messageId}/events — List message events operationId: listMessageEvents Lifecycle events for a message (sent, delivered, read, reactions, ...). Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. - messageId (path, required): string — Unique identifier of the message — the `id` returned when you send a message or by the List messages endpoint. Outbound messages use a `msg_` id; inbound (received) messages use the id the List endpoint exposes for that row. Both are addressable. - limit (query): integer — Maximum number of items to return per page. Must be between 1 and 200; defaults to 50. Combine with `cursor` to paginate. - cursor (query): string — Opaque pagination cursor. Pass the `next_cursor` value from a previous response to fetch the next page; omit it on the first request. Responses: 200, 401, 404 ### POST /chats/{chatId}/messages/{messageId}/reactions — React to a message operationId: addReaction Adds or removes a tapback reaction on any message in the chat — inbound (received) or outbound — addressed by the message `id` from the List messages endpoint. Use a `+` prefix to add and `-` to remove (e.g. `+love`, `-love`). Returns 404 if the message isn't in the chat and 409 if it hasn't been dispatched to the device yet. Blooio channels only. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. - messageId (path, required): string — Unique identifier of the message — the `id` returned when you send a message or by the List messages endpoint. Outbound messages use a `msg_` id; inbound (received) messages use the id the List endpoint exposes for that row. Both are addressable. Request body (application/json): - reaction (required): string — e.g. '+love' to add, '-love' to remove. Responses: 200, 401, 404, 409, 422 ### POST /chats/{chatId}/messages/{messageId}/notify — Notify Anyway operationId: notifyMessage Breaks through the recipient's Focus for a message you sent, the same as tapping Notify Anyway in Messages. Use it when Read Focus status reports `silenced`. Calling it again for a message that was already notified succeeds. It cannot be undone. Returns 404 if the message isn't in the chat. Returns 409 for an inbound message, an SMS or RCS message, a message that hasn't been dispatched to the device yet, a group chat, or a non-Blooio channel. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. - messageId (path, required): string — Unique identifier of the message — the `id` returned when you send a message or by the List messages endpoint. Outbound messages use a `msg_` id; inbound (received) messages use the id the List endpoint exposes for that row. Both are addressable. Responses: 200, 400, 401, 404, 409 ### POST /attachments — Register an attachment operationId: createAttachment Responses: 501 ### GET /attachments/{attachmentId} — Get an attachment operationId: getAttachment Parameters: - attachmentId (path, required): string — Unique identifier of the attachment, as referenced on a message's content. Responses: 501 ### GET /templates — List templates operationId: listTemplates Responses: 501 ### POST /templates — Create a template operationId: createTemplate Responses: 501 ### GET /templates/{templateId} — Get a template operationId: getTemplate Parameters: - templateId (path, required): string — Unique identifier of the message template. Returned when you create a template or by the List templates endpoint. Responses: 501 ### DELETE /templates/{templateId} — Delete a template operationId: deleteTemplate Parameters: - templateId (path, required): string — Unique identifier of the message template. Returned when you create a template or by the List templates endpoint. Responses: 501 ## Polls Native iMessage polls (Blooio channels). ### POST /chats/{chatId}/polls — Send a poll operationId: sendPoll Sends a native iMessage poll to a chat. Requires a title and 2+ options. Blooio channels only. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. Request body (application/json): - title (required): string - options (required): string[] Responses: 201, 401, 404, 409, 422 ### POST /chats/{chatId}/polls/{pollId}/vote — Vote on a poll operationId: votePoll Casts or toggles a vote by `option_index` (0-based) or `option` text. Blooio channels only. Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. - pollId (path, required): string — Unique identifier of the poll, prefixed with `msg_` (a poll shares the id of its underlying message). Request body (application/json): - option_index: integer — 0-based option index. - option: string — Option text. Responses: 200, 201, 401, 404, 409, 422 ### GET /chats/{chatId}/polls/{pollId} — Get poll results operationId: getPollResults Parameters: - chatId (path, required): string — Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint. - pollId (path, required): string — Unique identifier of the poll, prefixed with `msg_` (a poll shares the id of its underlying message). Responses: 200, 401, 404 ## Groups Group chats on group-capable channels (Blooio, WhatsApp). ### GET /groups — List groups operationId: listGroups Parameters: - limit (query): integer — Maximum number of items to return per page. Must be between 1 and 200; defaults to 50. Combine with `cursor` to paginate. - cursor (query): string — Opaque pagination cursor. Pass the `next_cursor` value from a previous response to fetch the next page; omit it on the first request. Responses: 200, 401 ### POST /groups — Create a group operationId: createGroup Creates a group chat on a group-capable channel (blooio/whatsapp) with one or more members. No iMessage conversation exists yet: the first message sent to the group opens it, and `chat_linked` becomes `true` once it does. There is no separate linking step. On that first send the device may hand back an iMessage conversation that another of your groups already holds with the same people. The new group is then merged into that one. Its group id keeps working for `GET /groups/{groupId}`, `GET /groups/{groupId}/members` and sends with `to.group_id`, and its chat id for `GET /chats/{chatId}`; each resolves to the surviving group, whose `id` is the canonical one. Every other request addressed to a merged id (updates, deletes, chat actions) returns 404, so switch to the canonical id once you have it. Request body (application/json): - channel_id (required): string — Must be a group-capable channel (blooio/whatsapp). - members (required): string | object[] — Member phone numbers (E.164) or emails, each given as a string or an object `{ "identifier": "..." }`. Every identifier is validated; invalid members 422 `invalid_members` instead of being dropped. - name: string Responses: 201, 400, 401, 422 ### GET /groups/{groupId} — Get a group operationId: getGroup A group id that was merged into another group on its first send (see Create a group) returns the surviving group. Compare the returned `id` with the one you asked for to pick up the canonical id. Parameters: - groupId (path, required): string — Unique identifier of the group, prefixed with `grp_`. Returned by the List groups endpoint. Responses: 200, 401, 404 ### PATCH /groups/{groupId} — Update a group operationId: updateGroup Parameters: - groupId (path, required): string — Unique identifier of the group, prefixed with `grp_`. Returned by the List groups endpoint. Request body (application/json): - name (required): string Responses: 200, 401, 404, 422 ### DELETE /groups/{groupId} — Delete a group operationId: deleteGroup Parameters: - groupId (path, required): string — Unique identifier of the group, prefixed with `grp_`. Returned by the List groups endpoint. Responses: 200, 401, 404 ### GET /groups/{groupId}/members — List group members operationId: listGroupMembers For a merged group id, lists the members of the surviving group. Parameters: - groupId (path, required): string — Unique identifier of the group, prefixed with `grp_`. Returned by the List groups endpoint. Responses: 200, 401, 404 ### POST /groups/{groupId}/icon — Set group icon operationId: setGroupIcon Uploads a group icon. Max 10MB; image/jpeg|png|gif|webp|heic|heif. Requires the group chat to be linked on a device. Parameters: - groupId (path, required): string — Unique identifier of the group, prefixed with `grp_`. Returned by the List groups endpoint. Responses: 200, 400, 401, 404, 409 ### DELETE /groups/{groupId}/icon — Remove group icon operationId: removeGroupIcon Parameters: - groupId (path, required): string — Unique identifier of the group, prefixed with `grp_`. Returned by the List groups endpoint. Responses: 200, 401, 404, 409 ## Webhooks Webhook subscriptions and delivery attempts for inbound + lifecycle events. ### GET /webhooks — List webhooks operationId: listWebhooks Parameters: - limit (query): integer — Maximum number of items to return per page. Must be between 1 and 200; defaults to 50. Combine with `cursor` to paginate. - cursor (query): string — Opaque pagination cursor. Pass the `next_cursor` value from a previous response to fetch the next page; omit it on the first request. Responses: 200, 401 ### POST /webhooks — Create a webhook operationId: createWebhook Subscribes an HTTPS endpoint to events. Returns the signing secret once, on creation. Request body (application/json): - url (required): string — HTTPS endpoint. - event_types: string[] — Retired. Every webhook receives every event; filter by `type` in your handler. Omit this field, or send `["*"]`. Any narrower value is rejected with 422 `event_types_retired`. Subscriptions created before the retirement keep the list they were registered with. - api_key: string — Scope the subscription to the lines this API key owns. Omit `api_key` and `integration_id` for an organization-wide subscription that receives events from every line. Must belong to your organization; sending both is rejected with 422 `ambiguous_scope`. - integration_id: string — Scope the subscription to the lines this integration owns. Mutually exclusive with `api_key`. - channel_id: string - channel_type: "blooio" | "twilio" | "whatsapp" | "whatsapp_business" | "rcs_business" | "amb" - api_version: "2026-10-01" | "2026-09-01" — The payload version to deliver. Defaults to the latest. Anything not listed by `GET /webhooks/versions` is rejected with 422 `invalid_api_version`, whose `details.supported_versions` lists the accepted values. Responses: 201, 400, 401, 422 ### GET /webhooks/versions — List webhook payload versions operationId: listWebhookVersions Every payload version a subscription can be set to, newest first, with what each one changed. New subscriptions default to the version marked `latest`. Set a subscription's version with `api_version` on create or update. Responses: 200, 401 ### GET /webhooks/{webhookId} — Get a webhook operationId: getWebhook Parameters: - webhookId (path, required): string — Unique identifier of the webhook subscription, prefixed with `wh_`. Returned when you create a webhook or by the List webhooks endpoint. Responses: 200, 401, 404 ### PATCH /webhooks/{webhookId} — Update a webhook operationId: updateWebhook Updates url, status (`active`/`disabled`), or the payload `api_version`. A version change applies to the next event delivered; deliveries already made, and replays of them, keep the version they were sent with. Parameters: - webhookId (path, required): string — Unique identifier of the webhook subscription, prefixed with `wh_`. Returned when you create a webhook or by the List webhooks endpoint. Request body (application/json): - url: string - event_types: string[] — Retired. Omit this field to leave an existing subscription's frozen list untouched; sending anything narrower than `["*"]` is rejected with 422 `event_types_retired`. - status: "active" | "disabled" - api_version: "2026-10-01" | "2026-09-01" — Move the subscription to another payload version, for example to upgrade after updating your handler. Takes effect from the next event. Unsupported values are rejected with 422 `invalid_api_version`. Responses: 200, 401, 404, 422 ### DELETE /webhooks/{webhookId} — Delete a webhook operationId: deleteWebhook Parameters: - webhookId (path, required): string — Unique identifier of the webhook subscription, prefixed with `wh_`. Returned when you create a webhook or by the List webhooks endpoint. Responses: 200, 401, 404 ### POST /webhooks/{webhookId}/secret/rotate — Rotate webhook signing secret operationId: rotateWebhookSecret Rotates and returns a new signing secret (once). Parameters: - webhookId (path, required): string — Unique identifier of the webhook subscription, prefixed with `wh_`. Returned when you create a webhook or by the List webhooks endpoint. Responses: 200, 401, 404 ### GET /webhooks/{webhookId}/deliveries — List webhook deliveries operationId: listWebhookDeliveries Recent delivery attempts for a webhook. Also available at `/webhooks/{webhookId}/logs`. Parameters: - webhookId (path, required): string — Unique identifier of the webhook subscription, prefixed with `wh_`. Returned when you create a webhook or by the List webhooks endpoint. - limit (query): integer — Maximum number of items to return per page. Must be between 1 and 200; defaults to 50. Combine with `cursor` to paginate. - cursor (query): string — Opaque pagination cursor. Pass the `next_cursor` value from a previous response to fetch the next page; omit it on the first request. Responses: 200, 401, 404 ### POST /webhooks/{webhookId}/deliveries/{deliveryId}/replay — Replay a webhook delivery operationId: replayWebhookDelivery Re-sends a past delivery. Also available at `/webhooks/{webhookId}/logs/{deliveryId}/replay`. Parameters: - webhookId (path, required): string — Unique identifier of the webhook subscription, prefixed with `wh_`. Returned when you create a webhook or by the List webhooks endpoint. - deliveryId (path, required): string — Unique identifier of the webhook delivery attempt, prefixed with `wdel_`. Returned by the List webhook deliveries endpoint. Responses: 201, 400, 401, 404 ## Events Unified activity feed across messages, polls, groups, reactions, typing, payments, and messaging-safety state changes. ### GET /events — List events operationId: listEvents Unified activity feed (message lifecycle, polls, groups, reactions, typing, payments, `safety.state_changed`, `safety.number_banned`). Filter by type, chat, or message. Parameters: - type (query): string — Event type or wildcard, e.g. `message.received` or `message.*`. - chat_id (query): string - message_id (query): string - limit (query): integer — Maximum number of items to return per page. Must be between 1 and 200; defaults to 50. Combine with `cursor` to paginate. - cursor (query): string — Opaque pagination cursor. Pass the `next_cursor` value from a previous response to fetch the next page; omit it on the first request. Responses: 200, 400, 401 ### GET /events/{eventId} — Get an event operationId: getEvent Parameters: - eventId (path, required): string — Unique identifier of the event, prefixed with `evt_`. Returned by the List events endpoint. Responses: 200, 400, 401, 404 ## Phone Numbers Phone number lookup and geocoding (enterprise plans). ### GET /phone-numbers/lookup — Look up a phone number operationId: lookupPhoneNumber Returns parsed info / geocoding for a phone number. Requires an enterprise plan. Parameters: - number (query, required): string — Phone number in E.164. Responses: 200, 401, 403 ### POST /phone-numbers/lookup — Look up a phone number (POST) operationId: lookupPhoneNumberPost Same as the GET form, accepting the number in a JSON body. Returns parsed info / geocoding for a phone number. Requires an enterprise plan. Request body (application/json): - number (required): string — Phone number in E.164. Responses: 200, 400, 401, 403 ### POST /phone-numbers/batch — Batch phone number lookup operationId: batchLookupPhoneNumbers Looks up up to 100 phone numbers. Requires an enterprise plan. Request body (application/json): - numbers (required): string[] Responses: 200, 400, 401, 403, 422 ## API Keys Programmatic API key management — create keys, list them, deprecate them, and assign them to channels. **Blooio Apps (OAuth) only.** Every endpoint under this tag is callable *only* with a Blooio Apps OAuth access token (`blo_at_...`) whose install was granted the `apikeys:read` / `apikeys:manage` scopes. API-key and dashboard authentication are rejected with `403 oauth_token_required`. This mirrors the API-key management that has always lived in the dashboard, exposed to installed apps under explicit user consent. ### GET /api-keys — List API keys operationId: listApiKeys Lists the installed organization's API keys with their metadata (name, type, expiry, blocked/deprecated state). The raw key values are returned so the app can reuse them. **Blooio Apps (OAuth) only.** Requires the `apikeys:read` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`. Responses: 200, 401, 403 ### POST /api-keys — Create an API key operationId: createApiKey Mints a new API key for the installed organization and returns it. This is the **only** way to create an API key programmatically — the raw key is returned once in this response; store it securely. A new key owns no numbers until you assign channels to it (see Assign a channel to an API key) and, by default, has no explicit expiry (`valid_until: -1`). Organizations are capped at a fixed number of active keys; exceeding it returns `409 api_key_limit_reached`. **Blooio Apps (OAuth) only.** Requires the `apikeys:manage` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`. Request body (application/json): - name: string — Human-readable label for the key. - type: string — Optional key type/category tag. - valid_until: integer | null — Expiry time (epoch milliseconds). Pass `null` or `-1` (or omit) for a key that never expires. Responses: 201, 401, 403, 409 ### GET /api-keys/{apiKey} — Get an API key operationId: getApiKey Fetches a single API key's metadata by its value. **Blooio Apps (OAuth) only.** Requires the `apikeys:read` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`. Parameters: - apiKey (path, required): string — The API key value (prefixed with `api_`), as returned when the key was created or by the List API keys endpoint. Responses: 200, 401, 403, 404 ### DELETE /api-keys/{apiKey} — Deprecate an API key operationId: deprecateApiKey Deprecates (revokes) an API key. The key stops authenticating immediately and its number/channel ownership is released. Deprecation is idempotent and cannot be undone via the API. **Blooio Apps (OAuth) only.** Requires the `apikeys:manage` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`. Parameters: - apiKey (path, required): string — The API key value (prefixed with `api_`), as returned when the key was created or by the List API keys endpoint. Responses: 200, 401, 403, 404 ### GET /api-keys/{apiKey}/channels — List an API key's channels operationId: listApiKeyChannels Lists the numbered senders (Blooio and Twilio) the API key currently owns — the same lines the send layer will consider when a request authenticates with this key. **Blooio Apps (OAuth) only.** Requires the `apikeys:read` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`. Parameters: - apiKey (path, required): string — The API key value (prefixed with `api_`), as returned when the key was created or by the List API keys endpoint. Responses: 200, 401, 403, 404 ### PUT /api-keys/{apiKey}/channels/{channel} — Assign a channel to an API key operationId: assignChannelToApiKey Makes the API key the owner of a channel's line, so messages sent with that key can originate from it. This is the programmatic equivalent of assigning a number to a key on the dashboard **Channels** board. The key becomes the number's sole owner: if another API key or integration owned it, that ownership is deprecated (and its hybrid mode re-evaluated). Assigning is idempotent — re-assigning a line the key already owns returns `unchanged: true`. Only numbered, key-ownable channels can be assigned. Blooio lines are the primary case (Twilio where applicable); non-numbered channel types return `422 channel_not_assignable`. On plans without number pools, a key may own at most one line (`400`). **Blooio Apps (OAuth) only.** Requires the `apikeys:manage` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`. Parameters: - apiKey (path, required): string — The API key value (prefixed with `api_`), as returned when the key was created or by the List API keys endpoint. - channel (path, required): string — The channel reference — the same three forms `from` accepts: a phone number (URL-encode `+` as `%2B`), a channel alias, or the exact channel id (`ch_...`). Responses: 200, 400, 401, 403, 404, 422 ### DELETE /api-keys/{apiKey}/channels/{channel} — Unassign a channel from an API key operationId: unassignChannelFromApiKey Removes the API key's ownership of a channel's line. The key can no longer send from it. Hybrid mode is re-evaluated for the key. **Blooio Apps (OAuth) only.** Requires the `apikeys:manage` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`. Parameters: - apiKey (path, required): string — The API key value (prefixed with `api_`), as returned when the key was created or by the List API keys endpoint. - channel (path, required): string — The channel reference — the same three forms `from` accepts: a phone number (URL-encode `+` as `%2B`), a channel alias, or the exact channel id (`ch_...`). Responses: 200, 400, 401, 403, 404, 422 ## AI Model-backed decisions about conversations. Bloobability v2 returns, for each policy you write, a calibrated probability that it holds and a separate estimate of whether the conversation can answer it, from the instant or full tier you choose. The deprecated v1 gauge stays documented for reference. ### POST /ai/bloobability/v2/decide — Decide policies against a conversation operationId: decideBloobabilityV2 Decides every policy you send against one conversation with Bloobability v2. Each result carries two calibrated numbers in [0, 1]: `probability` (the policy holds) and `answerable` (the conversation contains enough to judge it). There is no verdict and no threshold: choose your own. A low `answerable` never changes `probability`. ## Tiers You choose the model with `tier`. Nothing routes between tiers or falls back from one to the other, and the response's `tier` echoes your choice. - `instant` is the fast tier, for short, explicit questions whose deciding facts are stated in the conversation. Its `answerable` is not a reliable check for missing information: in the latest measured screen it flagged 0 of 60 deliberately unanswerable cases. - `full` is the more capable tier, for reversals, conditions, several speakers, longer threads and conversations that may be missing facts. Use it when unsure. The tiers are separate models and do not return identical probabilities. Pin the tier, and store `model_version`, when you compare numbers across requests. ## Contract The body is checked exactly: unknown fields, explicit `null`, blank strings, duplicate JSON keys and duplicate message or policy IDs are rejected with 400, and `error.details` names the failing `reason`, `path` and `rule`. Message `timestamp`s, when given, must be RFC 3339 with an offset, non-decreasing, and not later than `assessment_time`. Bodies over 1 MiB are rejected with 413. A request that exceeds the chosen tier's context is rejected with 422 and never truncated. 429 and 503 `model_unavailable` mean the model is busy or restarting: retry with backoff. Errors are `{ "error": { "code", "message", "details" } }`; 5xx bodies carry no `details`. Policies come back in request order and their IDs are echoed unchanged. The call is stateless. Request body (application/json): - tier (required): "instant" | "full" — Which model decides. `instant` is fastest, for short questions with the deciding facts stated; `full` handles reversals, conditions and missing information. There is no `auto`. - messages (required): object[] — The conversation in chronological order. Every message is in scope; none are dropped. - policies (required): object[] — Independent policies. Probabilities do not sum to one. - assessment_time: string — Optional "now" for time-relative policies, RFC 3339 with an offset. Never defaulted to the request time, and it does not filter messages. Responses: 200, 400, 401, 403, 413, 422, 429, 502, 503, 504 ### POST /ai/bloobability/v1/decide — Bloobability v1 (deprecated, no longer available) operationId: decideBloobabilityV1 **Deprecated.** The Bloobability v1 model is no longer available, so every authenticated call returns 410 `deprecated` and the request body is not read. Use `POST /ai/bloobability/v2/decide` with the Bloobability v2 models instead; see the [Blooio inference page](https://blooio.com/inference). Responses: 401, 403, 410 # Guides ## iMessage REST API URL: https://docs.blooio.com/guides/imessage-rest-api A complete reference to the Blooio iMessage REST API — resources, HTTP verbs, authentication, errors, and idempotency. The Blooio iMessage REST API lets you send and receive iMessages over plain HTTPS. This guide is a practical tour of the API's resources and conventions so you can integrate with any HTTP client — curl, fetch, requests, axios, or anything else. ## What is a REST API for iMessage? Apple does not expose a public iMessage API. Blooio runs real Apple infrastructure and exposes a standard REST interface on top of it, so any app that can speak HTTP can send iMessages. Every request uses plain JSON and returns predictable, documented response shapes. Key properties: - **HTTPS only** — every request goes to `https://api.blooio.com/v2/api` - **Stateless** — every request carries a Bearer token; no cookies or sessions - **JSON in, JSON out** — no XML, no multipart except for file uploads - **Conventional HTTP verbs** — `GET`, `POST`, `PATCH`, `DELETE` do what you'd expect - **Predictable URLs** — `/chats/{chatId}/messages`, `/contacts/{contactId}`, `/groups/{groupId}/members` ## Base URL ``` https://api.blooio.com/v2/api ``` All examples in the docs are relative to this base URL. ## Authentication Every request includes a Bearer token in the `Authorization` header: ```bash curl -X GET https://api.blooio.com/v2/api/me \ -H "Authorization: Bearer YOUR_API_KEY" ``` Get an API key from the [Blooio dashboard](https://app.blooio.com). See [Authentication](/authentication) for details on rotating keys and scoping permissions. ## Resources The REST API is organised into a handful of resources. Each resource is a plural noun at a top-level path. | Resource | Path | What it represents | | -------- | -------------------------- | --------------------------------------------- | | Chats | `/chats` | 1-to-1 or group conversations | | Messages | `/chats/{chatId}/messages` | Individual messages inside a chat | | Contacts | `/contacts` | People you message | | Groups | `/groups` | Multi-party chats | | Webhooks | `/webhooks` | HTTP endpoints you register to receive events | | Numbers | `/me/numbers` | Phone numbers on your account | ## HTTP verbs Blooio uses the standard REST verb vocabulary. No surprises, no custom verbs. | Verb | Meaning | Example | | -------- | --------------------------- | --------------------------- | | `GET` | Fetch a resource or list | `GET /contacts` | | `POST` | Create or perform an action | `POST /chats/{id}/messages` | | `PATCH` | Partially update a resource | `PATCH /contacts/{id}` | | `DELETE` | Remove a resource | `DELETE /webhooks/{id}` | ## Sending a message The most common REST call — send an iMessage — is a single `POST`: **cURL** ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"text": "Hello from the REST API"}' ``` **Node.js** ```javascript const chatId = encodeURIComponent('+15551234567') const res = await fetch( `https://api.blooio.com/v2/api/chats/${chatId}/messages`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'Hello from the REST API' }) } ) const { message_id } = await res.json() ``` **Python** ```python from urllib.parse import quote chat_id = quote('+15551234567', safe='') res = requests.post( f'https://api.blooio.com/v2/api/chats/{chat_id}/messages', headers={ 'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}", 'Content-Type': 'application/json', }, json={'text': 'Hello from the REST API'}, ) print(res.json()['message_id']) ``` Response: ```json { "message_id": "Qm8FhbH3P7R", "status": "queued" } ``` ## Request conventions ### URL-encoding phone numbers Phone numbers contain a `+` which has special meaning in URLs. Always URL-encode the `chatId`: ```javascript // becomes %2B15551234567 const chatId = encodeURIComponent("+15551234567"); ``` Forgetting to encode the `+` is the single most common REST integration mistake. ### Pagination List endpoints use `limit` and `offset`: ```bash curl 'https://api.blooio.com/v2/api/chats?limit=25&offset=50' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Defaults: `limit=25`, `offset=0`. Maximum `limit` is 100. ### Sorting and filtering Most list endpoints accept `sort` (e.g. `created_at:desc`) and a `q` search parameter. See each endpoint's reference page for supported fields. ## Response shape Every successful response is a JSON object. List endpoints return: ```json { "data": [ ... ], "count": 42, "limit": 25, "offset": 0 } ``` Single-resource endpoints return the resource directly: ```json { "id": "msg_abc123", "text": "Hello", "status": "delivered", "created_at": "2026-04-19T12:34:56Z" } ``` ## Errors Errors use standard HTTP status codes and a consistent JSON body: ```json { "error": "invalid_request", "message": "text or attachments is required", "details": { "field": "text" } } ``` | Code | Meaning | Action | | ----- | ------------------------------------------------- | ---------------------------------------------------------------- | | `400` | Invalid request body | Fix the payload | | `401` | Missing or invalid API key | Check your Bearer token | | `403` | Forbidden | The API key lacks permission | | `404` | Resource not found | Check the ID or path | | `409` | Conflict | Duplicate idempotency key or state mismatch | | `422` | Semantically invalid | Payload is valid JSON but logically wrong | | `429` | Conversation or configured outbound limit reached | Inspect `code`; do not retry a messaging-safety limit on a timer | | `5xx` | Server error | Retry with exponential backoff | See [Messaging safety limits and recovery](/guides/messaging-safety) for every send-limit code and [Error handling](/guides/error-handling) for retry strategies. ## Idempotency Send the `Idempotency-Key` header on `POST` requests to make them safe to retry: ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Idempotency-Key: a1b2c3d4-order-123' \ -H 'Content-Type: application/json' \ -d '{"text": "Hello"}' ``` If the same key is sent twice within 24 hours, Blooio returns the original response without creating a duplicate message. See [Idempotency](/idempotency) for the full rules. ## Send limits Send endpoints use `429` for conversation-state and configured outbound limits. These responses do not become eligible merely because a timer elapsed. Inspect the machine-readable `code`: conversation limits require recipient activity, while configured outbound limits require a settings or usage change. Do not invent a retry delay when `Retry-After` is absent. ## Webhooks The REST API's read side is usually backed by webhooks — Blooio `POST`s events to a URL you register. Create a webhook: ```bash curl -X POST 'https://api.blooio.com/v4/webhooks' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "url": "https://yourapp.com/hooks/blooio" }' ``` Events include message received, delivered, failed, and reactions. Every subscription receives all of them, so switch on the `type` field in your handler. See [Webhook events](/api-reference/webhook-events) for the full catalogue. > **Note** > > If your endpoint is not yet reachable publicly, use a tunnel like ngrok — see > [Receive webhooks locally](/guides/webhooks-local). ## Code examples Full end-to-end examples live in [message-sending basics](/guides/message-sending). For language-specific walkthroughs see: - [Send iMessage with Python](/guides/send-imessage-api-python) - [Send iMessage with Node.js](/guides/send-imessage-api-nodejs) ## Next steps - [Quickstart](/quickstart) — send your first iMessage in under five minutes - [Authentication](/authentication) — API key management and rotation - [Webhook signatures](/guides/webhook-signatures) — verify event authenticity - [API reference](/api-reference/introduction) — every endpoint, every field ## Message sending basics URL: https://docs.blooio.com/guides/message-sending A complete guide to sending messages, including chat IDs, options, and common quirks This guide covers everything you need to know about sending messages through the Blooio API, including chat ID formats, message options, and common pitfalls. ## The basics Send a message by POSTing to `/chats/{chatId}/messages`: **cURL** ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"text": "Hello!"}' ``` **Node.js** ```javascript const chatId = encodeURIComponent('+15551234567') const res = await fetch(`https://api.blooio.com/v2/api/chats/${chatId}/messages`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'Hello!' }) }) ``` **Python** ```python from urllib.parse import quote chat_id = quote('+15551234567', safe='') res = requests.post(f'https://api.blooio.com/v2/api/chats/{chat_id}/messages', headers={ 'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}", 'Content-Type': 'application/json' }, json={'text': 'Hello!'} ) ``` ## Chat ID formats The `chatId` parameter determines who receives your message. Blooio supports four formats: | Format | Example | Description | |--------|---------|-------------| | Phone number | `+15551234567` | E.164 format (must be URL-encoded) | | Email | `user@example.com` | Send to iMessage-enabled email | | Group ID | `grp_abc123` | Send to an existing group | | Multi-recipient | `+15551234567,+15559876543` | Comma-separated list | ### URL encoding phone numbers Phone numbers contain a `+` character which has special meaning in URLs. Always URL-encode your chat ID: ```javascript // ✅ Correct const chatId = encodeURIComponent('+15551234567') // becomes %2B15551234567 // ❌ Wrong - the + becomes a space const chatId = '+15551234567' ``` > **Warning** > > Forgetting to URL-encode phone numbers is the most common integration mistake. > The `+` character becomes a space, resulting in invalid phone numbers. ### Multi-recipient messages Send to multiple people at once with a comma-separated list: ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567,%2B15559876543/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"text": "Hello everyone!"}' ``` When you send to multiple recipients: - Blooio creates an **unnamed group** automatically (or reuses an existing one with the same participants) - The response includes `group_id` and `group_created: true` if a new group was created - Subsequent messages to that combination reuse the same group ```json { "message_id": "msg_abc123", "status": "queued", "group_id": "grp_xyz789", "group_created": true, "participants": ["+15551234567", "+15559876543"] } ``` ## Message body options ### Text messages The `text` field can be a string or an array of strings: ```json // Single message {"text": "Hello!"} // Multiple messages (sent in sequence) {"text": ["Hello!", "How are you?", "Let me know if you have questions."]} ``` When you pass an array, each string becomes a separate message. The response includes `message_ids` (plural) instead of `message_id`. ### Rich text (Markdown) Set `format: "markdown"` to send **bold**, _italic_, underlined, or ~~struck-through~~ text. It is opt-in and additive: the default is `plain`, which sends your string exactly as given, so a request that omits `format` (or sends literal `**stars**`) is unchanged. ```json { "text": "**Order shipped!** Track it ++here++ or reply ~~STOP~~ to opt out.", "format": "markdown" } ``` Four constructs are parsed and delivered as real iMessage rich text — the recipient sees styled text, not the delimiters: | Construct | Syntax | | ------------- | ---------------------------- | | Bold | `**bold**` or `__bold__` | | Italic | `*italic*` or `_italic_` | | Underline | `++underline++` | | Strikethrough | `~~strike~~` | They nest freely (`**bold and _italic_**`). Everything else Markdown can express — headings, lists, links, code spans, images — is **not** styling iMessage can carry, so it is delivered as literal characters: `[Blooio](https://blooio.com)` keeps its brackets and URL. Escape a delimiter with a backslash (`\*not italic\*`) to send it literally. The styling travels in the message's attributed body, so `text` on reads and webhooks stays the plain string the recipient sees (delimiters removed). The Markdown comes back on a separate `formatted_text` field, re-serialized into a normalized spelling (`__bold__` returns as `**bold**`) — the same field is delivered on the message webhooks, so a message reads identically via REST or webhook. `format` applies to a text send and to `parts`. Combining it with `attachments` is rejected with `400 invalid_content`, because a media caption is not a styled bubble — send the media and the styled text as two messages instead. > **Warning** > > `format: "markdown"` is Blooio iMessage only. Any other channel type responds > `400 format_unsupported_for_channel_type`, and a Blooio send that falls back > to SMS arrives as unstyled plain text — SMS cannot carry styling. ### Attachments Include media by providing publicly accessible URLs: ```json { "text": "Check out this image", "attachments": ["https://cdn.example.com/image.png"] } ``` Or provide named attachments: ```json { "attachments": [ { "url": "https://cdn.example.com/doc.pdf", "name": "contract.pdf" } ] } ``` > **Note** > > Attachment URLs must be publicly accessible. Blooio downloads them before > sending. ### Typing indicators Show a typing indicator before sending: ```json { "text": "Hello!", "use_typing_indicator": true } ``` This makes messages feel more natural. The default follows your organization's preference in the dashboard. ### Sender number For accounts with multiple numbers, specify which number to send from: ```json { "text": "Hello!", "from_number": "+15559876543" } ``` See [Number Pools](/guides/number-pools) for details on how sender selection works. ## Message status lifecycle Messages progress through these statuses: | Status | Description | | ------------------------ | ---------------------------------------------------------------- | | `queued` | Accepted by Blooio, waiting to be handed to Apple/the carrier | | `sent` | Transmitted to Apple/the carrier (protocol resolves around here) | | `delivered` | Confirmed delivery to the recipient | | `failed` | Delivery failed (see the `error` field) | | `cancellation_requested` | A cancel was requested for a still-queued message (best-effort) | | `cancelled` | Cancelled before it was dispatched | Inbound messages arrive via webhooks with status `received`, and read receipts arrive as a `read` event. See the [Message fields reference](/guides/message-fields) for the full field-by-field breakdown. ### Checking status Poll the status endpoint: ```bash curl -H 'Authorization: Bearer YOUR_API_KEY' \ 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages/msg_abc123/status' ``` Or subscribe to `message.status` webhooks for real-time updates. ## Protocol detection Blooio automatically detects whether the recipient supports iMessage. The `protocol` field indicates how the message was sent. **It is never `null`** — a freshly-accepted message starts at `pending` and resolves to a concrete transport within seconds of send: | Protocol | Description | | ---------- | ------------------------------------------------------------------------------------------------------------------ | | `pending` | Accepted and dispatched; the wire service is not resolved yet (transient, settles quickly) | | `imessage` | Sent via iMessage (blue bubble) | | `rcs` | Sent via RCS | | `sms` | Fell back to SMS/MMS (green bubble) | | `unknown` | Accepted by the carrier, but the wire service couldn't be resolved before the tracking window closed (see `error`) | > **Note** > > Protocol is resolved at send time. A contact may switch between iMessage and > SMS if they change devices or disable iMessage. > **Warning** > > The legacy `non-imessage` value has been replaced. Unresolved sends now report > `pending` (transient) or `unknown` (terminal) instead. If you branch on > `protocol`, treat `pending` as "not yet known" rather than a final transport. ## Common quirks and gotchas ### Phone numbers must be E.164 format Always include the country code with `+` prefix: ```javascript // ✅ Correct "+15551234567"; // ❌ Wrong "5551234567"; // Missing country code "1-555-123-4567"; // Contains dashes "(555) 123-4567"; // Contains formatting ``` ### Messages return 202, not 200 Successful message sends return `202 Accepted` because messages are queued asynchronously. The message isn't sent yet—it's accepted for delivery. ### Use idempotency keys for retries Network issues can cause duplicate messages. Always include an `Idempotency-Key` header: ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: order-12345-confirmation' \ -d '{"text": "Order confirmed!"}' ``` If you retry with the same key, Blooio returns the original message with `200` instead of creating a duplicate. ### Group messages need a linked chat When sending to a group ID (`grp_xxx`), the group must have an active iMessage chat linked. Groups created via API don't automatically have chats—the first message creates the link. ### Emergency numbers are blocked Blooio refuses to send to emergency numbers (911, etc.) with a `403 Forbidden` response. ### Sender availability matters If no active number is currently available for your account, message sends fail with `503 Service Unavailable`. Retry once one of your provisioned senders is available again. ### Messaging safety limits exist Blooio enforces per-conversation allowances and applies graduated protections when a sending number shows risky one-way, repeated-template, delivery, or ban-related activity. Some limits return `403` or `429`; `queue` and `slow` instead accept the message and defer dispatch. See [Messaging safety limits and recovery](/guides/messaging-safety) for every limit, machine-readable error code, and recovery step. ## Response codes summary | Code | Meaning | | ----- | ----------------------------------------------------------- | | `200` | Duplicate request (idempotency key matched) | | `202` | Message accepted for sending | | `400` | Invalid request (bad phone format, missing text, etc.) | | `401` | Invalid or missing API key | | `403` | Action forbidden (emergency number, wrong number ownership) | | `404` | Chat or group not found | | `429` | Conversation or configured outbound limit reached | | `503` | No active number available | ## Next steps - [Send attachments](/guides/attachments) - Include images and files - [Messaging safety limits and recovery](/guides/messaging-safety) - Handle conversation and number protections - [Error handling](/guides/error-handling) - Handle failures gracefully - [Number Pools](/guides/number-pools) - Understand sender number selection - [Webhooks](/guides/webhooks-local) - Receive delivery updates ## Messaging safety limits and recovery URL: https://docs.blooio.com/guides/messaging-safety Understand conversation limits, number protections, error codes, and the steps that restore sending Blooio applies safety limits to protect iMessage numbers from patterns that Apple may treat as automated or unwanted messaging. There are two independent systems: - **Conversation limits** apply to one recipient or chat. They clear when that recipient responds. - **Number protections** apply to one sending number. They use rolling activity windows and clear when every triggering pattern has recovered. Do not blindly retry a blocked send. Read the response body's stable `code` field and follow the recovery step for that code. ## Conversation limits Each chat earns a larger consecutive-message allowance as the recipient builds a real two-way history with you. | Conversation state | How it is reached | Consecutive outbound allowance | How to recover | | ------------------ | ------------------------------------------------------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------- | | New | No written reply or reaction yet | 3 plain-text messages before the first response | Stop sending and wait for a reply or reaction. | | Acknowledged | The recipient reacted but has not written back | 3 messages after each response | Wait for another response. A written reply is still required before sending links or attachments. | | Active | At least one written reply | 4 messages after each response | Wait for another reply or reaction. | | Established | 3+ written replies across 2+ different UTC days | 6 messages after each response | Wait for another reply or reaction. | | Trusted | 10+ written replies across 3+ days in a thread at least 7 days old | 8 messages after each response | Wait for another reply or reaction. | | Inactive | No reply or reaction for more than 14 days | One plain-text re-engagement message | After the re-engagement, wait for a reply or reaction. | A reaction proves that the recipient is engaged, so it resets the consecutive-message allowance and keeps a chat from becoming inactive. It does **not** unlock rich content or promote the chat to a higher allowance. Only a written reply unlocks links, media, and attachments and contributes to the higher conversation states. ### Conversation error handling - `conversation_content_restricted` (`403`): send plain text and wait for a written reply before sending links, media, or attachments. - `conversation_awaiting_reply` (`429`): the 3-message first-contact allowance is exhausted. Wait for the recipient to respond. - `conversation_streak_limit` (`429`): the current state's consecutive-message allowance is exhausted. Wait for the recipient to respond. - `conversation_inactive_paused` (`429`): the one re-engagement message was already used. Wait for the recipient to respond. The response carries `retry_after` and a `Retry-After` header (300 seconds); that is how often to check back, not when the pause ends. These responses are not transient rate limits. Exponential retries will continue to fail and can create unwanted traffic. Resume the workflow after an inbound message or reaction event. ## Number-level protections Number protections are graduated. Blooio applies the most restrictive action required by any active reason. | Action | Effect | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `queue` | Sends are accepted. Up to 8 messages in one chat can drain together, followed by a 3–8 second wait before the next burst. A conversation's lower allowance still applies. | | `slow` | Sends are accepted with the same burst behavior and a 30–60 second wait. | | `pause_new` | Brand-new chats are rejected. Existing chats and recipients who have previously replied remain available. | | `reply_only` | Sending is limited to recipients who have messaged the organization first. | | `review` | All outbound sending on the number is paused. Contact support. | `queue` and `slow` are transparent deferrals: the API accepts the message and its status remains `queued` until dispatch. Do not submit a duplicate because delivery is taking longer. ### Recovery by reason The `reasons` object in a v4 `safety.state_changed` event identifies every active reason. The Limits page shows the same reason-specific recovery steps. If several reasons are present, each one must recover before the action clears completely. #### `one_way`: broad outreach with too few replies This reason becomes active when all of the following are true: - More than 10 new chats per day on a trailing 3-day average. - At least 10 new chats exist in the trailing 7-day sample. - Fewer than 40% of those new chats receive a written reply. It starts at `slow`. It escalates to `pause_new` when the first-reply rate falls below 20% or the pattern remains active for 48 hours. To recover: 1. Pause new outreach on the affected number. 2. Continue useful replies in conversations where recipients are already engaged. 3. Send future first messages only to opted-in recipients and make them specific enough to invite a genuine response. 4. Wait for the 3-day new-chat average to reach 10 or fewer, or for the trailing 7-day first-reply rate to reach at least 40%. Old activity remains in the rolling windows, so recovery can take several days even after behavior changes. #### `template`: repeated first-message openers This reason becomes active when there are at least 10 first touches in 7 days and at least half use a repeated opener. An opener is treated as repeated after the same fingerprint appears 5 or more times. To recover: 1. Stop reusing the flagged opener. Do not send additional outreach merely to dilute the percentage. 2. Personalize future first messages for the recipient and context. 3. Allow up to 7 days for earlier repeated openers to leave the rolling window. This signal is measured across the organization, so the same campaign copied across several numbers can protect all of them. #### `delivery`: recent delivery health changed With sufficient samples, Blooio compares the most recent 72 hours with the earlier baseline. The reason becomes active when the iMessage share drops by more than 10 percentage points or the device-visible failure rate doubles. To recover: 1. Pause new outreach and inspect recent failed messages, recipient addresses, and protocol results. 2. Correct invalid or unreachable recipients before resuming. 3. If failures are not elevated and only the recipient protocol mix changed, contact support; messaging more Android recipients can lower iMessage share without indicating a problem with the number. 4. Allow up to 72 hours for the recent sample to normalize or age out. #### `sibling_ban`: another number on the account was banned This reason applies `slow` for 72 hours after another number owned by the same organization is banned. Use the remaining number conservatively, prioritize existing two-way conversations, and investigate the sending pattern that caused the other ban. The reason becomes eligible to clear 72 hours after the ban. ### When the protection clears Number state is re-evaluated as messaging activity continues. A stale send can be evaluated against the previous state while a background refresh runs, so wait briefly before retrying a send that should now be eligible. In v4, subscribe to `safety.state_changed` and resume when `action` becomes `none` or moves to an action your workflow can handle. In v2, use the Limits page and the next send's machine-readable result. The relevant blocking codes are: - `safety_new_conversations_paused` (`403`) - `safety_reply_only` (`403`) - `safety_account_review` (`403`) ## Monitor line health `safety.state_changed` tells you when a protection has already applied. To act before that, poll `GET /v4/channels/{channel}/health` for each dedicated or inbound number. It returns the number's current safety state alongside the signals the protections are built on: - `replies.first_reply_rate`: the share of chats this number started that got a written reply. At the default `days=7` it is the same 7-day rate the `one_way` reason uses. - `replies.unanswered_conversations` and `replies.unanswered_rate`: chats this number started that used the full 3-message first-contact allowance and got no reply. - `delivery`: iMessage share and device-visible failure rate, with the counts behind them. The rates are `null` below 20 resolved sends. - `risk`: a 0–100 risk score with the factors that produced it. - `daily`: per-UTC-day sent, received, new chats, and first replies. ```bash curl -H 'Authorization: Bearer YOUR_API_KEY' \ 'https://api.blooio.com/v4/channels/ch_abc123/health?days=7' ``` ### How fresh the data is - `safety` is read live on every request. A new action shows on your next poll. - The metrics are recomputed at most every 5 minutes. `computed_at` says when; read it instead of assuming the data is current. - If you poll every 5 minutes or more often, the metrics are at most about 5 minutes old, plus one poll interval while a refresh lands. Polling more often than every 5 minutes returns the same metrics. - If you poll every 5 to 30 minutes, each poll returns the data the previous poll refreshed, so it is about one poll interval old. - If you poll less often than every 30 minutes, each request computes fresh metrics and takes a little longer. Poll every 5 minutes, by the number's `ch_` channel id. A phone number or alias works too, but it is resolved to the channel first on every request. ### Slow down before a protection applies The `one_way` reason needs more than 10 new chats a day and a first-reply rate under 40%. If you throttle new outreach once the rate approaches that line, the number keeps sending at full speed instead of being slowed or paused: ```javascript async function outreachBudget(channelId) { const res = await fetch( `https://api.blooio.com/v4/channels/${channelId}/health?days=7`, { headers: { Authorization: `Bearer ${process.env.BLOOIO_API_KEY}` } }, ); const { data } = await res.json(); if (data.safety.action !== "none") return 0; // a protection already applies const rate = data.replies.first_reply_rate; if (rate !== null && data.replies.new_conversations >= 10 && rate < 0.5) { return 5; // new chats per day until replies recover } return 50; } ``` To watch delivery the way the `delivery` reason does, compare `days=3` (close to its 72-hour recent sample) with `days=7`; subtracting the 3-day counts from the 7-day counts approximates the earlier baseline. Health is available for dedicated and inbound numbers. Shared and trial numbers return `403 health_unsupported_for_line_type`, and non-Blooio channels return `403 health_unsupported_for_channel_type`. ## Turning limits off Administrators can turn safety limits off for a dedicated number from the Limits page. This bypasses both conversation limits and number protections for that number and materially increases the risk of an unrecoverable Apple ban. Recipient opt-outs are still enforced. Shared and inbound numbers cannot be uncapped by customers because their Apple trust may affect other accounts. Contact support only when the displayed reason appears incorrect; disabling limits is an exception, not the normal recovery process. > **Warning** > > Apple bans cannot generally be reversed. Recover by changing the sending > pattern whenever possible instead of turning protections off. ## Integration checklist 1. Branch on the machine-readable `code`, never the message text. 2. Treat conversation `429` responses as "wait for recipient activity," not "retry after a timer." 3. Treat `queue` and `slow` as accepted asynchronous sends and continue tracking the original message ID. 4. Keep existing, replied conversations available during `pause_new`. 5. In v4, subscribe to `safety.state_changed`; inspect `action`, `previous_action`, and `reasons`. 6. In v4, poll `GET /v4/channels/{channel}/health` every 5 minutes to slow outreach before a protection applies. 7. Escalate `review` and unexplained delivery-health protections to support. ## Message fields reference (v2) URL: https://docs.blooio.com/guides/message-fields Every field on a v2 message object, plus the full status lifecycle and protocol (transport) states This is the field-by-field reference for the **v2** message object (`api.blooio.com/v2/api`). It covers what each field means, the full set of `status` (delivery lifecycle) values, and the full set of `protocol` (transport) values. For sending mechanics and examples, see [Message sending basics](/guides/message-sending). Multi-channel senders (e.g. WhatsApp) use the [v4 message fields reference](/guides/message-fields-v4). ## The message object ```json { "message_id": "msg_abc123", "direction": "outbound", "external_id": "+15551234567", "internal_id": "+15559876543", "text": "Hello!", "attachments": [], "sender": null, "reactions": [], "time_sent": 1720992000000, "time_delivered": 1720992003000, "status": "delivered", "protocol": "imessage", "error": null } ``` | Field | Type | Description | | ---------------- | ----------------- | ----------------------------------------------------------------------------------------------- | | `message_id` | string | Unique message id, prefixed `msg_`. | | `direction` | enum | `inbound` or `outbound`. | | `external_id` | string | The contact's phone number or email, or a `grp_` group id for group messages. | | `internal_id` | string \| null | The organization sender number (from-number) used for this message. | | `text` | string \| null | Message body. `null` for attachment-only messages. | | `attachments` | array | Attachment objects sent with the message. | | `sender` | string \| null | Sender's phone/email for **inbound group** messages. `null` for outbound and 1:1 chats. | | `reactions` | array | Tapbacks and emoji reactions on this message. | | `time_sent` | int64 | Epoch milliseconds when the message was sent. | | `time_delivered` | int64 \| null | Epoch milliseconds when delivery was confirmed. `null` until a receipt arrives. | | `status` | enum | Delivery lifecycle state — see [Status lifecycle](#status-lifecycle). | | `protocol` | enum | Transport that carried the message — see [Protocol states](#protocol-states). | | `error` | string \| null | Human-readable failure reason when `status` is `failed` (or when protocol resolution failed). | | `reply_to` | object \| null | Present only on inline-reply messages; omitted for top-level messages. | ## Status lifecycle `status` describes where a message is in its **delivery lifecycle**. It is independent of `protocol` (which transport carried it). | Status | Description | | ------------------------ | ------------------------------------------------------------------ | | `pending` | Persisted and being prepared for dispatch. | | `queued` | Accepted and waiting to be handed to Apple/the carrier. | | `sent` | Handed off to Apple/the carrier. Protocol resolves around here. | | `delivered` | A delivery receipt was received from the recipient. | | `failed` | The message could not be delivered — see `error`. | | `cancellation_requested` | A cancel was requested for a still-queued message (best-effort). | | `cancelled` | The message was cancelled before it was dispatched. | Typical happy path: ``` pending → queued → sent → delivered ``` > **Note** > > Inbound messages don't have a lifecycle — they're surfaced via webhooks with > status `received`. A `read` receipt arrives as a separate `read` webhook event, > not as a change to the message `status` column. ### Checking status Poll the status endpoint: ```bash curl -H 'Authorization: Bearer YOUR_API_KEY' \ 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages/msg_abc123/status' ``` Or subscribe to `message.status` webhooks for real-time transitions. ## Protocol states `protocol` records the **transport** that carried (or will carry) the message. Blooio picks the transport at send time — iMessage when the recipient supports it, otherwise RCS or SMS. > **Warning** > > `protocol` is **never `null`**. A freshly-accepted message starts at `pending` > and resolves to a concrete transport within seconds. The legacy `non-imessage` > value has been removed — unresolved sends now report `pending` (transient) or > `unknown` (terminal). | Protocol | Meaning | | ---------- | ------------------------------------------------------------------------------------------------------------------- | | `pending` | Accepted and dispatched; the wire service is not resolved yet. Transient — settles within seconds of `sent`. | | `imessage` | Delivered over iMessage (blue bubble). | | `rcs` | Delivered over RCS. | | `sms` | Fell back to SMS/MMS (green bubble). | | `unknown` | Accepted by the carrier, but the wire service could not be resolved before the tracking window closed (see `error`). | ### Reading protocol safely If your integration branches on `protocol`, treat `pending` as **"not yet known"** rather than a final answer — re-check the status endpoint (or wait for the `message.status` webhook) to get the resolved transport. `unknown` is terminal and means the message was accepted but its transport could not be confirmed. ```javascript switch (message.protocol) { case "pending": // Not resolved yet — check again shortly. break; case "imessage": case "rcs": // Rich transport confirmed. break; case "sms": // Fell back to SMS/MMS. break; case "unknown": // Accepted, but transport never confirmed. Inspect `error`. break; } ``` ## Next steps - [Message sending basics](/guides/message-sending) — chat IDs, options, quirks - [Error handling](/guides/error-handling) — handle failures gracefully - [Webhooks](/guides/webhooks-local) — receive status transitions in real time - [Message fields reference (v4)](/guides/message-fields-v4) — multi-channel senders ## Message fields reference (v4) URL: https://docs.blooio.com/guides/message-fields-v4 Every field on a v4 multi-channel message object, plus the full status lifecycle and protocol (transport) states across iMessage, SMS, RCS, WhatsApp, and Apple Messages for Business This is the field-by-field reference for the **v4** message object (`api.blooio.com/v4`). v4 is a multi-channel API — the same message shape covers iMessage, SMS, RCS, WhatsApp, and Apple Messages for Business — so it adds a few transport values that don't exist in v2. For the single-channel iMessage API, see the [v2 message fields reference](/guides/message-fields). > **Note** > > The v4 API is currently in **beta**. Field shapes are stable, but new > `status` / `protocol` values may be added as channels evolve — always handle > unknown values defensively. ## The message object ```json { "id": "msg_018f7b2a1c3d7e4f9a2b0c1d2e3f4a5b", "chat_id": "chat_abc123", "channel_id": "ch_abc123", "channel_type": "blooio", "channel_address": "+15559876543", "protocol": "imessage", "direction": "inbound", "sender": "+15551234567", "recipient": "+15559876543", "contact": { "contact_id": "ct_018f7b2a3e4f7a5b8c6d7e8f9a0b1c2d", "identifier": "+15551234567", "name": "Jordan Lee" }, "group": null, "type": "text", "text": "Hello!", "status": "received", "reply_to_message_id": null, "error": null, "attachments": [], "created_at": 1720992000000, "updated_at": 1720992003000 } ``` | Field | Type | Description | | --------------------- | --------------- | --------------------------------------------------------------------------------------- | | `id` | string | Unique message id, prefixed `msg_`. | | `chat_id` | string | The chat this message belongs to (`chat_`). | | `channel_id` | string | The sending/receiving channel (`ch_`). | | `channel_type` | enum | `blooio` (iMessage), `twilio`, `whatsapp_business`, or `rcs_business`. | | `channel_address` | string \| null | The address of **your own line** on this channel (the number or alias, e.g. `+15559876543`). Same regardless of direction. | | `protocol` | enum | Transport that carried the message — see [Protocol states](#protocol-states). | | `direction` | enum | `inbound` or `outbound`. | | `sender` | string \| null | Who the message is **from**, direction-aware. Inbound: the other party. Outbound: your own line (`channel_address`). | | `recipient` | string \| null | Who the message is **to**, direction-aware. Inbound: your own line (`channel_address`). Outbound: the other party. The mirror of `sender`. | | `contact` | object \| null | The **other party** (never your own line), regardless of direction — `{ "contact_id", "identifier", "name" }`. On an inbound group message it's the member who sent it; `null` on a message you sent to a group. `contact_id` is `null` when no contact record matches, and `name` when none is on file. | | `group` | object \| null | `{ "group_id", "name" }` when the chat is a group, `null` in a 1:1 chat. | | `type` | string | Content type, e.g. `text`, `media`, `poll`. WhatsApp Business adds `template` and `interactive` (see the [WhatsApp Business guide](/guides/whatsapp-business)). | | `text` | string \| null | Message body. `null` for media-only messages. | | `status` | string | Delivery lifecycle state — see [Status lifecycle](#status-lifecycle). | | `reply_to_message_id` | string \| null | The `msg_` this message replies to, when it's an inline reply. | | `error` | object \| null | Structured error (`{ code, message, ... }`) when `status` is `failed` or protocol resolution failed. | | `attachments` | array | Ordered attachments (`url`, `media_type`, `size`, optional `caption`). Empty array when none. | | `created_at` | int64 | Epoch milliseconds when the message was created. | | `updated_at` | int64 | Epoch milliseconds of the last status/protocol update. | > **Note** > > `sender`, `recipient`, `contact`, and `channel_address` are the **same > direction-aware endpoints delivered on the `message.*` webhooks** — so a > message reads identically whether you poll this endpoint or receive a webhook. > On an inbound message, `sender` is the other party and `recipient` is your own > line; on outbound they swap. `contact` always points at the other party. These > are channel-agnostic: for `whatsapp_business` and `amb` the values are the > channel's identifier/alias rather than an E.164 number, but the meaning is the > same. The **send endpoints** return the message at the top level (not wrapped in `data`) as a `MessageSendResult`, which adds the resolved scalar `from` (the sender's phone number or alias) and routing hints like `group_id`, `hybrid`, and `fallback`. ## Status lifecycle `status` describes where a message is in its **delivery lifecycle**, independent of which channel or transport carried it. | Status | Description | | ------------ | ------------------------------------------------------------------------------- | | `queued` | Accepted and waiting to be handed to the channel. | | `sent` | Handed off to the channel/provider. Protocol resolves around here. | | `delivered` | A delivery receipt was received. | | `read` | A read receipt was received (where the channel supports it). | | `failed` | The message could not be delivered — see `error`. | | `superseded` | The row was replaced (an idempotency-key retry loser, or a poll-vote supersession) and is not an independently deliverable message. | Typical happy path: ``` queued → sent → delivered → read ``` > **Note** > > Inbound messages surface through the unified **event feed** and webhooks with > status `received`. Lifecycle transitions are emitted as `message.*` events > (`message.sent`, `message.delivered`, `message.read`, `message.failed`). ## Protocol states `protocol` records the **transport** that carried (or will carry) the message. For `blooio` (iMessage) channels, Blooio picks the transport at send time and resolves it shortly after; other channel types have a fixed transport. > **Warning** > > `protocol` is **never `null`**. A freshly-accepted message starts at `pending` > and resolves to a concrete transport within seconds. | Protocol | Meaning | | ---------- | ------------------------------------------------------------------------------------------------------------------- | | `pending` | Accepted and dispatched; the wire service is not resolved yet. Transient — settles within seconds of `sent`. | | `imessage` | Delivered over iMessage (blue bubble). | | `rcs` | Delivered over RCS. | | `sms` | Delivered over SMS/MMS (green bubble). | | `whatsapp` | Sent over WhatsApp. | | `amb` | Sent over Apple Messages for Business. | | `unknown` | Accepted, but the transport could not be resolved before the tracking window closed (see `error`). | ### How protocol relates to channel_type For fixed-transport channels the protocol is known immediately; only `blooio` (iMessage) sends start at `pending` while the device resolves the wire service. | `channel_type` | Initial `protocol` | Resolves to | | ------------------- | -------------------------------- | ----------------------- | | `blooio` | `pending` | `imessage` / `rcs` / `sms` (or `unknown`) | | `twilio` | `sms` | `sms` | | `rcs_business` | `rcs` | `rcs` | | `whatsapp_business` | `whatsapp` | `whatsapp` | | `amb` | `amb` | `amb` | ### Reading protocol safely Treat `pending` as **"not yet known"** and `unknown` as a **terminal** "accepted but unconfirmed" state. Because v4 is multi-channel and evolving, always keep a default branch for values you don't recognize. ```javascript switch (message.protocol) { case "pending": // Not resolved yet — re-read or wait for a message.* event. break; case "imessage": case "rcs": case "whatsapp": case "whatsapp_business": case "amb": // Rich transport confirmed. break; case "sms": // SMS/MMS. break; case "unknown": // Accepted, but transport never confirmed. Inspect `error`. break; default: // Forward-compatible: handle new transports gracefully. break; } ``` ## Next steps - [Message fields reference (v2)](/guides/message-fields) — single-channel iMessage API - [Message sending basics](/guides/message-sending) — chat IDs, options, quirks - [WhatsApp Business](/guides/whatsapp-business) — templates, the 24-hour window, and interactive messages - [Interactive messages (Apple Messages for Business)](/amb-interactive) — quick replies, list pickers, forms, and Apple Pay - [Webhooks](/guides/webhooks-local) — receive `message.*` events in real time ## Send iMessage with Python URL: https://docs.blooio.com/guides/send-imessage-api-python A hands-on Python tutorial for sending and receiving iMessages via the Blooio REST API — requests, async with httpx, webhooks, and a working FastAPI example. This guide walks through sending and receiving iMessages from Python using the Blooio REST API. By the end you'll have a minimal send script, an async version, and a FastAPI webhook receiver you can drop into a real app. ## What you'll need - Python 3.9 or newer - A Blooio API key (get one in the [dashboard](https://app.blooio.com)) - A phone number the recipient can receive iMessages on Install a single HTTP dependency: **requests (sync)** ```bash pip install requests ``` **httpx (async)** ```bash pip install httpx ``` ## Send your first iMessage The minimal send looks like this: ```python title="send.py" from urllib.parse import quote API_KEY = os.environ['BLOOIO_API_KEY'] BASE_URL = 'https://api.blooio.com/v2/api' def send_imessage(to: str, text: str) -> dict: chat_id = quote(to, safe='') res = requests.post( f'{BASE_URL}/chats/{chat_id}/messages', headers={ 'Authorization': f'Bearer {API_KEY}', 'Content-Type': 'application/json', }, json={'text': text}, timeout=10, ) res.raise_for_status() return res.json() if __name__ == '__main__': result = send_imessage('+15551234567', 'Hello from Python!') print(f"Message ID: {result['message_id']}, status: {result['status']}") ``` Run it: ```bash export BLOOIO_API_KEY=sk_live_your_key_here python send.py ``` > **Warning** > > Always URL-encode the phone number with `urllib.parse.quote(..., safe='')`. > The `+` in `+15551234567` has special meaning in URLs and will be interpreted > as a space if not encoded. ## Check iMessage capability first Not every phone number can receive iMessages. Before sending, hit the capabilities endpoint: ```python def has_imessage(phone: str) -> bool: chat_id = quote(phone, safe='') res = requests.get( f'{BASE_URL}/contacts/{chat_id}/capabilities', headers={'Authorization': f'Bearer {API_KEY}'}, timeout=10, ) res.raise_for_status() return res.json().get('capabilities', {}).get('imessage', False) if has_imessage('+15551234567'): send_imessage('+15551234567', 'Blue bubbles only!') else: print('Recipient does not support iMessage; fall back to SMS.') ``` ## Send an iMessage with attachments Pass a list of publicly-reachable URLs: ```python def send_with_attachments(to: str, text: str, attachments: list[str]) -> dict: chat_id = quote(to, safe='') res = requests.post( f'{BASE_URL}/chats/{chat_id}/messages', headers={ 'Authorization': f'Bearer {API_KEY}', 'Content-Type': 'application/json', }, json={ 'text': text, 'attachments': attachments, }, timeout=30, ) res.raise_for_status() return res.json() send_with_attachments( '+15551234567', 'Check out this photo', ['https://example.com/photo.jpg'], ) ``` Attachments can be images, videos, PDFs, or any file type iMessage supports. See [Attachments](/guides/attachments) for size limits and MIME handling. ## Send to a group Groups are chats too. Pass the group ID as the `chatId`: ```python def send_to_group(group_id: str, text: str) -> dict: res = requests.post( f'{BASE_URL}/chats/{group_id}/messages', headers={ 'Authorization': f'Bearer {API_KEY}', 'Content-Type': 'application/json', }, json={'text': text}, timeout=10, ) res.raise_for_status() return res.json() send_to_group('grp_abc123', 'Morning team!') ``` ## Async with httpx For high-throughput apps use `httpx` with asyncio: ```python title="send_async.py" from urllib.parse import quote API_KEY = os.environ['BLOOIO_API_KEY'] BASE_URL = 'https://api.blooio.com/v2/api' async def send_imessage(client: httpx.AsyncClient, to: str, text: str) -> dict: chat_id = quote(to, safe='') res = await client.post( f'{BASE_URL}/chats/{chat_id}/messages', headers={'Authorization': f'Bearer {API_KEY}'}, json={'text': text}, timeout=10.0, ) res.raise_for_status() return res.json() async def main(): recipients = ['+15551234567', '+15557654321', '+15559876543'] async with httpx.AsyncClient() as client: results = await asyncio.gather( *[send_imessage(client, r, 'Hi!') for r in recipients] ) for r in results: print(r['message_id']) if __name__ == '__main__': asyncio.run(main()) ``` ## Idempotency To make retries safe, pass an `Idempotency-Key` header: ```python def send_idempotent(to: str, text: str, key: str) -> dict: chat_id = quote(to, safe='') res = requests.post( f'{BASE_URL}/chats/{chat_id}/messages', headers={ 'Authorization': f'Bearer {API_KEY}', 'Content-Type': 'application/json', 'Idempotency-Key': key, }, json={'text': text}, timeout=10, ) res.raise_for_status() return res.json() # Derive the key from your domain entity — order ID, job ID, etc. send_idempotent('+15551234567', 'Order #4281 shipped', 'order-4281-shipped') ``` Retries with the same key within 24 hours return the original response without creating a duplicate. ## Receive incoming iMessages (FastAPI) Blooio delivers inbound messages and status events via webhooks. Here is a minimal FastAPI receiver: ```python title="webhook.py" from fastapi import FastAPI, Header, HTTPException, Request SIGNING_SECRET = os.environ['BLOOIO_WEBHOOK_SECRET'] app = FastAPI() def verify_signature(body: bytes, signature: str) -> bool: expected = hmac.new( SIGNING_SECRET.encode(), body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, signature or '') @app.post('/webhooks/blooio') async def blooio_webhook( request: Request, x_blooio_signature: str = Header(default=''), x_blooio_event: str = Header(default=''), ): body = await request.body() if not verify_signature(body, x_blooio_signature): raise HTTPException(status_code=401, detail='Invalid signature') event = await request.json() if x_blooio_event == 'message.received': text = event.get('data', {}).get('text') sender = event.get('data', {}).get('from') print(f'{sender}: {text}') return {'ok': True} ``` Run it: ```bash pip install fastapi uvicorn export BLOOIO_WEBHOOK_SECRET=whsec_your_secret uvicorn webhook:app --port 3001 ``` Then register the webhook URL with Blooio — see [Receive webhooks locally](/guides/webhooks-local) to tunnel localhost. > **Note** > > See [Webhook signatures](/guides/webhook-signatures) for the exact signing > algorithm and replay-attack prevention. ## Handling errors Blooio returns standard HTTP status codes and a consistent error body. Handle them explicitly: ```python from requests.exceptions import HTTPError try: send_imessage('+15551234567', 'hi') except HTTPError as e: status = e.response.status_code body = e.response.json() if status == 429: retry_after = e.response.headers.get('Retry-After') if retry_after: print(f'Transient rate limit; retry in {retry_after}s') else: print(f"Send limit {body.get('code')}; wait for the required recipient activity or settings change") elif status == 400: print(f"Invalid request: {body.get('message')}") elif status >= 500: print('Blooio is having issues; retry with backoff') else: raise ``` See [Messaging safety limits and recovery](/guides/messaging-safety) for `403` and `429` send-limit handling, and [Error handling](/guides/error-handling) for retry strategies and backoff patterns. ## Environment variables Keep your API key out of your codebase: ```bash # .env BLOOIO_API_KEY=sk_live_... BLOOIO_WEBHOOK_SECRET=whsec_... ``` ```python from dotenv import load_dotenv load_dotenv() ``` ## Going further - [Message sending basics](/guides/message-sending) — multipart messages, mentions, reply threading - [Twilio fallback integration](/guides/twilio-integration) — route to SMS when iMessage isn't available - [Authentication](/authentication) — scoping API keys and rotation - [API reference](/api-reference/introduction) — every endpoint ## Send iMessage with Node.js URL: https://docs.blooio.com/guides/send-imessage-api-nodejs A Node.js (JavaScript and TypeScript) tutorial for sending and receiving iMessages via the Blooio REST API — fetch, async/await, Express webhooks, and bulk patterns. This guide walks through sending and receiving iMessages from Node.js using the Blooio REST API. Works with any Node 18+ runtime — the native `fetch` means zero dependencies for the basic case. ## What you'll need - Node.js 18 or newer (for native `fetch`) - A Blooio API key from the [dashboard](https://app.blooio.com) - A phone number the recipient can receive iMessages on No SDK required. Blooio's REST API works with `fetch`, `axios`, `undici`, or any HTTP client. ## Send your first iMessage **JavaScript** ```javascript title="send.js" const API_KEY = process.env.BLOOIO_API_KEY const BASE_URL = 'https://api.blooio.com/v2/api' async function sendImessage(to, text) { const chatId = encodeURIComponent(to) const res = await fetch(`${BASE_URL}/chats/${chatId}/messages`, { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ text }), }) if (!res.ok) { throw new Error(`Blooio ${res.status}: ${await res.text()}`) } return res.json() } const result = await sendImessage('+15551234567', 'Hello from Node.js!') console.log(`Message ID: ${result.message_id}, status: ${result.status}`) ``` **TypeScript** ```typescript title="send.ts" type SendResponse = { message_id: string status: 'queued' | 'sent' | 'delivered' | 'failed' } const API_KEY = process.env.BLOOIO_API_KEY! const BASE_URL = 'https://api.blooio.com/v2/api' async function sendImessage(to: string, text: string): Promise { const chatId = encodeURIComponent(to) const res = await fetch(`${BASE_URL}/chats/${chatId}/messages`, { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ text }), }) if (!res.ok) { throw new Error(`Blooio ${res.status}: ${await res.text()}`) } return res.json() as Promise } const result = await sendImessage('+15551234567', 'Hello from TypeScript!') console.log(`Message ID: ${result.message_id}, status: ${result.status}`) ``` Run it: ```bash export BLOOIO_API_KEY=sk_live_your_key_here node send.js ``` > **Warning** > > Always `encodeURIComponent` phone numbers. Without it the `+` becomes a space > and the request fails silently with a 400. ## Check capability before sending Blooio only charges for iMessages that successfully dispatch through Apple's network, but you should still check capability when building fallback flows: ```typescript async function hasImessage(phone: string): Promise { const chatId = encodeURIComponent(phone); const res = await fetch(`${BASE_URL}/contacts/${chatId}/capabilities`, { headers: { Authorization: `Bearer ${API_KEY}` }, }); if (!res.ok) return false; const { capabilities } = await res.json(); return Boolean(capabilities?.imessage); } if (await hasImessage("+15551234567")) { await sendImessage("+15551234567", "Blue bubbles only!"); } ``` ## Attachments and multipart messages Send images, videos, or PDFs by passing public URLs: ```typescript await fetch(`${BASE_URL}/chats/${chatId}/messages`, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ text: "Check out this photo", attachments: ["https://example.com/photo.jpg"], }), }); ``` For a single bubble containing text and a link/photo use `parts`: ```typescript body: JSON.stringify({ parts: [ { text: 'Order update — tracking attached' }, { url: 'https://tracking.example.com/abc', name: 'Track package' }, ], }), ``` See [Attachments](/guides/attachments) for size limits and [Link previews](/guides/link-previews) for rich-link behaviour. ## Bulk sending with concurrency control Don't fire 1,000 parallel requests. Use a concurrency limiter — the `p-limit` package is the simplest option: ```bash npm install p-limit ``` ```typescript const limit = pLimit(10); // 10 concurrent sends const recipients = ["+15551234567", "+15557654321" /* ... */]; const results = await Promise.allSettled( recipients.map((to) => limit(() => sendImessage(to, `Hi ${to}!`))), ); const successes = results.filter((r) => r.status === "fulfilled").length; const failures = results.filter((r) => r.status === "rejected").length; console.log(`Sent ${successes}, failed ${failures}`); ``` > **Note** > > For reliable bulk delivery use a queue (BullMQ, PgBoss) and persist an > idempotency key per recipient so retries don't create duplicates. ## Idempotency Pass an `Idempotency-Key` header on POST requests to make them safe to retry: ```typescript await fetch(`${BASE_URL}/chats/${chatId}/messages`, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `order-${orderId}-shipped`, }, body: JSON.stringify({ text: `Order ${orderId} shipped!` }), }); ``` Retries with the same key within 24 hours return the original response without creating a duplicate. Use a deterministic key derived from your domain entity, not a random UUID. ## Receive incoming iMessages (Express) Blooio delivers inbound messages and status events via webhooks. Minimal Express receiver: ```javascript title="webhook.js" const app = express(); // Raw body is required for signature verification app.post( "/webhooks/blooio", express.raw({ type: "application/json" }), (req, res) => { const signature = req.header("x-blooio-signature") ?? ""; const event = req.header("x-blooio-event") ?? ""; const secret = process.env.BLOOIO_WEBHOOK_SECRET; const expected = crypto .createHmac("sha256", secret) .update(req.body) .digest("hex"); if ( !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)) ) { return res.sendStatus(401); } const payload = JSON.parse(req.body.toString("utf8")); if (event === "message.received") { const { from, text } = payload.data; console.log(`${from}: ${text}`); } res.sendStatus(200); }, ); app.listen(3001, () => console.log("Listening on :3001")); ``` Register the webhook URL with Blooio once it's reachable — for local dev, tunnel with ngrok: see [Receive webhooks locally](/guides/webhooks-local). > **Note** > > See [Webhook signatures](/guides/webhook-signatures) for the full signing > algorithm and replay-attack prevention. ## Error handling and retries Blooio returns standard HTTP status codes and a consistent JSON error body: ```typescript async function sendWithRetry( to: string, text: string, maxAttempts = 3, ): Promise { const idempotencyKey = crypto.randomUUID(); for (let attempt = 1; attempt <= maxAttempts; attempt++) { const chatId = encodeURIComponent(to); const res = await fetch(`${BASE_URL}/chats/${chatId}/messages`, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ text }), }); const body = await res.json().catch(() => ({})); if (res.ok) return body; // Messaging-safety and configured send limits do not use timer retries. const retryAfter = res.headers.get("retry-after"); if (res.status >= 400 && res.status < 500 && !retryAfter) { throw new Error( `Blooio ${res.status} ${body.code ?? ""}: ${body.message ?? "request rejected"}`, ); } const retryAfterMs = Number(retryAfter ?? "0") * 1000; const backoffMs = Math.max(retryAfterMs, 2 ** attempt * 250); await new Promise((r) => setTimeout(r, backoffMs)); } throw new Error(`Blooio: failed after ${maxAttempts} attempts`); } ``` See [Messaging safety limits and recovery](/guides/messaging-safety) for `403` and `429` send-limit handling, and [Error handling](/guides/error-handling) for the complete error taxonomy. ## Environment and secrets Keep keys out of the codebase: ```bash # .env BLOOIO_API_KEY=sk_live_... BLOOIO_WEBHOOK_SECRET=whsec_... ``` ```typescript ``` ## Going further - [Message sending basics](/guides/message-sending) — chat IDs, mentions, reply threading - [iMessage REST API reference](/guides/imessage-rest-api) — conventions, pagination, idempotency rules - [Send with Python](/guides/send-imessage-api-python) — same patterns in Python - [Twilio fallback integration](/guides/twilio-integration) — SMS backup for non-iMessage numbers - [API reference](/api-reference/introduction) — every endpoint and parameter ## Contact cards (Name & Photo) URL: https://docs.blooio.com/guides/contact-card How to manage and share your iMessage contact card — the Name & Photo identity others see in conversations This guide covers managing your iMessage contact card (Name & Photo) and sharing it with contacts. The contact card is the identity that recipients see — your name, profile photo, and sharing preferences. > **Warning** > > Contact card features — setting the first name / last name, setting the avatar (profile photo), and sharing your contact card — are only available on **Dedicated Commercial** and **Dedicated Enterprise** plans. On other plans these operations return `403`. ## Overview The contact card system has two parts: 1. **Managing your card** — Read and update the name, photo, and sharing settings for a specific number 2. **Sharing your card** — Trigger the actual sharing of your card in a specific chat, so the recipient receives your identity > **Note** > > Contact cards are scoped **per number**. If your API key is bound to multiple phone numbers, each number has its own contact card. Use `GET /me/numbers` to discover your available numbers. ## Prerequisites No separate Messages app setup is required on your end. Once a number is provisioned, you can read, update, and share its Name & Photo directly through the API. ## Discovering your numbers If your API key has multiple numbers, you need to specify which one to operate on. List your numbers first: **cURL** ```bash curl 'https://api.blooio.com/v2/api/me/numbers' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` **Node.js** ```javascript const res = await fetch('https://api.blooio.com/v2/api/me/numbers', { headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}` } }) const { numbers } = await res.json() console.log(numbers) // [{ phone_number: "+15551234567", is_active: true, last_active: "2026-..." }] ``` **Python** ```python res = requests.get('https://api.blooio.com/v2/api/me/numbers', headers={'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}"} ) numbers = res.json()['numbers'] ``` ## Reading your contact card Get the current contact card for a specific number: **cURL** ```bash curl 'https://api.blooio.com/v2/api/me/numbers/%2B15551234567/contact-card' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` **Node.js** ```javascript const number = encodeURIComponent('+15551234567') const res = await fetch(`https://api.blooio.com/v2/api/me/numbers/${number}/contact-card`, { headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}` } }) const card = await res.json() // { phone_number, first_name, last_name, name, avatar, sharing: { ... } } ``` **Python** ```python from urllib.parse import quote number = quote('+15551234567', safe='') res = requests.get(f'https://api.blooio.com/v2/api/me/numbers/{number}/contact-card', headers={'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}"} ) card = res.json() ``` The response includes: | Field | Type | Description | |-------|------|-------------| | `first_name` | string | First name | | `last_name` | string | Last name | | `name` | string | Display name (usually first + last) | | `avatar` | string | Base64-encoded JPEG profile photo | | `has_wallpaper` | boolean | Whether a poster background is set | | `sharing.enabled` | boolean | Whether Name & Photo sharing is on | | `sharing.audience` | integer | `0` = Contacts Only, `1` = Always Ask | | `sharing.name_format` | integer | `0` = First & Last, `1` = First Only | ## Updating your contact card > **Note** > > Setting the `first_name`, `last_name`, or `avatar` requires a **Dedicated Commercial** or **Dedicated Enterprise** plan. Numbers on other plans cannot change these fields. Update any combination of fields — only provided fields are changed: **cURL** ```bash curl -X PUT 'https://api.blooio.com/v2/api/me/numbers/%2B15551234567/contact-card' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "first_name": "John", "last_name": "Doe", "sharing": { "enabled": true, "audience": 1 } }' ``` **Node.js** ```javascript const number = encodeURIComponent('+15551234567') const res = await fetch(`https://api.blooio.com/v2/api/me/numbers/${number}/contact-card`, { method: 'PUT', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ first_name: 'John', last_name: 'Doe', sharing: { enabled: true, audience: 1 } }) }) ``` **Python** ```python from urllib.parse import quote number = quote('+15551234567', safe='') res = requests.put(f'https://api.blooio.com/v2/api/me/numbers/{number}/contact-card', headers={ 'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}", 'Content-Type': 'application/json' }, json={ 'first_name': 'John', 'last_name': 'Doe', 'sharing': {'enabled': True, 'audience': 1} } ) ``` ### Updating the profile photo To set a profile photo, pass a base64-encoded image in the `avatar` field. **Photo guidelines:** | Property | Recommendation | |----------|---------------| | Format | JPEG (preferred) or PNG | | Shape | Square — displayed as a circle in Messages | | Size | 500x500 to 1024x1024 pixels recommended | | Max file size | ~1 MB (approximately 1.3 MB as base64) | | Transparency | Not needed — use JPEG for smaller payload | ```javascript const imageBuffer = fs.readFileSync('profile.jpg') const base64Image = imageBuffer.toString('base64') await fetch(`https://api.blooio.com/v2/api/me/numbers/${number}/contact-card`, { method: 'PUT', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ avatar: base64Image }) }) ``` ## Sharing your contact card in a chat After your card is configured, you can share it with specific contacts. Contact sharing in iMessage works as a **piggyback** — your card is attached to the next outgoing message you send. > **Note** > > Sharing your contact card (the standalone endpoint and the `share_contact: true` flag) is only available on **Dedicated Commercial** and **Dedicated Enterprise** plans. There are two ways to trigger this: ### Option 1: Standalone endpoint (stage, then send separately) First stage the share, then send a message in a separate call: ```bash # Step 1: Stage the contact card share curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/contact-card' \ -H 'Authorization: Bearer YOUR_API_KEY' # Step 2: Send a message — the contact card automatically piggybacks on it curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"text": "Hi there!"}' ``` ### Option 2: Flag on send-message (one call) Include `share_contact: true` when sending a message to do both in one request: ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"text": "Hi there!", "share_contact": true}' ``` This also works with attachments: ```json { "text": "Check this out", "attachments": ["https://example.com/photo.jpg"], "share_contact": true } ``` > **Warning** > > The contact card is only sent **once per chat**. Apple tracks who has received your card, so calling the share endpoint or flag multiple times is harmless but redundant. > **Warning** > > **New groups:** the card has to be staged on a conversation that already exists, and a new group's conversation is only created by its first message. So `share_contact: true` on a group's **first** message is skipped — the message sends without your card. Put `share_contact: true` on the second message instead. ## How sharing works Name & Photo sharing is staged before it is attached to the next outgoing message: 1. When you call the share endpoint, the share flow stages permission to share with that chat's participants 2. The next outgoing message (text or attachment) automatically carries the contact card data 3. The recipient's Messages app receives and displays your name and photo This is the same mechanism as tapping "Share" on the Name & Photo banner in the Messages app. ## Sharing settings reference | Setting | Values | Description | |---------|--------|-------------| | `sharing.enabled` | `true` / `false` | Master toggle for Name & Photo sharing | | `sharing.audience` | `0` | Contacts Only — auto-share with people in your contacts | | `sharing.audience` | `1` | Always Ask — show a prompt before sharing (default for API use) | | `sharing.name_format` | `0` | Share first name and last name | | `sharing.name_format` | `1` | Share first name only | `audience` and `name_format` accept only `0` or `1` (the strings `"0"` and `"1"` also work). Any other value is rejected rather than guessed at. ## Number Purchase API URL: https://docs.blooio.com/guides/number-purchase-api Programmatically browse, buy, and remove Blooio lines over the v4 API — including the KYC, access-approval, and billing steps required before you can purchase. The **Number Purchase API** lets you provision Blooio lines from your own code instead of clicking through the dashboard. You can browse available inventory, buy shared, dedicated, or inbound lines, poll the purchase, and remove (unsubscribe) a line — all over the [v4 API](/reference/v4). This is the same billing and provisioning flow the dashboard uses, so the two never drift. It's also how resellers and platforms (for example, [AgentPhone](https://blooio.com/case-studies/agentphone)) provision a fresh number for each of their end customers without a human in the loop. > **Note** > > The Number Purchase API is a **v4** feature. All endpoints live under `https://api.blooio.com/v4`. Make sure the version toggle at the top of the docs is set to **v4**.
*The full lifecycle — request access, browse, purchase, provision, track, and remove. Click a step for details; drag to pan, use the controls to zoom.* ## Before you can buy a line Purchasing is gated. An organization has to clear three prerequisites before any purchase call will succeed — this keeps line provisioning tied to a verified, billable business. ### 1. Complete organization KYC Open **Organization Settings → Identity Verification** in the [dashboard](https://app.blooio.com) and complete KYC with Stripe Identity. This verifies the business behind the organization and unlocks the features (including number purchasing) that aren't available to unverified organizations. Until KYC shows as **verified**, requesting access is blocked and the API returns `403`. ### 2. Request Number Purchase API access In the same **Organization Settings** page, find the **Number Purchase API** section and submit the access request. You'll be asked for: - **What you're building** — your use case. - **Website** — your company/product URL. - **Notes** — anything else the review team should know (optional). Submitting opens a review ticket. A Blooio staff member approves it, which flips `settings.number_purchase_api.enabled` to `true` for your organization. Only an **organization admin** can submit the request. > **Warning** > > Until access is approved, every Number Purchase API endpoint returns `403` with `code: feature_not_enabled` and the message *"The number purchase API is not enabled for this organization. Request access in your organization settings."* ### 3. Add a payment method Number purchases are billable and charge your organization's **default card on file**. Add one in the dashboard's billing settings first. Without a saved card, `POST /channels/blooio/purchases` returns `402` with `code: no_payment_method`. ### Per-order line cap A single purchase can't exceed your organization's per-order line cap (default **10**). If you need to buy more lines in one call, apply for an increase under **Messaging → Limits** in the dashboard. Exceeding the cap returns `400` with `code: line_limit_exceeded`. ## Authentication Both credential types work, exactly like the rest of the [v4 API](/authentication): - **API keys** — `Authorization: Bearer bl_live_...`. Full organization access. - **OAuth app tokens** — when calling on behalf of another organization, the token must carry the right scope: - `numbers:read` — list available inventory and read purchase status. - `numbers:manage` — purchase and remove lines. ```bash Authorization: Bearer YOUR_BLOOIO_API_KEY ``` See [Calling the API with OAuth tokens](/oauth-api-tokens) for the app flow. ## Step 1 — Browse available inventory Use `GET /channels/blooio/available` to see what's in stock. The `type` query selects the plan family: - `shared` — available shared lines (returns masked inventory rows). - `dedicated` — unassigned dedicated inventory. - `inbound` — reply-only inbound lines (provisioned by area code — pass `area_code` to get a quote). ```bash curl 'https://api.blooio.com/v4/channels/blooio/available?type=dedicated&country=US&limit=5' \ -H 'Authorization: Bearer YOUR_BLOOIO_API_KEY' ``` ```json { "data": [ { "masked_national": "(801) ***-****", "area_code": "801", "country_code": "1", "phone_number_country": "US", "location": "Salt Lake City, UT" } ], "has_more": false, "next_cursor": null } ``` > **Note** > > The full phone number is never returned before purchase — only a masked form (`(801) ***-****`). The actual line is revealed once it's provisioned to your account. Full schema: [List available numbers](/reference/v4/channels/listAvailableBlooioNumbers). ### Area-code quotes For `dedicated` or `inbound`, pass one or more `area_code` values to check availability by area code. Each requested code comes back as either **in stock** (provisioned free) or needing a **custom order** (a custom area-code fee applies and a provisioning ticket is opened at purchase). ```bash curl 'https://api.blooio.com/v4/channels/blooio/available?type=dedicated&area_code=801&area_code=212' \ -H 'Authorization: Bearer YOUR_BLOOIO_API_KEY' ``` ```json { "data": [ { "area_code": "801", "matched": true }, { "area_code": "212", "matched": false, "custom_order": true } ], "matched_count": 1, "custom_order_count": 1, "has_more": false, "next_cursor": null } ``` ## Step 2 — Purchase a line Buy lines with `POST /channels/blooio/purchases`. The call charges your default payment method and returns a `purchase_id`; the lines are provisioned **asynchronously** once Stripe confirms the invoice. > **Warning** > > An **`Idempotency-Key` header is required** on every purchase. It makes the billable call safe to retry — replaying the same key returns the same purchase instead of charging twice. ```bash curl -X POST 'https://api.blooio.com/v4/channels/blooio/purchases' \ -H 'Authorization: Bearer YOUR_BLOOIO_API_KEY' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: 7f3c1e0a-9b2d-4c11-8f6a-2b1c9d4e5f60' \ -d '{ "plan": "dedicated_com", "quantity": 2, "area_codes": ["415", "628"] }' ``` | Field | Type | Notes | |-------|------|-------| | `plan` | string (required) | Plan id — use the specific id (e.g. `dedicated_com`), not the family name (`dedicated` returns `invalid_plan`). See [Plans](#plans). | | `quantity` | integer | Number of new lines (dedicated/inbound). Shared is always `1`. Defaults to `1`. | | `area_codes` | string[] | Preferred 3-digit US area codes. Out-of-stock codes incur a custom fee and open a ticket. | | `zip_codes` | string[] | Preferred ZIP codes, used when an area code isn't specified. | A successful call returns `202 Accepted` while provisioning proceeds in the background: ```json { "data": { "purchase_id": "idem_abc123", "status": "provisioning" } } ``` Full schema: [Purchase numbers](/reference/v4/channels/purchaseBlooioNumbers). ### Rules enforced Checked server-side before the card is charged, identically to the dashboard: - **Payment method required** — no saved card returns `402 no_payment_method`. - **One shared number per organization** — a second shared purchase returns `409 shared_number_limit`. - **Per-order line cap** — a single order can't exceed the cap (default **10** new lines); larger orders return `400 line_limit_exceeded`. Raise it from **Messaging → Limits** in the dashboard. - **Custom area codes** — out-of-stock codes are billed the custom area-code fee and open a provisioning ticket; in-stock codes are assigned immediately at no custom fee. ### Cards that need authentication (3DS/SCA) If the card requires 3DS/SCA, you get `202` with `status: action_required` and an `action_url`. Send the customer to that URL to authenticate; a `number.purchase.action_required` webhook also fires. ```json { "data": { "purchase_id": "idem_abc123", "status": "action_required", "action_url": "https://invoice.stripe.com/i/..." } } ``` ## Step 3 — Track provisioning Provisioning finishes after the invoice is paid. You can either poll or listen for a webhook. ### Poll the purchase ```bash curl 'https://api.blooio.com/v4/channels/blooio/purchases/idem_abc123' \ -H 'Authorization: Bearer YOUR_BLOOIO_API_KEY' ``` The status moves from `pending`/`provisioning` to `completed` (with the provisioned `allocations`) or `failed`. ```json { "data": { "purchase_id": "idem_abc123", "status": "completed", "allocations": [ { "binding_id": "bind_...", "phone_number": "+14155550142", "type": "dedicated" } ] } } ``` Full schema: [Get purchase status](/reference/v4/channels/getBlooioPurchase). ### Or listen for webhooks Prefer webhooks over polling for production. Because purchases complete asynchronously, subscribe to these events to learn the outcome: | Event | When | |-------|------| | `number.purchase.completed` | Lines provisioned. Payload carries `purchase_id`, `lines` (each `{ phone_number, channel_id }`), plus flat `phone_numbers` and `channel_ids` arrays covering **every** provisioned line. | | `number.purchase.action_required` | Card needs 3DS/SCA. Payload carries `purchase_id`, `action_url`. | | `number.purchase.failed` | Terminal failure; nothing provisioned. Payload carries `purchase_id`, `reason`. | | `number.removed` | A line was removed/unsubscribed. Payload carries `phone_number`, `channel_id`, `binding_id`, `reasons`. | For API-originated purchases, Blooio also emails your organization's admins the result. Always [verify the webhook signature](/webhook-signatures) before trusting a payload. ## Removing a line Unsubscribe an owned line with `DELETE /channels/{channel}`. The `{channel}` path segment accepts either the E.164 number or the `ch_` channel id. A `reasons` array is **required** — it's the same churn-reason handling as the dashboard unsubscribe button, and it emits a `number.removed` webhook. ```bash curl -X DELETE 'https://api.blooio.com/v4/channels/%2B14155550142' \ -H 'Authorization: Bearer YOUR_BLOOIO_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "reasons": ["too_expensive", "no_longer_needed"] }' ``` ```json { "data": { "phone_number": "+14155550142", "channel_id": "ch_...", "reasons": ["Too expensive", "No longer needed"] } } ``` Full schema: [Remove a number](/reference/v4/channels/removeBlooioNumber). ## Plans Pass the specific `plan` id when purchasing. Pricing shown is indicative — see the [pricing page](https://blooio.com/pricing) for current rates. | Plan id | Family | Description | |---------|--------|-------------| | `shared_nc` | shared | Entry shared line (limited new conversations/day). One per organization. | | `shared_com` | shared | Commercial shared line (higher new-conversation allowance). One per organization. | | `dedicated_com` | dedicated | Dedicated commercial line. Buy several with `quantity`. | | `dedicated_ent` | dedicated | Dedicated enterprise line with tiered volume pricing. | | `inbound_basic` | inbound | Reply-only inbound line — outbound is gated by prior inbound history (see [Number pools](/guides/number-pools)). | > **Note** > > Shared plans are limited to a **single line per organization**. Sending `quantity` > 1 on a shared plan returns `409` with `code: shared_number_limit`. ## Errors | Status | `code` | Meaning | |--------|--------|---------| | `403` | `feature_not_enabled` | Number Purchase API access hasn't been approved for the organization. | | `402` | `no_payment_method` | No saved card. Add one in billing before purchasing. | | `402` | `payment_failed` | The card was declined; nothing was provisioned. | | `400` | `idempotency_key_required` | Purchases require an `Idempotency-Key` header. | | `400` | `invalid_plan` | Unknown plan id (e.g. `dedicated` instead of `dedicated_com`). | | `400` | `plan_sunset` | The plan is no longer available for new purchases. | | `400` | `line_limit_exceeded` | Order exceeds your per-order line cap. Apply for an increase under Messaging → Limits. | | `409` | `shared_number_limit` | A shared plan is limited to one line per organization. | ## Related - [Number provisioning](/number-provisioning) — the same lifecycle as a core concept reference. - [Number pools](/guides/number-pools) — how a sender number is selected once you own lines. - [Webhooks](/webhooks) and [verifying signatures](/webhook-signatures) — subscribe to `number.purchase.*` and `number.removed` events. - [Calling the API with OAuth tokens](/oauth-api-tokens) — provisioning lines on behalf of another organization. - Reference: [List available](/reference/v4/channels/listAvailableBlooioNumbers) · [Purchase](/reference/v4/channels/purchaseBlooioNumbers) · [Get status](/reference/v4/channels/getBlooioPurchase) · [Remove](/reference/v4/channels/removeBlooioNumber). - [AgentPhone case study](https://blooio.com/case-studies/agentphone) — provisioning a line per end customer at scale. ## Tracking trends and conversation insights URL: https://docs.blooio.com/guides/conversation-insights Use the Bloobability v2 decide endpoint to judge conversations against your own policies with the instant or full model, estimate how often each policy applies, and track how those rates move over time. The **Bloobability decide** endpoint answers one question per policy: *how likely is it that this policy holds for this conversation?* You send a conversation and the policies you care about, such as "customer asked for a refund," "customer is at risk of churning," or "agent resolved the issue," and get back a **calibrated probability** for each one. That single primitive is enough to build two things: - **Conversation insights**: tag what happened in any one conversation, for routing, review queues, or a live dashboard. - **Trends**: measure how often a policy applies across many conversations, and watch that rate move week over week. > **Note** > > This is a **v4** feature under the [AI](/reference/v4/ai) tag. All requests go to `https://api.blooio.com/v4`. Make sure the version toggle at the top of the docs is set to **v4**. ## How it works Every result carries two calibrated numbers between 0 and 1: - `probability`: the policy holds for this conversation. - `answerable`: the conversation contains enough to judge the policy at all. A low `answerable` never changes `probability`; it tells you how much the conversation had to go on. There is no verdict and no built-in threshold, and neither model points to the messages behind a result. The call is **stateless**: nothing is stored, there's no idempotency key, and replaying a request re-runs inference. You bring the conversations; Blooio judges them. ## Choose a tier Every request names one of two models with `tier`. Nothing routes between them automatically, nothing falls back from one to the other, and the response's `tier` always echoes your choice. | | `instant` | `full` | | --- | --- | --- | | Best for | Short, explicit questions where the conversation states the deciding fact | Reversals ("actually, cancel that"), conditions, several speakers, long threads, and conversations that may be missing a fact | | Server time, short thread, 1 / 6 policies | 8.2 / 24.9 ms | 25.8 / 83.9 ms | | Accuracy on 900 fresh public questions | 77.9% | 88.8% | | Flags missing information | No: caught 0 of 60 deliberately unanswerable cases | Yes: caught 60 of 60 in the same screen | Server times are medians measured on the RTX 4090 that serves both tiers, for a four-message thread sent one request at a time. They exclude the internet and the public API in front of the model, and the two tiers take turns on one GPU, so the time you see per request is higher; they are not a latency guarantee. **When unsure, use `full`.** Choose by how hard each question is, not by how many you ask: one policy that depends on a reversal can need `full`, while several simple, stated-fact policies run well on `instant`. On `instant`, read `answerable` as a weak hint only; it does not reliably tell "unlikely" apart from "the conversation never said." ## Step 1: Define your policies A policy is an `id` (the stable key you'll track over time) and a `definition` (a plain-language statement that is true or false of the conversation). There's nothing to register: any definition works. Write definitions the way you'd brief a new support lead: plain, specific, one idea each. ```json [ { "id": "refund_request", "definition": "The customer currently asks for their money back." }, { "id": "churn_risk", "definition": "The customer signals they may cancel or stop using the product." }, { "id": "positive_sentiment", "definition": "The customer expresses satisfaction or praise." } ] ``` The model reads only the `definition`; the `id` is echoed back unchanged so you can join results to your own records. ## Step 2: Decide a conversation Send your tier, the messages in chronological order, and your policies. Every message needs a unique `id` and non-blank `text`. The other message fields are optional, and you should send them only when they're real: - `role` is free text: `customer`, `agent`, `manager`, `tool`, or your own. - `speaker_id` tells apart two people who share a role, such as two agents. - `timestamp` is RFC 3339 with an offset, and must not decrease from one message to the next. Add a top-level `assessment_time` when a policy depends on "now" (for example, "the customer has waited more than a day"). ```bash curl -X POST https://api.blooio.com/v4/ai/bloobability/v2/decide \ -H "Authorization: Bearer YOUR_BLOOIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tier": "full", "messages": [ { "id": "m1", "role": "customer", "speaker_id": "c1", "text": "This is the third time it broke. I want my money back." }, { "id": "m2", "role": "agent", "speaker_id": "a1", "text": "I am sorry. Let me pull up your order." } ], "policies": [ { "id": "refund_request", "definition": "The customer currently asks for their money back." }, { "id": "churn_risk", "definition": "The customer signals they may cancel or stop using the product." } ] }' ``` The response returns one result per policy, **in the same order you sent them**: ```json { "tier": "full", "model_version": "bloobability-v2-full-392ad04431a8", "policies": [ { "id": "refund_request", "probability": 0.97, "answerable": 0.99 }, { "id": "churn_risk", "probability": 0.41, "answerable": 0.72 } ] } ``` The probabilities above are illustrative. ### Reading a result | Field | What it tells you | | --- | --- | | `probability` | **The answer.** The estimated probability that the policy holds. This is the number to store and aggregate. | | `answerable` | How well the conversation supports judging the policy. On `full`, use it to separate "unlikely" from "can't tell." | | `tier` | The model that answered, always the one you asked for. | | `model_version` | The exact weights and calibration that produced the numbers. Store it alongside them. | If your application needs a yes/no, choose the threshold yourself. ## Step 3: Estimate a rate across many conversations To answer "what share of this week's conversations were refund requests?", decide each conversation and **average the probabilities** for that policy across the cohort. > **Warning** > > **Average probabilities rather than counting conversations above a threshold.** A threshold count throws away everything below the line, so it drifts with wherever you put the line. The average uses every conversation's probability, not just the ones above it. ```js const BLOO_API_KEY = process.env.BLOO_API_KEY; const POLICIES = [ { id: "refund_request", definition: "The customer currently asks for their money back." }, { id: "churn_risk", definition: "The customer signals they may cancel or stop using the product." }, ]; async function decide(messages) { const res = await fetch("https://api.blooio.com/v4/ai/bloobability/v2/decide", { method: "POST", headers: { Authorization: `Bearer ${BLOO_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ tier: "full", messages, policies: POLICIES }), }); if (!res.ok) throw new Error(`decide failed: ${res.status}`); return res.json(); } // conversations: [{ id, messages: [{ id, role, text }] }, ...] async function policyRates(conversations) { const totals = Object.fromEntries(POLICIES.map((p) => [p.id, 0])); for (const convo of conversations) { const { policies } = await decide(convo.messages); for (const r of policies) totals[r.id] += r.probability; } const n = conversations.length || 1; return Object.fromEntries( Object.entries(totals).map(([id, sum]) => [id, sum / n]), ); } // { refund_request: 0.18, churn_risk: 0.07 }: the estimated share of the cohort. ``` Each rate is the estimated fraction of conversations in the cohort where that policy applies. Store these per bucket (day, week, agent, segment) and you have the raw material for trends. ## Step 4: Track a trend over time Bucket your conversations by period, run `policyRates` on each bucket, and compare. The result is a time series of prevalence you can chart or alert on. ```js // buckets: [{ label: "2026-W01", conversations: [...] }, ...] async function trend(buckets) { const series = []; for (const bucket of buckets) { series.push({ label: bucket.label, rates: await policyRates(bucket.conversations) }); } return series; } ``` To keep a trend meaningful, hold three things constant: - **Same policy definitions.** Use the identical `definition` in every bucket. A wording change is a new measurement, not a continuation of the old one. - **Same model.** Keep one `tier` for the whole series and check that `model_version` stays the same. The two tiers are different models and don't return identical probabilities. Blooio's own Trends feature uses `full`. - **Comparable cohorts.** Compare like with like (same channel, segment, or funnel stage) so a shift in the mix doesn't masquerade as a shift in behavior. > **Warning** > > **Trust direction more than exact magnitude.** Calibration was fitted on research data and isn't guaranteed to hold identically for every cohort, policy wording, or future period. Treat "refund rate is up" as the reliable signal, and an exact point change as an estimate. ## Step 5: Route the hard cases to a human For per-conversation actions (auto-tagging, routing, closing tickets), choose your own thresholds and send everything uncertain to review: - A high `probability` with a high `answerable` is the strongest case for acting. - A middling `probability`, or a low `answerable` on `full`, is a good candidate for review. - Use `full` for anything you'd act on without review. `instant` is fast, but it misses more reversals and doesn't flag missing facts. The endpoint gives you numbers, not decisions: it doesn't certify any threshold as safe for automation, so validate yours on your own conversations before acting without review. ## Errors Errors use the standard v4 shape, `{ "error": { "code", "message", "details" } }`. `details` never echoes your conversation or policy text, and 5xx errors carry none. | Status | Meaning | | --- | --- | | `400` | The body doesn't match the contract: an unknown `tier`, unknown or `null` fields, blank strings, text that isn't valid Unicode, duplicate keys or IDs, or out-of-order timestamps. `details` names the failing `reason`, `path`, and `rule`. | | `413` | The body is over 1 MiB. | | `422` | The conversation and policies don't fit the chosen tier's context. Nothing is truncated: shorten the thread or send fewer policies per request. | | `429` / `503` | The model is busy or restarting (`queue_full`, `queue_timeout`, `model_unavailable`). Retry with backoff. | | `503` | This request made the model fail (`inference_timeout`, `gpu_memory_exhausted`). Retrying it unchanged won't help: shorten the thread or send fewer policies. | | `502` / `504` | The model host gave a bad answer or didn't answer in time. Retry later. | A request can carry up to 128 policies and 2,048 messages. Those are transport limits, not a promise that the conversation fits the model. ## Limitations > **Warning** > > Know these before you build on the scores: > > - **Domain.** Built for **English, text-only customer conversations**. Attachments aren't read, and other languages or genres are unmeasured. > - **Calibrated, not perfect.** A probability of `0.9` does not mean "correct exactly nine times in ten" for every policy and cohort. Both tiers are overconfident on sentences built to trick word-overlap shortcuts: on one such public set, 36 of `full`'s 233 answers at 90% or more were wrong. The numbers are most reliable in aggregate, for rates and trends. > - **`instant` doesn't detect missing information.** Its `answerable` flagged none of the 60 deliberately unanswerable cases in our latest screen. > - **Model-derived labels.** Scores reflect a model's judgment of your definition, not human-verified ground truth. Where that judgment is systematically off, the scores will be off the same way. ## Recap - Define stable policies (`id` + `definition`) and keep the wording fixed over time. - Decide conversations with `POST /v4/ai/bloobability/v2/decide`; read `probability` and `answerable`. - Pick `full` unless your questions are short and explicit and speed matters most; then `instant`. - **Estimate rates by averaging probabilities**, not by counting conversations over a threshold. - Track trends with one fixed `tier`, identical definitions, and comparable cohorts; trust direction over magnitude. - Pick your own thresholds, and send uncertain cases to review. See the complete field reference on the [Decide policies against a conversation](/reference/v4/ai/decideBloobabilityV2) endpoint page, and the benchmarks behind each tier on the [Bloobability v2 model page](https://blooio.com/inference/bloobability-v2-0-0). ## Receive webhooks locally URL: https://docs.blooio.com/guides/webhooks-local Tunnel your local server and test Blooio webhook deliveries ## Overview Run a local server, expose it with a tunnel (e.g., ngrok), and point Blooio to the public URL. ## Start a local server ```javascript title="server.js" const app = express(); app.use(express.json()); app.post("/webhooks/blooio", (req, res) => { console.log("[Blooio]", req.headers["x-blooio-event"], req.body); res.sendStatus(200); }); app.listen(3001, () => console.log("Listening on http://localhost:3001")); ``` ## Expose the server ```bash ngrok http http://localhost:3001 ``` Copy the HTTPS URL from ngrok, e.g., `https://abcd1234.ngrok.io`. ## Configure your webhook URL Create a new webhook using the API: ```bash curl -X POST 'https://api.blooio.com/v4/webhooks' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "url": "https://abcd1234.ngrok.io/webhooks/blooio" }' ``` The response is `201` with the subscription wrapped in a `data` envelope. It carries the `signing_secret` once, at creation: ```json { "data": { "id": "wh_abc123", "url": "https://abcd1234.ngrok.io/webhooks/blooio", "event_types": ["*"], "status": "active", "channel_id": null, "channel_type": null, "created_at": 1735324800000, "signing_secret": "whsec_a1b2c3d4e5f6789012345678901234567890abcdef" } } ``` > **Note** > > The URL must be `https://`. A subscription receives every event type — switch on the `type` field in your handler to pick out the ones you care about. ## Verify deliveries You should see incoming events in your terminal. See event schemas in [Webhook events](/api-reference/webhook-events). ## Managing webhooks ### List all webhooks ```bash curl -X GET 'https://api.blooio.com/v4/webhooks' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ### Update a webhook Updatable fields are `url` and `status` (`active` or `disabled`): ```bash curl -X PATCH 'https://api.blooio.com/v4/webhooks/{webhookId}' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "status": "disabled" }' ``` ### Delete a webhook ```bash curl -X DELETE 'https://api.blooio.com/v4/webhooks/{webhookId}' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ### Rotate webhook secret If you need a new signing secret: ```bash curl -X POST 'https://api.blooio.com/v4/webhooks/{webhookId}/secret/rotate' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ### Inspect deliveries ```bash curl -X GET 'https://api.blooio.com/v4/webhooks/{webhookId}/deliveries' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ## Verify webhook signatures URL: https://docs.blooio.com/guides/webhook-signatures Cryptographically verify that webhooks are genuinely from Blooio ## Overview Blooio signs all outgoing webhooks with an HMAC-SHA256 signature, allowing you to verify that: 1. The webhook was sent by Blooio (not a malicious third party) 2. The payload hasn't been tampered with in transit ## Signature format Every webhook request includes an `X-Blooio-Signature` header: ``` X-Blooio-Signature: t=1735324800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` | Component | Description | |-----------|-------------| | `t` | Unix timestamp (seconds) when the webhook was sent | | `v1` | HMAC-SHA256 signature of `{timestamp}.{raw_body}` | The `v1` prefix allows for future algorithm versioning. ## Getting your signing secret When you create a webhook with `POST /v4/webhooks`, the response includes a `signing_secret`: ```json { "data": { "id": "wh_abc123", "url": "https://example.com/webhook", "event_types": ["*"], "status": "active", "channel_id": null, "channel_type": null, "created_at": 1735324800000, "signing_secret": "whsec_a1b2c3d4e5f6789012345678901234567890abcdef" } } ``` > **Warning** > > **Save this secret immediately!** It's only shown once at creation time. If you lose it, you'll need to rotate to get a new one. ## Rotating your secret If you need a new signing secret: ```bash curl -X POST 'https://api.blooio.com/v4/webhooks/{webhookId}/secret/rotate' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` The response includes the new secret (shown once): ```json { "data": { "id": "wh_abc123", "signing_secret": "whsec_newSecret1234567890abcdef1234567890abcdef" } } ``` > **Note** > > Rotation is immediate. The old secret stops working instantly. Update your server before rotating in production. ## Verifying signatures **Node.js / Express** ```javascript const crypto = require('crypto'); function verifyBlooioSignature(rawBody, signatureHeader, secret) { // Parse the signature header const parts = signatureHeader.split(','); const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1]; const signature = parts.find(p => p.startsWith('v1=')).split('=')[1]; // Reject webhooks older than 5 minutes (replay protection) const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10); if (age > 300) { throw new Error('Webhook timestamp too old'); } // Compute expected signature const expectedSignature = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); // Constant-time comparison to prevent timing attacks return crypto.timingSafeEqual( Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex') ); } // Express middleware app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-blooio-signature']; if (!signature) { return res.status(401).send('Missing signature'); } try { const isValid = verifyBlooioSignature( req.body.toString(), signature, process.env.BLOOIO_WEBHOOK_SECRET ); if (!isValid) { return res.status(401).send('Invalid signature'); } } catch (err) { return res.status(401).send(err.message); } const event = JSON.parse(req.body); // Process the webhook... res.sendStatus(200); }); ``` > **Warning** > > Use `express.raw()` to get the raw request body. Parsing as JSON first will change the body and break signature verification. **Python / Flask** ```python def verify_blooio_signature(raw_body: bytes, signature_header: str, secret: str) -> bool: # Parse the signature header parts = dict(p.split('=') for p in signature_header.split(',')) timestamp = parts.get('t') signature = parts.get('v1') if not timestamp or not signature: raise ValueError('Invalid signature format') # Reject webhooks older than 5 minutes age = int(time.time()) - int(timestamp) if age > 300: raise ValueError('Webhook timestamp too old') # Compute expected signature signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}" expected = hmac.new( secret.encode('utf-8'), signed_payload.encode('utf-8'), hashlib.sha256 ).hexdigest() # Constant-time comparison return hmac.compare_digest(signature, expected) # Flask example from flask import Flask, request app = Flask(__name__) @app.route('/webhook', methods=['POST']) def webhook(): signature = request.headers.get('X-Blooio-Signature') if not signature: return 'Missing signature', 401 try: is_valid = verify_blooio_signature( request.get_data(), signature, os.environ['BLOOIO_WEBHOOK_SECRET'] ) if not is_valid: return 'Invalid signature', 401 except ValueError as e: return str(e), 401 event = request.get_json() # Process the webhook... return '', 200 ``` **Go** ```go package main "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "io" "net/http" "strconv" "strings" "time" ) func verifyBlooioSignature(body []byte, signatureHeader, secret string) error { // Parse signature header parts := strings.Split(signatureHeader, ",") var timestamp, signature string for _, part := range parts { if strings.HasPrefix(part, "t=") { timestamp = strings.TrimPrefix(part, "t=") } if strings.HasPrefix(part, "v1=") { signature = strings.TrimPrefix(part, "v1=") } } if timestamp == "" || signature == "" { return errors.New("invalid signature format") } // Check timestamp age ts, _ := strconv.ParseInt(timestamp, 10, 64) age := time.Now().Unix() - ts if age > 300 { return errors.New("webhook timestamp too old") } // Compute expected signature signedPayload := timestamp + "." + string(body) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signedPayload)) expected := hex.EncodeToString(mac.Sum(nil)) // Constant-time comparison if !hmac.Equal([]byte(signature), []byte(expected)) { return errors.New("signature mismatch") } return nil } func webhookHandler(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) signature := r.Header.Get("X-Blooio-Signature") err := verifyBlooioSignature(body, signature, os.Getenv("BLOOIO_WEBHOOK_SECRET")) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } // Process webhook... w.WriteHeader(http.StatusOK) } ``` ## Troubleshooting ### Common issues | Problem | Solution | |---------|----------| | `Invalid signature` | Make sure you're using the raw request body, not parsed JSON | | `Webhook timestamp too old` | Check your server's clock is synchronized (NTP) | | `Missing signature header` | The webhook may be from before signatures were enabled | | Lost your secret | Use the rotate endpoint to get a new one | ### Testing signatures You can verify your implementation by checking the signature header value locally: ```javascript const crypto = require('crypto'); const secret = 'whsec_your_secret_here'; const timestamp = Math.floor(Date.now() / 1000); const body = JSON.stringify({ id: 'evt_test', type: 'message.sent', data: {} }); const signature = crypto .createHmac('sha256', secret) .update(`${timestamp}.${body}`) .digest('hex'); console.log(`t=${timestamp},v1=${signature}`); ``` ## Best practices 1. **Store secrets securely** - Use environment variables or a secrets manager 2. **Verify before processing** - Always validate the signature before acting on webhook data 3. **Handle clock skew** - The 5-minute tolerance accounts for minor clock differences 4. **Use constant-time comparison** - Prevents timing attacks on signature verification 5. **Log verification failures** - Monitor for potential attacks or misconfigurations ## Send attachments URL: https://docs.blooio.com/guides/attachments Include media URLs when sending messages ## Supported attachments Pass an array of publicly accessible URLs in the `attachments` field. Common formats: images, PDFs, and other files. **cURL** ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "attachments": [ "https://cdn.example.com/image.png" ], "metadata": { "order_id": "123" } }' ``` **Node.js** ```javascript const chatId = encodeURIComponent('+15551234567') await fetch(`https://api.blooio.com/v2/api/chats/${chatId}/messages`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ attachments: ['https://cdn.example.com/image.png'] }) }) ``` **Python** ```python from urllib.parse import quote chat_id = quote('+15551234567', safe='') requests.post(f'https://api.blooio.com/v2/api/chats/{chat_id}/messages', headers={ 'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}", 'Content-Type': 'application/json' }, json={ 'attachments': ['https://cdn.example.com/image.png'] } ) ``` ## Named attachments You can also provide attachment objects with custom filenames: ```json { "attachments": [ { "url": "https://cdn.example.com/doc.pdf", "name": "contract.pdf" } ] } ``` ## Photo & video carousels (iMessage) On the v4 send API, several images and/or videos are grouped into a **single message** that Messages renders as a swipeable photo/video carousel/collage — one bubble instead of several separate bubbles. **This is the default.** On a Blooio iMessage send whose attachments are **two or more image/video URLs**, the carousel layout is applied automatically — you don't need to set anything. To send those attachments as separate bubbles instead, set `carousel: false`. > **Note** > > Carousels are only available on **Blooio iMessage** channels. The default-on > behavior only applies there; images sent over Twilio/MMS or other channels are > never grouped. Explicitly setting `carousel: true` on another channel type > returns `422 carousel_unsupported_for_channel_type`. ### Send from an `attachments` array The common form — an optional `text` becomes the carousel's caption. Because the two attachments are images, this is grouped into a carousel automatically: ```bash curl -X POST 'https://api.blooio.com/v4/channels/CHANNEL_ID/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "to": "+15551234567", "text": "Here are the photos!", "attachments": [ "https://cdn.example.com/photo1.jpg", "https://cdn.example.com/clip.mov", "https://cdn.example.com/photo2.png" ] }' ``` To send these as three separate image bubbles instead, add `"carousel": false`. ### Send from `parts` You can also use an explicit `parts` array. When every part is a media attachment (no text parts), it's grouped into a carousel automatically: ```json { "to": "+15551234567", "parts": [ { "url": "https://cdn.example.com/photo1.jpg" }, { "url": "https://cdn.example.com/photo2.jpg" } ] } ``` ### What will (and won't) send Every attachment must be an **image or video**. The type is inferred from the URL's file extension: - **Images:** `jpg`, `jpeg`, `png`, `gif`, `heic`, `heif`, `webp`, `bmp`, `tif`, `tiff`, `avif` - **Videos:** `mov`, `mp4`, `m4v`, `qt`, `3gp`, `avi`, `mkv`, `webm` The default-on grouping only fires when a send **qualifies** — two or more attachments that are all images/videos. Anything that doesn't qualify is simply left ungrouped (no error); it's only a hard error when you set `carousel: true` explicitly and the send breaks a rule. | Send | Result | | --- | --- | | 2+ image URLs (nothing set) | ✅ Grouped into one photo carousel automatically | | 2+ image URLs, `carousel: false` | ✅ Sent as separate image bubbles | | Mixed images **and** videos | ✅ Grouped into one photo/video collage | | A URL with **no extension** (signed / CDN URL) | ✅ Allowed — the real type is resolved on the device at send | | A URL with a query string (`?token=…`) | ✅ Only the path is checked, so this is fine | | A single image + a `.pdf` (nothing set) | ✅ Sent ungrouped — doesn't qualify, so it's left as-is | | A URL with a known non-visual extension (`.pdf`, `.zip`, …) **+** `carousel: true` | ❌ `422 invalid_carousel` | | Only **one** attachment **+** `carousel: true` | ❌ `422 invalid_carousel` (a carousel needs at least two) | | A **text** part mixed into `parts` **+** `carousel: true` | ❌ `422 invalid_carousel` (use top-level `text` for a caption) | | `carousel: true` on a non-media send (rich link, poll, …) | ❌ `422 invalid_content` | | A non-boolean `carousel` value | ❌ `422 invalid_content` | > **Warning** > > Grouping images/videos into one collage is now the **default** on Blooio > iMessage. Set `carousel: false` to opt out and send separate bubbles. Setting > `carousel: true` explicitly additionally enforces the visual-only rule, so a > non-visual attachment or a text part then errors instead of being sent > ungrouped. ### Reactions on a specific image When a recipient taps back on **one image** in a carousel, the `message.reaction` webhook tells you exactly which one. The payload includes: - `part_index` — the 0-based position of the reacted item within the message. - `reacted_attachment` — the exact attachment that was reacted to: `{ index, url, media_type, size, caption }`. It's `null` when the tapback landed on the caption/text or a non-multipart message. So a love on the 4th photo of a carousel reports `part_index: 3` and a `reacted_attachment` pointing at that specific image — not just "loved the message". The full ordered `attachments` array is included on the event too. ## Attribution badge (iMessage) Set the optional `badge` field on a v4 send to show an attribution line under the bubble, the way a message dictated to Siri or sent during a FaceTime call appears: | `badge` value | Recipient sees | | -------------------- | --------------------- | | `sent_with_siri` | **Sent with Siri** | | `sent_with_facetime` | **Sent with FaceTime** | Omit `badge` to send no attribution. Any other value returns `422 invalid_content`. ```bash curl -X POST 'https://api.blooio.com/v4/channels/CHANNEL_ID/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "to": "+15551234567", "text": "On my way!", "badge": "sent_with_siri" }' ``` It works on `text`, `attachments` (media), `parts` (multipart), `rich_link`, and `app_clip` sends, and the badge is rendered on the recipient's device. > **Note** > > `badge` is **Blooio iMessage only**. Setting it on any other content type > (poll, interactive, invitation, imessage_app) returns `422 invalid_content`; > it has no effect on non-Blooio channels. ## Best practices - Host media on stable, publicly reachable URLs (HTTPS) - Prefer reasonably sized images to reduce delivery latency - Include `metadata` to correlate messages in your system ## Rich link previews URL: https://docs.blooio.com/guides/link-previews Send URL messages with iMessage-style rich previews, override the image and title, and batch-send multiple links in one request When a message's `text` is exactly a URL, Blooio delivers it as an iMessage **URL balloon** — the rounded bubble with a hero image, bold title, and link preview, the same shape that appears when a user pastes a URL into Messages.app. Previews are generated automatically from the page's Open Graph metadata. Pass an optional `link_preview` object to override the image, the title, or both. ## Auto-generated preview Send a single URL and Blooio handles the rest — it fetches the page's OG tags and builds the preview. **cURL** ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "text": "https://example.com/launch" }' ``` **Node.js** ```javascript const chatId = encodeURIComponent('+15551234567') await fetch(`https://api.blooio.com/v2/api/chats/${chatId}/messages`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ text: 'https://example.com/launch' }), }) ``` **Python** ```python from urllib.parse import quote chat_id = quote('+15551234567', safe='') requests.post( f'https://api.blooio.com/v2/api/chats/{chat_id}/messages', headers={ 'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}", 'Content-Type': 'application/json', }, json={'text': 'https://example.com/launch'}, ) ``` ## Override the image and title Pass a `link_preview` object with `image_url`, `title`, or both to replace what the auto-fetched preview would show. **cURL** ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "text": "https://example.com/launch", "link_preview": { "image_url": "https://cdn.example.com/previews/launch-hero.png", "title": "Our new product is here" } }' ``` **Node.js** ```javascript await fetch(url, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ text: 'https://example.com/launch', link_preview: { image_url: 'https://cdn.example.com/previews/launch-hero.png', title: 'Our new product is here', }, }), }) ``` Both fields inside `link_preview` are optional. If only `title` is provided, the image comes from auto-fetched OG metadata. If only `image_url` is provided, the title is auto-fetched. ## Fields | Field | Type | Description | | ----------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | `image_url` | string (HTTPS URL) | Points to an image (png, jpg, webp, or gif). Blooio downloads it server-side and attaches it as the bubble's hero. Maximum 16 MB. | | `title` | string | Bold title line shown in the bubble. Maximum 200 characters. | ## Batch — multiple URL messages in one request Use the `parts` array and attach a `link_preview` to each part. Every part becomes its own URL-balloon message; messages are sent sequentially in the order of the array. The response contains `message_ids` and `count` instead of `message_id`. **cURL** ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "parts": [ { "text": "https://example.com/product", "link_preview": { "title": "Our product", "image_url": "https://cdn.example.com/previews/product.png" } }, { "text": "https://example.com/pricing", "link_preview": { "title": "Pricing plans" } }, { "text": "https://example.com/contact", "link_preview": {} } ] }' ``` **Node.js** ```javascript await fetch(url, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ parts: [ { text: 'https://example.com/product', link_preview: { title: 'Our product', image_url: 'https://cdn.example.com/previews/product.png', }, }, { text: 'https://example.com/pricing', link_preview: { title: 'Pricing plans' }, }, { text: 'https://example.com/contact', link_preview: {}, }, ], }), }) ``` Example response: ```json { "message_ids": ["msg_a1b2c3", "msg_d4e5f6", "msg_g7h8i9"], "status": "queued", "count": 3 } ``` > **Note** > > In batch mode (any part with a `link_preview`), every part must be text-only > and its `text` must be a single http(s) URL. Attachment parts and non-URL text > are rejected with a 400. ## Validation rules - `link_preview` requires the message `text` (or each per-part `text`) to be exactly a single http(s) URL — no surrounding words or whitespace. Mixed text plus URL returns 400. - Cannot be combined with the `attachments` field. - The single-message form doesn't accept an array of `text` values; use `parts` for batch mode. ## Failure handling Preview downloads are best-effort. If `image_url` can't be resolved (DNS error, 404, non-image content type, oversized file, or network timeout), the message still sends — Blooio silently falls back to the auto-fetched Open Graph preview. A broken preview URL never fails the request or returns 5xx. ## What the recipient sees The iMessage URL bubble on iPhone and iPad renders three elements: - Large hero image - Bold title line - URL host derived from the link Any field you don't provide comes from the page's OG tags. Fields the iOS bubble doesn't render (site name, long description) aren't exposed here — only what the recipient actually sees. ## Animated previews If the target URL's Open Graph metadata points at an animated webp or mp4 (e.g. a video preview), Blooio delivers it as an animated bubble automatically — the same behavior you get pasting the URL into Messages.app's compose view. No extra parameters needed. ## Related - [Sending messages](/guides/message-sending) — base concepts - [Send a message](/messages/sendMessage) — full API reference ## Custom bubbles (iMessage apps) URL: https://docs.blooio.com/guides/custom-bubbles Send and receive bring-your-own iMessage app extension bubbles A **custom bubble** is a Messages app-extension bubble — the rich, tappable card an iMessage app renders inline in a conversation. Blooio lets you **send** a bubble that points at your own iMessage app extension, and **receive** the bubbles your contacts send back as structured data. > **Note** > > Custom bubbles are **Blooio iMessage only** (peer-to-peer). Blooio is the > transport, not the renderer: recipients who have the app installed see its > interactive card; everyone else sees a native template card with Apple's > built-in App Store fallback. ## Sending a custom bubble Send the `imessage_app` content type. You identify **your own** signed iMessage app extension with `bundle_id` + `team_id`, and pass the app-state `url` your extension reads when the recipient taps the bubble. | Field | Required | Description | | ------------- | -------- | --------------------------------------------------------------------------------------------- | | `bundle_id` | yes | Your iMessage extension's bundle id (e.g. `com.example.app.imessage`). | | `team_id` | yes | Your Apple Developer Team ID. Combined with `bundle_id` to form the balloon plugin id. | | `url` | yes | The app-state string your extension understands. Opaque to Blooio — you define its meaning. | | `app_name` | no | Name shown on the template card (the not-installed fallback). | | `caption` | no | Caption line on the template card. | | `subcaption` | no | Secondary line on the template card. | | `image_url` | no | Image shown on the template card. | | `app_store_id` | no | App Store id (adam id) for the "get this app" link when the recipient doesn't have your app. | **cURL** ```bash curl -X POST 'https://api.blooio.com/v4/channels/CHANNEL_ID/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "to": "+15551234567", "imessage_app": { "bundle_id": "com.example.app.imessage", "team_id": "TEAMID1234", "url": "https://example.com/state?s=eyJzdGVwIjoxfQ", "app_name": "Example App", "caption": "Your turn!" } }' ``` **Node.js** ```javascript await fetch('https://api.blooio.com/v4/channels/CHANNEL_ID/messages', { method: 'POST', headers: { Authorization: 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ to: '+15551234567', imessage_app: { bundle_id: 'com.example.app.imessage', team_id: 'TEAMID1234', url: 'https://example.com/state?s=eyJzdGVwIjoxfQ', app_name: 'Example App', caption: 'Your turn!', }, }), }) ``` **Python** ```python requests.post( 'https://api.blooio.com/v4/channels/CHANNEL_ID/messages', headers={ 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json', }, json={ 'to': '+15551234567', 'imessage_app': { 'bundle_id': 'com.example.app.imessage', 'team_id': 'TEAMID1234', 'url': 'https://example.com/state?s=eyJzdGVwIjoxfQ', 'app_name': 'Example App', 'caption': 'Your turn!', }, }, ) ``` > **Note** > > The interactive overlay only renders for recipients who have **your** app > installed — Blooio does not host or ship a renderer. Everyone else sees the > template card built from `caption` / `app_name` / `image_url`, plus Apple's > App Store fallback. For an instant, no-install experience use `app_clip`; to > deep-link into an already-installed app use `rich_link`. ## How the card looks — and what happens on tap This is the part that surprises people, so read it before you pick `imessage_app`. **The fallback card is compact.** When the recipient does **not** have your app, Messages renders a small "thumbnail + caption" row — a little leading image (from `image_url`) next to `caption` / `subcaption`. It is **not** a large hero-image card, and there is no way to make the *fallback* card big; that layout is fixed by Messages. A large edge-to-edge image only appears inside your app's own interactive view, which only recipients who have your app installed will see. **Tapping is app-gated.** Tapping the bubble opens **your app** only if the recipient has your exact extension installed. If they don't, Messages sends them to the **App Store** (to the `app_store_id` listing if you provided one). It does **not** open `url` in a browser — `url` is app state, only meaningful to your extension. So a custom bubble can't "open a link" for someone who doesn't have your app. ### If you want a big, tappable card instead If your goal is a marketing/notification card — a large image that opens content when tapped, on every device, with no app required — use a different content type: | You want… | Use | Tap behavior | | --- | --- | --- | | A large image card that opens a link | [`rich_link`](/guides/link-previews) | Opens the URL | | An instant, no-install mini experience | `app_clip` | Launches an App Clip | | Your own interactive app bubble | `imessage_app` | Opens your app **if installed**, else the App Store | ### What to include (and skip) - **Always** set `caption` (and optionally `subcaption`) — it's the text every recipient sees on the fallback card. - Set `app_name` so the fallback card is attributed to your app. - Set `image_url` for the small leading thumbnail. Keep it square and small (thumbnail-sized); it is **not** rendered as a full-width banner, so a large hero image is wasted here — use `rich_link` if you need that. - Set `app_store_id` so recipients without your app land on the correct App Store listing rather than a generic "not available" state. - **Don't** rely on `url` to open anything for recipients who lack your app — it is only handed to your extension. Put anything a general audience should be able to open in a `rich_link` instead. ## Receiving a custom bubble When a contact sends a custom bubble back, its text is just a placeholder glyph on the wire — the meaningful content lives in the bubble's payload. Blooio decodes it for you and delivers a `message.received` webhook with `message_type: "imessage_app"` and a structured `imessage_app` object (symmetric to the send shape), so you never have to parse Apple's archive yourself. | Field | Description | | -------------------- | ------------------------------------------------------------------------------------------------------- | | `balloon_bundle_id` | Full Messages plugin id: `com.apple.messages.MSMessageExtensionBalloonPlugin::`. | | `team_id` | The sending app's Apple Developer Team ID. | | `bundle_id` | The sending app's iMessage extension bundle id — use it to route to the right handler. | | `app_store_id` | App Store numeric id, when present. | | `app_name` | App name carried on the bubble. | | `url` | The app-state string the sender's extension wrote. **This is the payload** — decode it in your own app logic. | | `caption` | Caption line, if any. | | `subcaption` | Secondary line, if any. | | `summary` | Short summary / accessibility label. | | `layout_class` | Messages layout class (e.g. `MSMessageTemplateLayout`). | | `session_id` | Session identifier that correlates a multi-step exchange (e.g. the turns of a back-and-forth), when present. | The top-level `text` mirrors the bubble's caption so text-only consumers still see something readable instead of a placeholder. ```json { "type": "message.received", "api_version": "2026-10-01", "created_at": 1730000000000, "organization_id": "org_EXAMPLE", "data": { "kind": "received", "message_type": "imessage_app", "text": "Your turn!", "sender": "+15551234567", "recipient": "+15557654321", "channel_address": "+15557654321", "chat_id": "chat_EXAMPLE", "message_id": "msg_EXAMPLE", "protocol": "imessage", "attachments": [], "imessage_app": { "balloon_bundle_id": "com.apple.messages.MSMessageExtensionBalloonPlugin:TEAMID1234:com.example.app.imessage", "team_id": "TEAMID1234", "bundle_id": "com.example.app.imessage", "app_store_id": 1234567890, "app_name": "Example App", "url": "https://example.com/state?s=eyJ0dXJuIjoyfQ", "caption": "Your turn!", "subcaption": null, "summary": "Example App", "layout_class": "MSMessageTemplateLayout", "session_id": "3F2504E0-4F89-41D3-9A0C-0305E82C3301" } } } ``` > **Note** > > The `url` is **opaque to Blooio** — it's whatever the sending app encoded. Your > integration (or the app that owns that `bundle_id`) is responsible for decoding > it and deciding what to do. Received bubbles can come from **any** iMessage app > a contact used, so branch on `bundle_id` / `team_id` to handle the ones you > support and ignore the rest. ## Building a stateful experience `url` is enough to build a full back-and-forth on top of a custom bubble **for your own extension**: 1. **Receive** a `message.received` event with `message_type: "imessage_app"`. 2. **Branch** on `bundle_id` / `team_id` to confirm it's your app. 3. **Decode** `url` with your own logic to read the current state. 4. **Reply** by sending a new `imessage_app` bubble whose `url` carries the next state (reuse the same `bundle_id` / `team_id`). Carry anything your extension needs to link the turns together inside `url`. `session_id` is inbound-only: it tells you which of a contact's bubbles belong to the same exchange, but a send cannot continue that session — every bubble you send starts a new one. Replaying a bubble from an app you don't own (for example, sending back a game invitation another app created) is not supported. Its extension decides what to do with state it did not write, and Blooio cannot make it open. ## Delivery status An `imessage_app` send follows the usual message lifecycle, minus `sent`: the send returns `queued` (and fires `message.queued`), then `message.delivered` when the recipient's device receives it, and `message.read` if they have read receipts on. Track it with the same webhooks and [message status](/reference/v4/messages/getMessageStatus) endpoint as any other message. ## Related - [Find My & Check In bubbles](/guides/apple-system-apps) — Apple's first-party iMessage apps - [Attachments & carousels](/guides/attachments) — media bubbles and the `badge` attribution - [Link previews](/guides/link-previews) — rich link cards (`rich_link`) - [Webhooks](/guides/webhooks-local) — subscribe to `message.received` - [Webhook signatures](/guides/webhook-signatures) — verify inbound webhooks ## Find My & Check In bubbles URL: https://docs.blooio.com/guides/apple-system-apps Send Apple's first-party Find My and Check In iMessage app bubbles Apple ships several **first-party iMessage apps** — Find My (location sharing) and Check In (safety) among them. Each renders as a custom bubble like any other iMessage app, so you send them with the same [`imessage_app`](/guides/custom-bubbles) content type: point at Apple's bundle id (team id `0000000000`) and provide the app's `url` payload. > **Note** > > Apple system apps are **Blooio iMessage only**. The bubble's card renders from > `app_name` / `caption` on every device, but the live experience (a running > Check In timer, an active location share) only activates when `url` is a valid > payload from an authentic Apple session — Apple end-to-end encrypts the session > data inside it. Blooio transports the bubble exactly as you provide it and never > inspects or alters the payload. ## Apple app identifiers | App | `bundle_id` | `team_id` | | ---------- | ------------------------------------------------------- | ------------ | | Find My | `com.apple.findmy.FindMyMessagesApp` | `0000000000` | | Check In | `com.apple.SafetyMonitorApp.SafetyMonitorMessages` | `0000000000` | | Apple Cash | `com.apple.PassbookUIService.PeerPaymentMessagesExtension` | `0000000000` | > **Warning** > > Apple Cash money movement is gated by the device Secure Element and cannot be > initiated through the API — you can receive and display an Apple Cash bubble, > but not create a payment request from it. ## Find My Find My bubbles come in two flavors, shown by the label on the card: - **Requested Location** — ask a contact to share their location. - **Started Sharing Location** — share your own location. The `url` is a query string carrying Apple's compressed location-session data: ```text ?FindMyMessagePayloadVersionKey=v0&FindMyMessagePayloadZippedDataKey= ``` **cURL** ```bash curl -X POST 'https://api.blooio.com/v4/channels/CHANNEL_ID/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "to": "+15551234567", "imessage_app": { "bundle_id": "com.apple.findmy.FindMyMessagesApp", "team_id": "0000000000", "url": "?FindMyMessagePayloadVersionKey=v0&FindMyMessagePayloadZippedDataKey=", "app_name": "Find My", "caption": "Requested Location" } }' ``` **Node.js** ```javascript await fetch('https://api.blooio.com/v4/channels/CHANNEL_ID/messages', { method: 'POST', headers: { Authorization: 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ to: '+15551234567', imessage_app: { bundle_id: 'com.apple.findmy.FindMyMessagesApp', team_id: '0000000000', url: '?FindMyMessagePayloadVersionKey=v0&FindMyMessagePayloadZippedDataKey=', app_name: 'Find My', caption: 'Requested Location', // or "Started Sharing Location" }, }), }) ``` ## Check In Check In lets someone notify a contact they've arrived safely, with a timer. The `url` is a query string; the key parameters are: | Parameter | Meaning | | ------------------ | ----------------------------------------------------------- | | `messageType` | `1` starts a Check In, `2` marks it ended. | | `interfaceVersion` | Payload format version (`1`). | | `sessionID` | Correlates the start and end of one Check In. | | `receiverHandle` | The recipient (E.164 phone or Apple ID). | | `sessionType` | The Check In mode (e.g. `2` for a destination check-in). | | `estimatedEndTime` | Unix timestamp the timer counts down to. | | `shareURL` | Apple's iCloud share link for the session. | ```text ?messageType=1&interfaceVersion=1&sessionID=&receiverHandle=+15551234567&sessionType=2&estimatedEndTime=&shareURL=https://www.icloud.com/share/&sharingInvitationData= ``` ```bash curl -X POST 'https://api.blooio.com/v4/channels/CHANNEL_ID/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "to": "+15551234567", "imessage_app": { "bundle_id": "com.apple.SafetyMonitorApp.SafetyMonitorMessages", "team_id": "0000000000", "url": "?messageType=1&interfaceVersion=1&sessionID=&receiverHandle=+15551234567&sessionType=2&estimatedEndTime=&shareURL=https://www.icloud.com/share/&sharingInvitationData=", "app_name": "Check In", "caption": "Check In: Home" } }' ``` ## Receiving When a contact sends one of these, you get a `message.received` webhook with `message_type: "imessage_app"` and the `imessage_app` object — `app_name` (`Find My` / `Check In`), `caption` / `summary` (e.g. `Requested Location`, `Check In: Home`), and the raw `url` payload. Branch on `bundle_id` to route: ```json { "type": "message.received", "data": { "message_type": "imessage_app", "text": "Requested Location", "sender": "+15551234567", "imessage_app": { "balloon_bundle_id": "com.apple.messages.MSMessageExtensionBalloonPlugin:0000000000:com.apple.findmy.FindMyMessagesApp", "team_id": "0000000000", "bundle_id": "com.apple.findmy.FindMyMessagesApp", "app_name": "Find My", "caption": "Requested Location", "summary": "Requested Location", "url": "?FindMyMessagePayloadVersionKey=v0&FindMyMessagePayloadZippedDataKey=…" } } } ``` > **Warning** > > The `url` carries Apple's end-to-end-encrypted session data (sharing > invitation, iCloud share link, participant ids). You can relay a bubble you > received, but a *live* session (real-time location, an active timer) requires a > payload from a genuine Apple session — it can't be synthesized. ## Related - [Custom bubbles (iMessage apps)](/guides/custom-bubbles) — the generic mechanism - [Attachments & carousels](/guides/attachments) - [Webhooks](/guides/webhooks-local) — subscribe to `message.received` ## Contact flows URL: https://docs.blooio.com/guides/contact-flows Check iMessage/SMS capabilities and choose the best channel Use the capabilities endpoint to decide which protocol to use. ## Check capabilities ```bash curl -H 'Authorization: Bearer YOUR_API_KEY' \ 'https://api.blooio.com/v2/api/contacts/%2B15551234567/capabilities' ``` Response example: ```json { "contact": "+15551234567", "type": "phone", "capabilities": { "imessage": true, "sms": true }, "last_checked": 1703123457000 } ``` ## Send on the best channel If `imessage` is true, prefer iMessage; otherwise send SMS. Blooio automatically selects the best protocol, but you can track which was used via webhooks. ```javascript async function sendBest(contact, text) { const encodedContact = encodeURIComponent(contact) // Check capabilities const capsRes = await fetch(`https://api.blooio.com/v2/api/contacts/${encodedContact}/capabilities`, { headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}` } }) const caps = await capsRes.json() const protocol = caps.capabilities?.imessage ? 'imessage' : 'sms' // Send the message const res = await fetch(`https://api.blooio.com/v2/api/chats/${encodedContact}/messages`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ text, metadata: { expected_protocol: protocol } }) }) return res.json() } ``` Store the expected protocol in `metadata` for downstream analytics. ## Managing contacts You can also create and manage contacts with the v2 API: ### Create a contact ```bash curl -X POST 'https://api.blooio.com/v2/api/contacts' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "identifier": "+15551234567", "name": "John Doe" }' ``` ### Update a contact ```bash curl -X PATCH 'https://api.blooio.com/v2/api/contacts/%2B15551234567' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "name": "Jane Doe" }' ``` ### List contacts ```bash curl 'https://api.blooio.com/v2/api/contacts?limit=50&sort=recent' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` ## Error handling & retries URL: https://docs.blooio.com/guides/error-handling Handle common errors, rate limits, and implement safe retries ## Error schema All errors follow a standard shape: `{ error, message, status }`. See [Errors](/api-reference/errors). ## Common cases - 401 Unauthorized: missing/invalid token - 402 Payment Required: trial limit reached - 403 Forbidden: action not allowed - 409 Conflict: duplicate idempotency key or non-cancellable message - 429 Too Many Requests: conversation or configured outbound limit reached - 503 Service Unavailable: no active number available Messaging-safety `403` and `429` responses are not transient failures. Branch on the response body's `code` and follow [Messaging safety limits and recovery](/guides/messaging-safety) instead of retrying until the request succeeds. ## Retry strategy Use an `Idempotency-Key` with exponential backoff for transient errors (5xx). Do not apply this retry loop to messaging-safety `403` or `429` responses. ```javascript async function sendWithRetry(chatId, body, { maxAttempts = 5 } = {}) { const encodedChatId = encodeURIComponent(chatId); let attempt = 0; while (attempt < maxAttempts) { attempt++; const idempotencyKey = `retry-${Date.now()}-${attempt}`; const res = await fetch( `https://api.blooio.com/v2/api/chats/${encodedChatId}/messages`, { method: "POST", headers: { Authorization: `Bearer ${process.env.BLOOIO_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify(body), }, ); if (res.ok) return res.json(); if (res.status < 500) throw new Error(`Non-retryable: ${res.status}`); await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** attempt, 10000)), ); } throw new Error("Max retry attempts reached"); } ``` Persist keys client-side to dedupe across restarts. ## Twilio Integration URL: https://docs.blooio.com/guides/twilio-integration Send SMS/MMS messages using your own Twilio account Blooio supports sending SMS and MMS messages through your own Twilio account. This guide covers setup, usage, and key differences from Blooio's native iMessage integration. ## Overview The Twilio integration allows you to: - Send SMS/MMS using your existing Twilio phone numbers - Receive inbound messages via webhooks - Track message delivery status - Use multiple Twilio accounts per organization > **Note** > > Twilio integration is completely separate from Blooio's iMessage service. Messages sent via Twilio use SMS/MMS protocols, not iMessage. ## When to use Twilio vs Blooio Twilio and Blooio serve different messaging needs: | Use Case | Recommended | Why | |----------|-------------|-----| | Broadcast campaigns | Twilio | High-volume SMS to many recipients, carrier-approved for marketing | | Promotional outreach | Twilio | A2P 10DLC registration supports commercial messaging at scale | | 1-on-1 conversations | Blooio | iMessage provides richer experience with reactions, read receipts, and typing indicators | | Customer support | Blooio | Personal, conversational feel builds stronger relationships | | High-touch sales | Blooio | iMessage feels like texting a friend, not a business | ### The hybrid approach A popular pattern combines both services. For automatic Twilio-first → Blooio-after-reply routing on one API key, see [Hybrid mode](/hybrid-mode). You can also do it manually: 1. **Outreach via Twilio** - Send initial SMS broadcasts to generate interest (e.g., "Reply YES to learn more") 2. **Engagement via Blooio** - Direct interested responders to text your Blooio iMessage number for personal follow-up This gives you the best of both worlds: Twilio's broadcast scale for top-of-funnel outreach, and Blooio's intimate iMessage experience for converting warm leads into customers. > **Note** > > Include your Blooio number in Twilio broadcasts: *"Text us at (555) 123-4567 to chat directly!"* - responses come through as iMessage conversations. ## Setup ### Connect your Twilio account In the Blooio dashboard, navigate to **Integrations** and click **Connect** on the Twilio card. You'll need: - **Account SID** - Found on your Twilio Console dashboard - **API Key SID** - Create under Account → API keys & tokens - **API Key Secret** - Generated when you create the API key - **Auth Token** (optional) - Your account auth token, used to verify webhook signatures > **Warning** > > Store your API Key Secret securely - Twilio only shows it once when created. ### Import phone numbers After connecting, select which Twilio phone numbers to import into Blooio. Webhooks are automatically configured for imported numbers. ### Create an API key for Twilio In the dashboard, go to **API Keys** and create a new key. Then assign your imported Twilio numbers to this API key. > **Note** > > An API key can only be assigned to **either** Blooio numbers **or** Twilio numbers, not both. This ensures clear separation between iMessage and SMS messaging. ## Sending messages ### Using the API Send messages the same way as with Blooio, using your API key that has Twilio numbers assigned: **cURL** ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "text": "Hello via Twilio!" }' ``` **Node.js** ```javascript const chatId = encodeURIComponent('+15551234567') await fetch(`https://api.blooio.com/v2/api/chats/${chatId}/messages`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'Hello via Twilio!' }) }) ``` **Python** ```python from urllib.parse import quote chat_id = quote('+15551234567', safe='') requests.post(f'https://api.blooio.com/v2/api/chats/{chat_id}/messages', headers={ 'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}", 'Content-Type': 'application/json' }, json={'text': 'Hello via Twilio!'} ) ``` ### Specifying the sender number The `from_number` parameter is **optional**: - **Omitted**: Blooio auto-selects from your API key's number pool (see below) - **Provided**: Uses the specified number (must be in your API key's number pool) ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "text": "Hello!", "from_number": "+15559876543" }' ``` ## Number pools ### What is a number pool? A **number pool** is the collection of Twilio phone numbers assigned to your API key. When you send a message without specifying `from_number`, Blooio automatically selects a number from this pool. To set up your number pool: 1. Import phone numbers from your Twilio account (in the Integrations page) 2. Assign one or more numbers to your API key (in the Numbers page) > **Note** > > Each API key has its own number pool. Numbers assigned to one API key cannot be used by another. ### How auto-selection works When you omit `from_number` from your request: 1. Blooio looks up all Twilio numbers assigned to your API key 2. The **first number** in the pool is selected 3. The message is sent from that number ```bash # from_number omitted - Blooio uses your API key's first Twilio number curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"text": "Hello!"}' ``` ### Twilio vs Blooio number pools | Feature | Blooio (iMessage) | Twilio | |---------|-------------------|--------| | Number selection | Automatic rotation with conversation history | First available or manual selection | | Pool management | Built-in allocation pools | Assign numbers to API key | | Conversation stickiness | Same number for ongoing conversations | Use `from_number` to maintain consistency | > **Warning** > > There is currently no automatic rotation for Twilio numbers. If you need to distribute traffic across multiple numbers, specify `from_number` explicitly in your application logic. ## Receiving messages Inbound Twilio messages are delivered to your webhooks the same way as iMessage: ```json { "id": "evt_019fd421-b020-7a63-8f8b-152ea9c99333", "type": "message.received", "api_version": "2026-10-01", "created_at": 1706640000000, "organization_id": "org_abc123", "data": { "message_id": "UW4IDpr58wpNgtS7QlLWI", "chat_id": "chat_019fd421-af64-7040-aeae-3e8df24a8e89", "channel_id": "ch_019f959b-ff70-7b1e-9b42-e457bf848fd7", "channel_type": "twilio", "channel_address": "+15559876543", "kind": "received", "direction": "inbound", "status": "received", "protocol": "sms", "message_type": "text", "text": "Incoming SMS", "sender": "+15551234567", "recipient": "+15559876543", "contact": { "contact_id": "ct_019fd421-af64-7040-aeae-3e8df24a8e8a", "identifier": "+15551234567", "name": null }, "group": null, "attachments": [] } } ``` - `sender` is the person who texted you; `recipient` and `channel_address` are the Twilio number that received it - `channel_type` is `twilio` for imported numbers, and `protocol` is `sms` (vs `blooio` / `imessage`) - Subscriptions registered through the older v1/v2 endpoint instead receive a flat body, where the event name is `event` and the sender is `external_id` ## Message status Twilio messages go through these statuses (as reported by Blooio webhooks): | Status | Description | |--------|-------------| | `queued` | Message accepted, pending send | | `sending` | Being transmitted to carrier | | `sent` | Delivered to carrier | | `delivered` | Confirmed delivery to recipient | | `failed` | Delivery failed or carrier rejected the message | > **Note** > > Twilio reports `undelivered` and `failed` as separate statuses. Blooio maps both to `failed` in webhook payloads for simplicity. Status updates are delivered via webhooks — every webhook receives them, so switch on the event type in your handler. ## Limitations ### Compared to Blooio (iMessage) - **No iMessage features**: Reactions, read receipts, typing indicators, and group chats are not available via Twilio - **No automatic number rotation**: You must manage sender number selection yourself - **SMS/MMS only**: Messages are sent as standard SMS or MMS, not iMessage - **Carrier restrictions**: Subject to carrier filtering and rate limits ### Rate limits Twilio enforces its own rate limits. Blooio does not queue or retry Twilio messages - they are sent directly through the Twilio API. ### Webhook validation For production use, provide your Twilio Auth Token when setting up the integration. This enables signature validation for incoming webhooks, preventing spoofed requests. ## Best practices 1. **Use separate API keys**: Keep Twilio and iMessage API keys separate for clear traffic separation 2. **Store from_number for conversations**: If message continuity matters, track which number was used and reuse it 3. **Monitor delivery status**: Register a webhook and handle the message status events to track deliverability 4. **Provide auth token**: Enable webhook signature validation for security 5. **Handle failures gracefully**: Twilio messages fail immediately with no retry - implement your own retry logic if needed ## Troubleshooting ### "No Twilio numbers assigned to this API key" You need to assign at least one Twilio number to your API key before sending. Go to the Numbers page in the dashboard and use the "Assigned To" dropdown. ### "This API key does not own the specified from_number" The `from_number` you specified isn't assigned to your API key. Either remove `from_number` to auto-select, or assign the number to your API key. ### "API key already owns Blooio numbers" An API key can only be assigned to Blooio OR Twilio numbers, not both. Create a separate API key for Twilio. ### Messages not being received Check that: 1. Webhook URLs were automatically configured when you imported the number (you can verify in your Twilio Console under the phone number's settings) 2. You have a webhook registered in Blooio and your handler acts on `message.received` events 3. If you manually changed webhook URLs in Twilio Console, re-import the number in Blooio to reconfigure them ## WhatsApp Business URL: https://docs.blooio.com/guides/whatsapp-business Send and receive WhatsApp messages through the official WhatsApp Business Platform (Cloud API) — templates, the 24-hour customer service window, media, and interactive messages Blooio connects to the official **WhatsApp Business Platform (Cloud API)** so you can message your customers on WhatsApp from the same multi-channel API you already use for iMessage, SMS, and RCS. Onboarding uses Meta's **Embedded Signup**, so your customers connect their own WhatsApp Business Account (WABA) and phone number in a few clicks — nothing to hand-enter. > **Note** > > WhatsApp Business is a **v4** channel (`channel_type: "whatsapp_business"`), served by `api.blooio.com/v4`. It is a separate channel from peer-to-peer WhatsApp (coming later) and shares no data with it. ## Overview The WhatsApp Business integration lets you: - Send **template** messages to start conversations (the only messages allowed outside the 24-hour window). - Send free-form **text**, **media** (image / video / audio / document), and **interactive** (reply buttons, list, CTA URL) messages inside the customer service window. - Receive inbound messages and delivery/read receipts as `message.*` webhook events. - Track the full `sent → delivered → read` lifecycle, mapped from Meta's status webhook. > **Note** > > Blooio relays directly to Meta and does **not** queue, pace, or retry WhatsApp messages. Meta's messaging limits, throughput, and errors are surfaced straight back to you (see [Limitations & rate limits](#limitations-rate-limits)). ## When to use WhatsApp Business | Use case | Why WhatsApp Business | |----------|-----------------------| | International audiences | WhatsApp is the dominant messaging app across most of the world | | Notifications & reminders | Approved **utility** templates deliver order, appointment, and shipping updates | | Marketing opt-ins | Approved **marketing** templates reach opted-in users with rich content | | Two-way support | Free-form replies for 24 hours after each inbound customer message | WhatsApp complements Blooio's iMessage (rich 1:1 in the US) and Twilio SMS (broadcast scale). Choose the channel per audience and message type. ## Onboarding (Embedded Signup) Connect WhatsApp from the Blooio dashboard **Integrations → WhatsApp Business → Connect**. This launches Meta's Embedded Signup, where your customer: 1. Logs in with Facebook and selects (or creates) their **WhatsApp Business Account**. 2. Selects (or adds) the **business phone number** to use. 3. Grants Blooio the `whatsapp_business_management` and `whatsapp_business_messaging` permissions. Blooio then exchanges the returned code for a business access token, subscribes the app to the WABA's webhooks, registers the phone number, and provisions a `whatsapp_business` channel. The WABA id and phone number are pulled dynamically from the customer's Meta account — there is nothing to type in. > **Warning** > > In the WhatsApp Business Platform's **Tech Provider** model, the onboarded business adds their own payment method to their WABA (Meta bills the business directly). Blooio only relays messages on their behalf. Once connected, the channel's `display_address` is the connected phone number, and you send to it just like any other Blooio number. ## Sending Send with the v4 `POST /v4/messages` endpoint. Set `to` to the recipient in **E.164** format and `from` to your connected WhatsApp number (or pass `channel_type: "whatsapp_business"` to let Blooio resolve the channel). ### Text Free-form text is allowed **inside the 24-hour customer service window** (see below). **cURL** ```bash curl -X POST 'https://api.blooio.com/v4/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "to": "+15551234567", "from": "+15559876543", "text": "Thanks for reaching out! How can we help?" }' ``` **Node.js** ```javascript await fetch('https://api.blooio.com/v4/messages', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ to: '+15551234567', from: '+15559876543', text: 'Thanks for reaching out! How can we help?' }) }) ``` **Python** ```python requests.post('https://api.blooio.com/v4/messages', headers={ 'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}", 'Content-Type': 'application/json' }, json={ 'to': '+15551234567', 'from': '+15559876543', 'text': 'Thanks for reaching out! How can we help?' } ) ``` ### Media Attach an image, video, audio file, or document by public HTTPS URL. An optional caption travels in `text` (images, videos, and documents support captions). **cURL** ```bash curl -X POST 'https://api.blooio.com/v4/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "to": "+15551234567", "from": "+15559876543", "text": "Your receipt is attached.", "attachments": [ { "url": "https://example.com/receipt.pdf", "fileName": "receipt.pdf" } ] }' ``` **Node.js** ```javascript await fetch('https://api.blooio.com/v4/messages', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ to: '+15551234567', from: '+15559876543', text: 'Your receipt is attached.', attachments: [ { url: 'https://example.com/receipt.pdf', fileName: 'receipt.pdf' } ] }) }) ``` **Python** ```python requests.post('https://api.blooio.com/v4/messages', headers={ 'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}", 'Content-Type': 'application/json' }, json={ 'to': '+15551234567', 'from': '+15559876543', 'text': 'Your receipt is attached.', 'attachments': [ { 'url': 'https://example.com/receipt.pdf', 'fileName': 'receipt.pdf' } ] } ) ``` Blooio infers the WhatsApp media type from the file extension. To force it, add `"media_type": "image" | "video" | "audio" | "document"` alongside `attachments`. ### Template Templates are the only messages allowed **outside** the 24-hour window. Reference an **approved** template by `name` and `language`, and pass positional body variables in `variables`. **cURL** ```bash curl -X POST 'https://api.blooio.com/v4/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "to": "+15551234567", "from": "+15559876543", "template": { "name": "order_confirmation", "language": "en_US", "variables": ["Ada", "#A1234"] } }' ``` **Node.js** ```javascript await fetch('https://api.blooio.com/v4/messages', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ to: '+15551234567', from: '+15559876543', template: { name: 'order_confirmation', language: 'en_US', variables: ['Ada', '#A1234'] } }) }) ``` **Python** ```python requests.post('https://api.blooio.com/v4/messages', headers={ 'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}", 'Content-Type': 'application/json' }, json={ 'to': '+15551234567', 'from': '+15559876543', 'template': { 'name': 'order_confirmation', 'language': 'en_US', 'variables': ['Ada', '#A1234'] } } ) ``` > **Note** > > Create and sync templates from the dashboard (or `POST /organizations/:id/integrations/whatsapp/:channelId/templates`). Templates must be **approved** by Meta before they can be sent. For advanced templates (header media, buttons), pass a raw Cloud API `components` array instead of `variables`. ### Interactive Inside the window, send reply buttons (up to 3), a list (up to 10 rows total), or a call-to-action URL button. **Reply buttons** ```bash curl -X POST 'https://api.blooio.com/v4/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "to": "+15551234567", "from": "+15559876543", "interactive": { "kind": "button", "body": "Did this resolve your issue?", "buttons": [ { "id": "yes", "title": "Yes, thanks" }, { "id": "no", "title": "Still stuck" } ] } }' ``` **List** ```bash curl -X POST 'https://api.blooio.com/v4/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "to": "+15551234567", "from": "+15559876543", "interactive": { "kind": "list", "body": "Pick a time that works for you", "button": "View times", "sections": [ { "title": "Morning", "rows": [ { "id": "9am", "title": "9:00 AM" }, { "id": "10am", "title": "10:00 AM" } ] } ] } }' ``` **CTA URL** ```bash curl -X POST 'https://api.blooio.com/v4/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "to": "+15551234567", "from": "+15559876543", "interactive": { "kind": "cta_url", "body": "Your order has shipped.", "display_text": "Track package", "url": "https://example.com/track/A1234" } }' ``` ## Customer service window & templates WhatsApp only allows free-form messages within a rolling **24-hour customer service window** that opens (and resets) each time the customer messages you. Outside that window you may send only approved **template** messages. Blooio enforces this locally, before relaying to Meta, so you fail fast with a clear error instead of burning your messaging quota on a rejected send: | Situation | Result | |-----------|--------| | Inside the window | Any content type is allowed | | Outside the window, non-template content | `422 template_required_outside_customer_service_window` | | Template not approved on the WABA | `422 template_not_approved` | > **Note** > > A template send is allowed at any time (in or out of the window), as long as the template is approved. Sending a template re-engages a customer whose window has closed. ## Receiving Inbound WhatsApp messages are delivered to your registered webhooks as `message.received` events, with `channel_type: "whatsapp_business"` and `protocol: "whatsapp_business"`: ```json { "id": "evt_019fd421-b020-7a63-8f8b-152ea9c99333", "type": "message.received", "api_version": "2026-10-01", "created_at": 1706640000000, "organization_id": "org_abc123", "data": { "message_id": "msg_019fd421-af64-7040-aeae-3e8df24a8e89", "chat_id": "chat_019fd421-af64-7040-aeae-3e8df24a8e89", "channel_id": "ch_019f959b-ff70-7b1e-9b42-e457bf848fd7", "channel_type": "whatsapp_business", "kind": "received", "direction": "inbound", "status": "received", "protocol": "whatsapp_business", "message_type": "text", "text": "Hi, is my order on its way?", "sender": "+15551234567", "recipient": "+14155550100", "contact": { "contact_id": "ct_019fd421-af64-7040-aeae-3e8df24a8e8a", "identifier": "+15551234567", "name": null }, "group": null, "channel_address": "+14155550100", "attachments": [] } } ``` WhatsApp-specific fields: - Interactive replies carry the tapped option id in the message metadata (`interactive_reply_id`). - Replies to a specific message carry the quoted message's wamid (`context_wamid`). - Media messages re-host the file to a servable Blooio URL and appear under `attachments`. Each inbound message opens or resets the 24-hour customer service window for that chat. ## Message status & webhook events Outbound WhatsApp messages progress through the standard v4 lifecycle, mapped from Meta's status webhook and delivered as `message.*` events: | Status | Event | Meaning | |--------|-------|---------| | `sent` | `message.sent` | Meta accepted the message (a wamid was issued) | | `delivered` | `message.delivered` | Delivered to the recipient's device | | `read` | `message.read` | The recipient opened the message | | `failed` | `message.failed` | Delivery failed — inspect `error` (see below) | Status is **forward-only**: a late `delivered` never walks back a `read`. Reactions arrive as `message.reaction` events. ## Limitations & rate limits Blooio relays directly to Meta and does not queue or retry. Meta's limits apply as-is: - **Messaging limits (tiers)** — cap the number of *unique* users you can open business-initiated (template) conversations with in a rolling 24 hours: `TIER_250` → `TIER_2K` → `TIER_10K` → `TIER_100K` → `TIER_UNLIMITED`. Set at the business-portfolio level and shared across all numbers in it. In-window replies and user-initiated chats don't count. - **Throughput** — up to 80 messages/second per number by default, auto-upgrading toward 1,000 mps for high-volume, high-quality numbers. Exceeding it returns error `130429` (retriable). - **Pair rate limit** — roughly 1 message every 6 seconds to the same user (error `131056`, retriable). Batch consecutive lines to one user into a single message. - **Content caps** (enforced locally before send): text body ≤ 4,096; media caption ≤ 1,024; reply buttons ≤ 3 (title ≤ 20); list ≤ 10 sections / 10 rows total; hydrated template body ≤ 1,024. - **Media sizes**: image 5 MB, video/audio 16 MB, document 100 MB, sticker 100 KB (static) / 500 KB (animated). > **Warning** > > When Meta rejects a send synchronously, Blooio marks the message `failed` and surfaces the reason. Retriable errors carry `retriable: true` — **your application** decides whether to resend; Blooio never buffers or auto-retries. ## Best practices 1. **Lead with templates, continue free-form.** Open with an approved template, then converse freely within the 24-hour window the customer's reply opens. 2. **Keep templates approved and synced.** Sync templates from the dashboard so `template_not_approved` never surprises you at send time. 3. **Monitor status events.** Handle `message.delivered` / `message.read` / `message.failed` to track deliverability, and watch quality signals in Meta Business Manager. 4. **Respect the window.** Check for `template_required_outside_customer_service_window` and fall back to a template (or another channel) when the window has closed. 5. **Handle failures yourself.** WhatsApp sends fail immediately with no Blooio retry — implement your own retry for `retriable: true` errors. ## Troubleshooting ### `template_required_outside_customer_service_window` (422) The 24-hour window has closed for this chat. Send an approved template to re-engage, or wait for the customer to message you again. ### `template_not_approved` (422) The template `name` / `language` you referenced isn't approved on this WABA. Create or sync the template and wait for Meta's approval (up to 24 hours). ### `130429` — throughput limit (retriable) You're sending faster than the number's messages-per-second limit. Slow down and resend; capacity auto-upgrades for high-quality, high-volume numbers. ### `131047` — re-engagement required Meta's version of the closed-window error, if a template send slips through. Send an approved template to re-open the conversation. ### `131048` / `131049` — quality / per-user marketing cap `131048` is a spam/quality restriction; `131049` means the user hit their cross-business marketing cap — wait at least 24 hours before retrying that user. ### `132005` — hydrated template body too long Your template variables produced a body over 1,024 characters. Shorten the variable values or the template body. ### `100` / `131009` — invalid or malformed content A payload/parameter was rejected. Check the recipient format (E.164), media URL/type, and template variable count. ## Next steps - [Message fields reference (v4)](/guides/message-fields-v4) — the full v4 message object, status, and protocol states - [Webhooks](/guides/webhooks-local) — receive `message.*` events in real time - [Twilio Integration](/guides/twilio-integration) — SMS/MMS via your own Twilio account ## Location tracking (FindMy) URL: https://docs.blooio.com/guides/location-tracking How to track the real-time location of contacts who share their location via Apple FindMy This guide covers using the Location API to access FindMy friend locations available through your blooio account. You can list all contacts sharing their location, fetch a specific contact's location, and trigger a refresh to get the latest coordinates. ## Overview Apple's FindMy app lets users share their real-time location with friends and family. The Location API exposes this data through three endpoints: 1. **List contacts** — Get all contacts currently sharing their location 2. **Get contact** — Get a specific contact's location by phone number or email 3. **Refresh** — Trigger a refresh to fetch updated locations > **Note** > > Location data comes from **Apple's FindMy service**. Contacts only appear here if they have already shared their location through FindMy. This is the same person-sharing data surfaced by Apple's FindMy experience. ## Prerequisites - Location access must be provisioned for your blooio service - Contacts must already be sharing their location through FindMy ## Listing all contact locations Retrieve cached locations for all contacts sharing their location: **cURL** ```bash curl 'https://api.blooio.com/v2/api/location/contacts' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` **Node.js** ```javascript const res = await fetch('https://api.blooio.com/v2/api/location/contacts', { headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}` } }) const { friends } = await res.json() console.log(friends) // [{ handle: "+15551234567", coordinates: [37.7749, -122.4194], status: "live", last_updated: 1709... }] ``` **Python** ```python res = requests.get('https://api.blooio.com/v2/api/location/contacts', headers={'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}"} ) friends = res.json()['friends'] for friend in friends: print(f"{friend['handle']}: {friend['coordinates']}") ``` ## Getting a specific contact's location Look up a single contact by their phone number or email: **cURL** ```bash curl 'https://api.blooio.com/v2/api/location/contacts/%2B15551234567' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` **Node.js** ```javascript const handle = encodeURIComponent('+15551234567') const res = await fetch(`https://api.blooio.com/v2/api/location/contacts/${handle}`, { headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}` } }) const location = await res.json() // { handle: "+15551234567", coordinates: [37.7749, -122.4194], status: "live", last_updated: 1709... } ``` **Python** ```python from urllib.parse import quote handle = quote('+15551234567', safe='') res = requests.get(f'https://api.blooio.com/v2/api/location/contacts/{handle}', headers={'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}"} ) location = res.json() ``` Returns `404` if the contact is not sharing their location or isn't in the cache. ## Refreshing locations Location data is cached. To get the latest coordinates, trigger a refresh: **cURL** ```bash curl -X POST 'https://api.blooio.com/v2/api/location/contacts/refresh' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` **Node.js** ```javascript const res = await fetch('https://api.blooio.com/v2/api/location/contacts/refresh', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}` } }) const { friends } = await res.json() ``` **Python** ```python res = requests.post('https://api.blooio.com/v2/api/location/contacts/refresh', headers={'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}"} ) friends = res.json()['friends'] ``` > **Warning** > > Refreshing locations typically takes **15-20 seconds**. The endpoint returns immediately with the current cache, but the latest location data may take a moment to update. For the freshest data, call refresh, wait ~20 seconds, then list contacts. ## Response fields | Field | Type | Description | |-------|------|-------------| | `handle` | string | Contact's phone number or email address | | `coordinates` | number[] | GPS coordinates as `[latitude, longitude]` | | `status` | string | Location freshness (see below) | | `last_updated` | integer | Timestamp of last location update (epoch milliseconds) | ### Location status values | Status | Meaning | |--------|---------| | `live` | Real-time location sharing is active — coordinates are current | | `shallow` | Cached location from a recent session — may be minutes old | | `legacy` | Older cached data — location may be hours or days stale | ## Common patterns ### Polling for location updates If you need periodic location tracking, call refresh on an interval and list contacts afterward: ```javascript async function trackLocations(apiKey, intervalMs = 60000) { const headers = { 'Authorization': `Bearer ${apiKey}` } const base = 'https://api.blooio.com/v2/api' setInterval(async () => { await fetch(`${base}/location/contacts/refresh`, { method: 'POST', headers }) // Wait for the refresh to complete await new Promise(r => setTimeout(r, 20000)) const res = await fetch(`${base}/location/contacts`, { headers }) const { friends } = await res.json() for (const f of friends) { console.log(`${f.handle}: [${f.coordinates}] (${f.status})`) } }, intervalMs) } ``` > **Note** > > Avoid refreshing more frequently than once per minute. Refreshes consume resources and can take a short time to propagate. For most use cases, refreshing every 5-10 minutes is sufficient. ## FaceTime calls URL: https://docs.blooio.com/guides/facetime How to initiate FaceTime calls programmatically and get shareable links > **Warning** > > **Coming Soon** -- The FaceTime API is temporarily disabled while we stabilize the call flow. The endpoint will return `501` until it is re-enabled. The documentation below describes the intended behavior. This guide covers using the FaceTime API to initiate calls and obtain shareable FaceTime links. ## Overview The FaceTime API lets you programmatically call a contact and receive a shareable `facetime.apple.com` link. When you initiate a call: 1. A FaceTime link is generated 2. The target contact is rung via FaceTime Audio 3. The first person who joins via the link is auto-admitted 4. The call ends after the participant connects The result is a shareable link that can be embedded in a web page, sent in a notification, or used in any workflow. > **Note** > > When this endpoint is re-enabled, you can initiate calls directly through the API without any separate setup flow. ## Initiating a call Call a contact by phone number or email address: **cURL** ```bash curl -X POST 'https://api.blooio.com/v2/api/facetime/calls' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"handle": "+15551234567"}' ``` **Node.js** ```javascript const res = await fetch('https://api.blooio.com/v2/api/facetime/calls', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ handle: '+15551234567' }) }) const { link } = await res.json() console.log(link) // "https://facetime.apple.com/join#v=1&p=..." ``` **Python** ```python res = requests.post('https://api.blooio.com/v2/api/facetime/calls', headers={ 'Authorization': f"Bearer {os.environ['BLOOIO_API_KEY']}", 'Content-Type': 'application/json' }, json={'handle': '+15551234567'} ) link = res.json()['link'] ``` You can also call by email: ```bash curl -X POST 'https://api.blooio.com/v2/api/facetime/calls' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"handle": "john@example.com"}' ``` ## Response fields | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Whether the call was initiated | | `link` | string | Shareable FaceTime link (`https://facetime.apple.com/join#...`) | | `handle` | string | The phone number or email that was called | ## How the call flow works When you call the endpoint, the call flow performs three steps: 1. **Generate link** — Creates a shareable FaceTime link 2. **Ring contact** — Initiates a FaceTime Audio call to the target handle (async, doesn't block the response) 3. **Auto-admit** — Monitors the call's waiting room and automatically admits the first person who joins via the link, then the call ends The link is returned immediately after step 1. Steps 2 and 3 happen in the background. > **Warning** > > The auto-admit process monitors the call for up to **2 minutes**. If no one joins via the link within that window, the call ends automatically. The link itself remains valid for up to 3 hours. ## Error handling | Status | Meaning | |--------|---------| | `200` | Call initiated, link returned | | `400` | Missing or invalid `handle` field | | `401` | Invalid or missing API key | | `404` | No active number is available for this API key | | `502` | FaceTime link generation failed (for example, if FaceTime is unavailable) | ## Common patterns ### Embedding a FaceTime link in a notification ```javascript const { link } = await initiateFaceTimeCall(handle) await sendPushNotification(userId, { title: 'Join the call', body: 'Tap to join the FaceTime call', url: link }) ``` ### Webhook-triggered calls Combine with webhooks to auto-call when a specific event occurs: ```javascript app.post('/webhook', async (req, res) => { const event = req.body if (event.type === 'message.received' && event.data.text === 'CALL ME') { const sender = event.data.from const callRes = await fetch('https://api.blooio.com/v2/api/facetime/calls', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.BLOOIO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ handle: sender }) }) const { link } = await callRes.json() console.log(`Call initiated: ${link}`) } res.sendStatus(200) }) ``` ## Apple Messages for Business URL: https://docs.blooio.com/guides/apple-messages-for-business Connect your brand to Apple Messages for Business and answer customers inside the Messages app through the Blooio v4 API — inbound-first, brand-scoped, and consent-gated. Apple Messages for Business lets customers start a conversation with your brand from across Apple's ecosystem — and lets you answer them through the same Blooio v4 API you already use for iMessage, SMS, RCS, and WhatsApp. It is **inbound-first, brand-scoped, and consent-gated**: a customer always taps first, every message is tied to your verified business, and you only message people who have engaged (or explicitly opted in). This page is the map. It covers what Apple Messages for Business is, how a conversation begins, how to connect a channel to Blooio, how to send your first reply, and where to go for each feature. ## Why Apple Messages for Business - **Native, branded, and verified.** Your logo, name, and brand color sit at the top of the thread with a verified checkmark. Customers know they're talking to the real you, inside the Messages app they already have. - **Rich by default.** Beyond text and media you can send [list pickers, time pickers, dynamic forms, and quick replies](/amb-interactive), take payment with [Apple Pay](/amb-apple-pay), and let customers [sign in](/guides/apple-messages-sign-in) — all without leaving the conversation. - **One API.** Apple Messages for Business is a channel on Blooio's v4 API (`channel_type: "amb"`). The same `POST /v4/messages`, chats, webhooks, idempotency, and error model you use elsewhere apply here. Base URL: `https://api.blooio.com/v4` (staging `https://api-staging.blooio.com/v4`). Authenticate every request with your API key as a bearer token: `Authorization: Bearer bl_live_...`. ## How a conversation starts Apple Messages for Business is **customer-initiated**. A customer reaches you from a Messages entry point you place on your surfaces: | Entry point | Where it appears | | --- | --- | | Messages button | Your website, emails, and in-app | | Apple Maps | Your business's place card in Maps | | Spotlight & Siri | Search results for your brand | | App Store | Your app's product page | | QR codes & deep links | Print, packaging, and signage (an `imessage://` / Business Chat link) | Tapping any of them opens a thread addressed to your business. The one sanctioned way to start a conversation **business-first** is a [business invitation](/amb-invitations) — an Apple-managed template you send to a phone number that has opted in.
*The end-to-end lifecycle: a customer taps an entry point, you receive the inbound over a webhook, and you reply over the same API. Click a step for details.* ## The opaque recipient id When a customer first messages you, Apple mints an **opaque id** for that customer, scoped to your business. It is not a phone number or an email, and you can't derive it — Blooio hands it to you as the sender on the inbound `message.received` [webhook](/webhooks) that opens the conversation. Address every reply to that id (`to`); it stays stable for that customer and your brand, so the conversation always threads correctly. > **Note** > Because Apple Messages for Business is inbound-first, you always have the opaque id before you need to reply. You never cold-message an id you haven't been given — the only exception is an [invitation](/amb-invitations), which is addressed to a phone number. ## Connect a channel to Blooio 1. **Register with Apple.** Create a Messages for Business account in [Apple Business Register](https://register.apple.com) and complete Apple's brand verification. Apple issues your **Business ID**. 2. **Choose Blooio as your Messaging Service Provider.** Select Blooio as your MSP during registration so Apple links your Business ID to Blooio. 3. **Connect in the dashboard.** Open **Integrations → Apple Messages for Business** and link your Business ID. 4. **Assign an alias.** Give the channel a short [alias](/channels-and-senders) such as `support`. You'll use it as `from` on sends (or in the channel-scoped URL). The alias survives swapping the underlying integration, so prefer it over the raw `ch_...` id in your code. Once linked, the channel appears on your [Channels priority list](/channels-and-senders) alongside your numbers, and [`GET /v4/channels`](/reference/v4/channels/listChannels) lists it with `channel_type: "amb"`. ## Send your first reply A customer messages you; Blooio delivers a `message.received` webhook carrying the opaque id. Reply by addressing that id from your Apple Messages for Business alias: ```bash curl -X POST https://api.blooio.com/v4/messages \ -H "Authorization: Bearer bl_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3f1c9d2e-7a9b-4c1e-9f2a-8b6d5e4c3a21" \ -d '{ "from": "support", "to": "opaque-amb-customer-id", "text": "Thanks for reaching out — how can we help?" }' ``` - `from` — your Apple Messages for Business [alias](/channels-and-senders). Omit it and POST to [`/v4/channels/support/messages`](/reference/v4/channels/sendMessageToChannel) instead, where the alias lives in the path. - `to` — the opaque id from the inbound webhook. - `Idempotency-Key` — makes a blind retry safe; see [Idempotency](/idempotency). Already holding the chat? Reply with [`POST /v4/chats/{chatId}/messages`](/reference/v4/messages/sendMessageToChat) and drop `from`/`to` — the chat fixes both the sender and the recipient. A successful call returns a queued message (`id`, `chat_id`, `channel_id`, `status`). Track delivery with [`GET /v4/chats/{chatId}/messages/{messageId}/events`](/reference/v4/messages/listMessageEvents) or a [webhook](/webhooks). ## What you can send | Content | Field | Guide | | --- | --- | --- | | Plain text | `text` | [Rich links & media](/guides/apple-messages-rich-links) | | Images, video, files (≤ 100 MB) | `attachments` | [Rich links & media](/guides/apple-messages-rich-links) | | Rich link card | `rich_link` | [Rich links & media](/guides/apple-messages-rich-links) | | Quick reply, list/time picker, form, custom app | `interactive` | [Interactive messages](/amb-interactive) | | Apple Pay request | `interactive` (`kind: apple_pay`) | [Apple Pay](/amb-apple-pay) | | Sign in (OAuth 2.0) | `interactive` (`kind: authenticate`) | [Sign in](/guides/apple-messages-sign-in) | | Business invitation | `invitation` | [Business invitations](/amb-invitations) | | Typing indicator | `POST /v4/chats/{chatId}/typing` | [Conversation lifecycle](/guides/apple-messages-conversations) | ## Where to go next | Guide | What it covers | | --- | --- | | [Interactive messages](/amb-interactive) | Quick replies, list/time pickers, dynamic forms, custom iMessage apps | | [Rich links & media](/guides/apple-messages-rich-links) | Rich link cards, images, video, and files | | [Sign in (OAuth 2.0)](/guides/apple-messages-sign-in) | Authenticate customers with your identity provider in-thread | | [Business invitations](/amb-invitations) | Start a conversation business-first with an Apple template | | [Apple Pay](/amb-apple-pay) | Take payment inside the conversation | | [Conversation lifecycle](/guides/apple-messages-conversations) | Typing indicators, read receipts, handoff, and closing | | [Monthly active users](/guides/apple-messages-mau) | What counts as a Monthly Active User | | [Capabilities & limits](/amb-capabilities) | OS floors, device gating, timeouts, and TTLs | ## Related - [Channels & senders](/channels-and-senders) - [Send a message](/reference/v4/messages/sendMessage) - [Webhooks](/webhooks) - [Errors](/errors) ## Monthly active users URL: https://docs.blooio.com/guides/apple-messages-mau What a Monthly Active User (MAU) is on Apple Messages for Business — a unique customer who exchanges at least one message with a brand in a calendar month. Pricing is per monthly active user (MAU): **a unique customer who exchanges at least one message with a brand in a calendar month.** One exchange is enough. A customer who sends a single emoji and a customer who talks every day are the same MAU for that month. ## How a customer becomes an MAU A customer can only enter a conversation in two ways. Both are an exchange of at least one message, so both count: 1. **They start the conversation.** They tap a Messages entry point on your website, in Apple Maps, Spotlight, Siri, the App Store, or a QR code, and the thread opens. See [How a conversation starts](/guides/apple-messages-for-business#how-a-conversation-starts). 2. **They accept a business invitation.** You send an [invitation](/amb-invitations) to their phone number and they tap **Yes**. Accepting is the exchange. From then on they are a customer who has exchanged at least one message with your brand. Sending the invitation is not enough. If they ignore it or tap **No**, no conversation opens and they are not an MAU. ## What counts as an exchange Any of the following counts. The customer only has to do **one** of them in the calendar month. **The customer sends or does something** - A text message, of any length - An emoji, including a message that is only an emoji - A reaction or tapback on any message — love, like, dislike, laugh, emphasize, or question — adding one or changing one - A reply to an [interactive message](/amb-interactive): a quick reply, a list picker selection, a time picker selection, or a form submission - Completing [sign-in](/guides/apple-messages-sign-in) - Paying with [Apple Pay](/amb-apple-pay) - A photo, video, voice message, or file - Accepting a [business invitation](/amb-invitations) **Your brand sends something they receive** - A text reply - A [rich link](/guides/apple-messages-rich-links), image, video, or file - An interactive message, an Apple Pay request, or a sign-in request A reply from your brand only exists after the customer has started the conversation or accepted an invitation, so it is the same customer already counted above. It still counts as an exchange on its own: a customer who receives a message in that month is an MAU even if they never type back. **These do not count** - An invitation the customer has not accepted, including one they declined - A typing indicator - A read receipt by itself ## Counted once per calendar month | Rule | What it means | | --- | --- | | One customer, one brand, one month | The same person is one MAU of your brand for that calendar month, whether they exchange once or a thousand times. | | The day does not matter | A message on the 1st and a reaction on the last day of the month are the same MAU. There is no partial month. | | A new month starts a new count | A customer who exchanges on March 31 and again on April 1 is an MAU in March and an MAU in April. | | Identity is the customer, not the device | Apple scopes a customer to your brand with an [opaque id](/guides/apple-messages-for-business#the-opaque-recipient-id). That id — not a phone, a browser, or a session — is the customer you count. Accepting an invitation is what creates it for a conversation you started. | | Brands are separate | The same person talking to two of your brands is an MAU of each brand. | ## Related - [Apple Messages for Business](/guides/apple-messages-for-business) - [Business invitations](/amb-invitations) - [Interactive messages](/amb-interactive) - [Conversation lifecycle](/guides/apple-messages-conversations) ## Rich links & media URL: https://docs.blooio.com/guides/apple-messages-rich-links Send text, rich link cards, images, video, and files in an Apple Messages for Business conversation — the everyday content types that sit alongside interactive messages. Not every message needs to be interactive. Most of a conversation is plain text, the occasional link, and media. This page covers the non-interactive content you can send in an Apple Messages for Business conversation: **text**, **rich link cards**, and **attachments** (images, video, audio, and files). For buttons, pickers, forms, Apple Pay, and sign-in, see [Interactive messages](/amb-interactive). Every example here uses the standard [`POST /v4/messages`](/reference/v4/messages/sendMessage) endpoint, addressed to the customer's opaque id from the inbound [webhook](/webhooks). You can also send through [`POST /v4/channels/{channel}/messages`](/reference/v4/channels/sendMessageToChannel) (alias in the path) or [`POST /v4/chats/{chatId}/messages`](/reference/v4/messages/sendMessageToChat) (sender and recipient fixed by the chat). ## Text Send plain text with the top-level `text` field: ```bash curl -X POST https://api.blooio.com/v4/messages \ -H "Authorization: Bearer bl_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 9a1f0c3b-2d4e-4f6a-8b1c-0d2e4f6a8b1c" \ -d '{ "from": "support", "to": "opaque-amb-customer-id", "text": "Your order #10482 is packed and ships today." }' ``` > **Note** > Markdown styling (`format: "markdown"`) is a Blooio iMessage feature, not an Apple Messages for Business one — Apple Messages for Business carries plain text only. Send `text` as the exact string the customer should see; any Markdown delimiters would arrive as literal characters. ## Rich link card A **rich link** renders as a tappable card in the thread instead of a bare URL. Provide the `rich_link` object with a `url` (required) and an optional `title`: ```bash curl -X POST https://api.blooio.com/v4/messages \ -H "Authorization: Bearer bl_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 1b2c3d4e-5f60-4712-8a9b-0c1d2e3f4a5b" \ -d '{ "from": "support", "to": "opaque-amb-customer-id", "rich_link": { "url": "https://yourbrand.com/order/10482", "title": "Track your order" } }' ``` | Field | Type | What it is | | --- | --- | --- | | `rich_link.url` | string (https) | **Required.** The destination Apple renders the card for and opens on tap. | | `rich_link.title` | string | Optional title line shown on the card. Omit it and Apple falls back to the page's own metadata. | ## Attachments (images, video, audio, files) Pass one or more public HTTPS URLs in `attachments`. Blooio fetches each file, stores a frozen copy, and delivers it — later changes to the source URL can't alter what was sent. Add `text` for a caption. ```bash curl -X POST https://api.blooio.com/v4/messages \ -H "Authorization: Bearer bl_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 7c8d9e0f-1a2b-4c3d-8e4f-5a6b7c8d9e0f" \ -d '{ "from": "support", "to": "opaque-amb-customer-id", "text": "Here is your updated receipt.", "attachments": ["https://cdn.yourbrand.com/receipts/10482.pdf"] }' ``` - **Formats.** Images, video, audio, and documents are supported, up to Apple's **100 MB** per-file limit. A file over that limit is rejected before the send is accepted. - **Use stable URLs.** Point `attachments` at durable CDN URLs. A short-lived signed URL can expire before Blooio fetches it, which fails the send. - **Inbound attachments.** When a customer sends you a file, the decryption window is short and single-use — Blooio fetches and re-hosts it for you and hands you a Blooio-signed URL on the inbound webhook. Re-host it on your own CDN before showing it to an end user. See the attachment TTL ratchet in [Capabilities & limits](/amb-capabilities#the-attachment-ttl-ratchet-apple-msp-cdn). ## Typing indicator Show a typing indicator before a reply so the pause reads as "thinking" rather than a stall. It's a separate call, covered in [Conversation lifecycle](/guides/apple-messages-conversations#typing-indicators): ```bash curl -X POST https://api.blooio.com/v4/chats/chat_.../typing \ -H "Authorization: Bearer bl_live_..." \ -H "Content-Type: application/json" \ -d '{ "state": "started" }' ``` ## Which content type should I use? | You want to… | Use | | --- | --- | | Say something | `text` | | Share a link as a card | `rich_link` | | Send a photo, video, or document | `attachments` | | Offer tappable choices | [`interactive`](/amb-interactive) | | Take a payment | [Apple Pay](/amb-apple-pay) | | Verify who the customer is | [Sign in](/guides/apple-messages-sign-in) | ## Related - [Apple Messages for Business overview](/guides/apple-messages-for-business) - [Interactive messages](/amb-interactive) - [Capabilities & limits](/amb-capabilities) - [Send a message](/reference/v4/messages/sendMessage) ## Sign in (OAuth 2.0) URL: https://docs.blooio.com/guides/apple-messages-sign-in Authenticate a customer inside an Apple Messages for Business conversation with Apple's native Sign in sheet and your OAuth 2.0 identity provider, then exchange the authorization code server-side. Sometimes you need to know **who** you're talking to before you can help — look up an order, change an account setting, or share something private. Apple Messages for Business lets a customer authenticate **inside the thread**: you send a Sign in request, Apple presents a native sign-in sheet backed by *your* OAuth 2.0 identity provider, and you receive an authorization code to exchange server-side. The customer never leaves Messages. This is the `authenticate` variant of an [interactive message](/amb-interactive). It is sent as Apple's New Authentication (interactive data version 2.0). ## Requirements - **OAuth integration configured.** Set up your OAuth 2.0 provider on the channel in the dashboard under **Integrations → Apple Messages for Business → OAuth**. A send fails fast if the integration is missing. - **Device support.** New Authentication (OAuth 2.0) requires **iOS/iPadOS 16 or macOS 13** and advertises the `AUTH2` capability token. Check the device's [`capability-list`](/amb-capabilities#the-capability-list-header) before sending; a device that doesn't advertise `AUTH2` is rejected with `422 amb_capability_unavailable`. - **Your own IdP.** The customer signs in against your authorization server — Blooio does not host an identity provider. ## Send a Sign in request Send an [interactive message](/amb-interactive) with `kind: "authenticate"`. Put the OAuth 2.0 request under `oauth2` (a shorthand for `authenticate.oauth2`); it is forwarded to Apple verbatim, so it uses Apple's field names. ```bash curl -X POST https://api.blooio.com/v4/messages \ -H "Authorization: Bearer bl_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 4d5e6f70-8a9b-4c1d-9e2f-3a4b5c6d7e8f" \ -d '{ "from": "support", "to": "opaque-amb-customer-id", "interactive": { "kind": "authenticate", "received_message": { "title": "Sign in to your account", "subtitle": "Verify it's you to continue", "style": "icon" }, "reply_message": { "title": "Signed in", "style": "icon" }, "oauth2": { "responseType": "code", "scope": ["openid", "profile", "email"], "redirectURI": "https://auth.yourbrand.com/amb/callback", "state": "order-10482-7f3a9c" } } }' ``` ### Fields | Field | Type | What it is | | --- | --- | --- | | `interactive.kind` | string | **Required.** `"authenticate"`. | | `oauth2.responseType` | string | **Required.** The OAuth 2.0 response type — use `"code"` for the authorization-code flow. | | `oauth2.scope` | array of string | **Required.** The scopes to request (e.g. `openid`, `profile`, `email`). | | `oauth2.redirectURI` | string (https) | **Required.** Your registered redirect URI. Must match what your authorization server expects. | | `oauth2.state` | string | Recommended. An opaque value you generate; echoed back so you can correlate the reply to this request and defend against CSRF. | | `received_message` | object | The bubble the customer sees **before** signing in (`title`, `subtitle`, `style`, optional `imageIdentifier`). | | `reply_message` | object | The bubble shown **after** they finish. | > **Note** > You may also nest the request under `authenticate` (`"authenticate": { "oauth2": { … } }`) instead of the flat `oauth2` shorthand — both reach the wire. The request is validated for the minimum Apple needs (`responseType`, `scope`, `redirectURI`) before sending; a malformed request is rejected with `422 invalid_authenticate` naming the missing field, rather than delivering an empty bubble.
*The sign-in round trip: your send, Apple's sheet, the customer authenticating against your IdP, and the authorization code arriving back on an inbound webhook. Click a step for details.* ## What the customer sees 1. A **Sign in** bubble appears with your `received_message` title/subtitle. 2. Tapping it opens Apple's native authentication sheet, which loads your authorization server's sign-in page for the requested `scope`. 3. The customer authenticates. Your server redirects to `redirectURI` with an authorization `code` (and your `state`). 4. The sheet closes and the `reply_message` bubble is shown in the thread. ## Reading the result The outcome arrives as a normal inbound **message** webhook, with a structured `metadata.interactive` object carrying the authorization code and your `state`: ```json { "role": "reply", "kind": "authenticate", "code": "authorization-code-from-your-idp", "state": "order-10482-7f3a9c" } ``` Then, **server-side**, exchange that code for tokens at your own token endpoint — exactly as in any OAuth 2.0 authorization-code flow: ```bash curl -X POST https://auth.yourbrand.com/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=authorization-code-from-your-idp" \ -d "redirect_uri=https://auth.yourbrand.com/amb/callback" \ -d "client_id=YOUR_CLIENT_ID" \ -d "client_secret=YOUR_CLIENT_SECRET" ``` Verify the returned `state` matches the one you sent before trusting the code. The access token your IdP returns identifies the customer — use it to look up their account and continue the conversation with confidence. > **Note** > The code is single-use and short-lived. Exchange it promptly, and never do the exchange from a client — keep your `client_secret` server-side. ## Troubleshooting | Symptom | Cause & fix | | --- | --- | | `422 invalid_authenticate` | The `oauth2` object is missing `responseType`, `scope`, or `redirectURI`. The message names the field. | | `422 amb_capability_unavailable` | The customer's latest device doesn't advertise `AUTH2` (needs iOS 16 / macOS 13+). Fall back to a different flow or a link. | | The sheet opens but sign-in fails | Your authorization server rejected the request — confirm the `redirectURI` is registered and the `scope` values are valid for your client. | | No reply webhook arrives | Confirm your webhook endpoint is reachable and subscribed to message events; see [Receive webhooks locally](/webhooks-local). | ## Related - [Interactive messages](/amb-interactive) - [Capabilities & limits](/amb-capabilities) - [Conversation lifecycle](/guides/apple-messages-conversations) - [Send a message](/reference/v4/messages/sendMessage) - [Webhooks](/webhooks) ## Conversation lifecycle URL: https://docs.blooio.com/guides/apple-messages-conversations How an Apple Messages for Business conversation flows end to end — inbound-first threading, typing indicators, read receipts, bot-to-human handoff, and closing — all through the Blooio v4 chats API. An Apple Messages for Business conversation has a shape: a customer opens it, you (an automation, a human agent, or both) work it, and eventually it closes. This page walks that lifecycle and the chat controls that go with it — **typing indicators**, **read receipts**, **handoff** between automation and humans, and **closing**. Everything here hangs off a **chat**. The inbound `message.received` [webhook](/webhooks) that opens a conversation carries a `chat_id`; use it with the [chats endpoints](/reference/v4/chats/getChat) for the rest of the conversation.
*A conversation handled by automation first, then escalated to a human agent and resolved. Click a step for details.* ## The lifecycle 1. **Open.** The customer taps a Messages entry point (or accepts an [invitation](/amb-invitations)). Apple mints an [opaque id](/guides/apple-messages-for-business#the-opaque-recipient-id) for them and Blooio delivers `message.received` with the `chat_id` and the opaque id as the sender. 2. **Reply.** You answer by addressing the opaque id, or by posting to the chat directly with [`POST /v4/chats/{chatId}/messages`](/reference/v4/messages/sendMessageToChat) (the sender and recipient are fixed by the chat, so omit `from`/`to`). 3. **Work the thread.** Show [typing](#typing-indicators), [mark it read](#read-receipts), send [interactive messages](/amb-interactive), take [payment](/amb-apple-pay), or [hand off](#handoff-automation-human) to a human. 4. **Close.** The conversation ends — resolved by you, or left by the customer. See [Closing & opt-out](#closing-opt-out). The opaque id is **stable** for that customer and your brand, so a returning customer threads back into the same conversation history. ## Typing indicators Show a typing indicator before a reply so a pause reads as "thinking" rather than a stall. Start it with [`POST /v4/chats/{chatId}/typing`](/reference/v4/chats/setTyping): ```bash curl -X POST https://api.blooio.com/v4/chats/chat_.../typing \ -H "Authorization: Bearer bl_live_..." \ -H "Content-Type: application/json" \ -d '{ "state": "started" }' ``` Clear it by sending `{ "state": "stopped" }`, or with [`DELETE /v4/chats/{chatId}/typing`](/reference/v4/chats/stopTyping). You don't have to clear it manually: Apple **auto-expires** a typing indicator **60 seconds** after it starts if you never send a message or a stop. A good rhythm is about one second of typing before each message you send — see the timeouts in [Capabilities & limits](/amb-capabilities#timeouts-ttls). When the customer is typing to you, Blooio delivers `typing.started` / `typing.stopped` [webhook events](/webhooks) so your agent console can show the same. ## Read receipts Mark a conversation read when your agent (or bot) has seen it, with [`POST /v4/chats/{chatId}/read`](/reference/v4/chats/markChatRead): ```bash curl -X POST https://api.blooio.com/v4/chats/chat_.../read \ -H "Authorization: Bearer bl_live_..." ``` ## Handoff: automation ↔ human Most desks run a bot for the first touch and bring in a human when the question needs one. Blooio emits **handoff** [webhook events](/webhooks) so your systems can coordinate who owns the conversation: | Event | Fired when | | --- | --- | | `handoff.created` | A conversation is handed off (for example, a bot escalates to a human queue). | | `handoff.customer_reply` | The customer replies while a handoff is pending. | | `handoff.acknowledged` | An agent (or system) picks up the handoff. | | `handoff.resolved` | The handoff is closed out. | Subscribe to these alongside `message.received` to drive routing: when `handoff.created` fires, surface the thread in your agent console; when a human replies, send through the same chat so the customer sees one continuous conversation. Replies from automation and from humans both go out over [`POST /v4/chats/{chatId}/messages`](/reference/v4/messages/sendMessageToChat) — the channel is the same; only your internal routing changes. > **Note** > A customer can ask your automation to stop. Honor it: once a customer has opted out of automated messages, route the conversation to a human and don't resume bot sends until they re-engage. ## Closing & opt-out A chat is normally `open`. It becomes `closed` when the conversation ends — including when the **customer leaves or reports** the conversation (Apple's CloseSession). Blooio surfaces this as a `chat.closed` [webhook event](/webhooks); for a conversation that began as an [invitation](/amb-invitations), the close carries your original `reference_id`. - **Sending to a closed chat** is rejected with a conflict such as `409 chat_closed` — don't retry; wait for the customer to re-engage. - **Mark a thread closed** from your side with [`PATCH /v4/chats/{chatId}`](/reference/v4/chats/updateChat) when your product considers it resolved. - **Respect opt-out.** If a customer leaves or reports a conversation that started from an invitation, do not invite that number again until they opt back in. See [Business invitations](/amb-invitations). ## Related - [Apple Messages for Business overview](/guides/apple-messages-for-business) - [Interactive messages](/amb-interactive) - [Capabilities & limits](/amb-capabilities) - [Webhooks](/webhooks) - [Get a chat](/reference/v4/chats/getChat) ## Number Pools URL: https://docs.blooio.com/guides/number-pools How sender number selection works for Blooio and Twilio messages A **number pool** is a collection of phone numbers assigned to your API key. When you send a message, Blooio selects a sender number from this pool—either automatically or based on your specification. For Twilio-first outreach that automatically continues on Blooio after a reply, see [Hybrid mode](/hybrid-mode). ## How number pools work Each API key in Blooio has its own number pool. The pool determines which phone numbers can be used as the sender (`from_number`) when sending messages through that API key. ``` ┌─────────────────────────────────────────────┐ │ Your Organization │ ├─────────────────────────────────────────────┤ │ │ │ ┌─────────────────┐ ┌─────────────────┐ │ │ │ API Key A │ │ API Key B │ │ │ │ (iMessage) │ │ (Twilio) │ │ │ ├─────────────────┤ ├─────────────────┤ │ │ │ +1 555-111-0001 │ │ +1 555-222-0001 │ │ │ │ +1 555-111-0002 │ │ +1 555-222-0002 │ │ │ │ +1 555-111-0003 │ │ +1 555-222-0003 │ │ │ └─────────────────┘ └─────────────────┘ │ │ │ └─────────────────────────────────────────────┘ ``` > **Note** > > Each API key can only be assigned **either** Blooio (iMessage) numbers **or** Twilio numbers—never both. This ensures clear separation between messaging protocols. ## Blooio (iMessage) number pools ### Automatic rotation For Blooio API keys, number selection is fully automatic with intelligent rotation: 1. **New conversations**: Blooio selects an available number from your pool 2. **Ongoing conversations**: The same number is used to maintain consistency 3. **Load distribution**: Numbers rotate to distribute traffic evenly ```bash # from_number is optional for Blooio - auto-selected from pool curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_BLOOIO_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"text": "Hello!"}' ``` ### Conversation stickiness Blooio remembers which number was used for each contact. When you message someone again, Blooio automatically uses the same sender number—no tracking required on your end. This creates a natural experience where contacts always see messages from the same number, as if they're texting a consistent person. ### Explicit number selection You can override automatic selection by specifying `from_number`: ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_BLOOIO_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "text": "Hello!", "from_number": "+15551110002" }' ``` > **Warning** > > The specified `from_number` must be in your API key's pool. Attempting to use a number from a different API key results in a `403 Forbidden` error. ### Allocation pools In the Blooio dashboard, you can configure **allocation pools** to control how numbers are distributed: - **Round-robin**: Cycle through numbers evenly - **Weighted**: Assign higher volume to specific numbers - **Geographic**: Match sender area codes to recipient regions These settings are configured per-organization in the dashboard. ## Twilio number pools ### Manual or first-available selection Twilio number pools work differently. When you omit `from_number`: 1. Blooio selects the **first number** in your API key's Twilio pool 2. No automatic rotation or conversation tracking occurs ```bash # from_number omitted - uses first assigned Twilio number curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_TWILIO_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"text": "Hello via SMS!"}' ``` ### No automatic stickiness Unlike Blooio, Twilio pools don't maintain conversation stickiness. If you need the same number for ongoing conversations, track it yourself: ```javascript // Store which Twilio number was used for each contact const fromNumbers = new Map() async function sendTwilioMessage(to, text) { // Check if we've messaged this contact before let fromNumber = fromNumbers.get(to) if (!fromNumber) { // First message - let Blooio auto-select, then store it const res = await sendMessage(to, { text }) fromNumber = res.from_number // Returned in response fromNumbers.set(to, fromNumber) return res } // Subsequent messages - use same number return sendMessage(to, { text, from_number: fromNumber }) } ``` ### Explicit selection recommended For Twilio, explicitly specifying `from_number` is recommended for production: ```bash curl -X POST 'https://api.blooio.com/v2/api/chats/%2B15551234567/messages' \ -H 'Authorization: Bearer YOUR_TWILIO_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "text": "Hello via SMS!", "from_number": "+15552220001" }' ``` ## Inbound numbers (reply-only) Numbers on the **Inbound plan** are reply-only and behave a little differently from a normal Blooio number: - They **cannot start new conversations**. Sending to a contact (or a group) that has never messaged the number returns `403 inbound_only_no_prior_inbound` (see [Errors](/api-reference/errors)). - They **can reply** to any contact that has messaged them at least once. Once an inbound message exists for an `(allocation, recipient)` pair, outbound from that pair is allowed. - Group chats work too. If someone adds your Inbound number to a group and a participant texts the group, your Inbound number can reply in that group. The "recipient" key for groups is the `group_id`, so the reply-only check is `(allocation, group_id)` for groups and `(allocation, contact)` for 1:1. - Multiple inbound numbers can live on the same Mac, distinguished by an internal line slot. From the API's perspective, each inbound number is its own E.164 like any other Blooio number. When an API key has both Inbound and Dedicated/Shared numbers, you can mix: ```javascript // Dedicated number — fine for the first outreach. await sendMessage(contact, { text: "Hi! Just following up.", from_number: "+15551110001" }) // Once they reply, your Inbound AI agent can take over the thread. await sendMessage(contact, { text: "Got it — let me check on that.", from_number: "+15559990001" }) ``` `/me/numbers` returns a `type` field (`"shared" | "dedicated" | "inbound" | "trial" | "2fa"`) so you can route outbound through the right number deterministically. > **Note** > > The reply-only constraint is enforced server-side per `(allocation, recipient)` — where `recipient` is the contact identifier for 1:1 chats and the `group_id` for group chats. There's no "warm up" period — the very first outbound from an Inbound number to a contact (or to a group) succeeds the moment we record an inbound message from that contact / in that group. ## Comparison: Blooio vs Twilio pools | Feature | Blooio (iMessage) | Twilio (SMS) | |---------|-------------------|--------------| | Auto-selection | Intelligent rotation | First available | | Conversation stickiness | Automatic | Manual tracking required | | Pool configuration | Dashboard allocation pools | Assign numbers to API key | | Protocol | iMessage | SMS/MMS | | Multi-protocol support | No (iMessage only) | No (Twilio only per key) | ## Setting up your number pool ### For Blooio numbers 1. Go to **Numbers** in the dashboard 2. Your Blooio-registered numbers appear automatically 3. Create an API key and assign numbers to it 4. Configure allocation preferences in **Settings** ### For Twilio numbers 1. Go to **Integrations** → **Twilio** → **Connect** 2. Enter your Twilio Account SID, API Key SID, and API Key Secret 3. Select which Twilio numbers to import 4. Create a new API key (separate from your Blooio key) 5. Assign imported Twilio numbers to the API key See [Twilio Integration](/guides/twilio-integration) for detailed setup instructions. ## Best practices ### Separate API keys by use case Create distinct API keys for different purposes: ``` ┌─────────────────────────────────────────────┐ │ API Key: "Sales Team" → Pool: 5 numbers │ │ API Key: "Support Team" → Pool: 3 numbers │ │ API Key: "Marketing SMS" → Pool: 10 Twilio │ └─────────────────────────────────────────────┘ ``` ### Don't mix protocols in logic Keep Blooio (iMessage) and Twilio (SMS) flows separate in your application. Different keys, different pools, different behavior. ### Monitor number health Numbers can become flagged by carriers. Monitor delivery rates per number and rotate out underperforming numbers. ### Use geographic matching when possible For Twilio pools, consider matching sender area codes to recipient regions: ```javascript function selectFromNumber(recipientNumber, twilioPool) { const recipientAreaCode = recipientNumber.slice(2, 5) // Find a number with matching area code const match = twilioPool.find(n => n.slice(2, 5) === recipientAreaCode) return match || twilioPool[0] // Fall back to first number } ``` ### Plan for growth If you anticipate high volume, provision more numbers early. Adding numbers to a pool is easy; dealing with deliverability issues from over-used numbers is not. ## Common errors ### "This API key does not own the specified from_number" The `from_number` you specified isn't assigned to your API key's pool. **Fix**: Either remove `from_number` to use auto-selection, or assign the number to your API key in the dashboard. ### "No numbers assigned to this API key" Your API key has an empty pool. **Fix**: Go to the Numbers page and assign at least one number to your API key. ### "API key already owns Blooio numbers" You tried to assign Twilio numbers to an API key that already has Blooio numbers. **Fix**: Create a separate API key for Twilio numbers. Each key can only hold one protocol type.