{"openapi":"3.1.0","info":{"title":"Blooio v4 API","version":"4.0.0-beta","description":"The Blooio v4 API is a multi-channel messaging API for iMessage, SMS, RCS, WhatsApp, and Apple Messages for Business (AMB). It exposes channels, contacts and identities, chats, messages, reactions, polls, groups, webhooks, and a unified event feed.\n\n## Authentication\nAuthenticate with an API key as a bearer token: `Authorization: Bearer bl_live_...`. Create keys in the Blooio dashboard.\n\n## Conventions\n- **IDs** are prefixed and globally unique: channels `ch_`, contacts `ct_`, identities `cid_`, chats `chat_`, messages `msg_`, groups `grp_`, webhooks `wh_`, webhook deliveries `wdel_`, events `evt_`.\n- **List responses** normally return `{ \"data\": [...], \"has_more\": boolean, \"next_cursor\": string | null }` and use cursor pagination (`limit`, `cursor`). The contacts list additionally supports offset pagination for search, filtering, and sorting, returning a `pagination` object when that mode is used.\n- **Item responses** return `{ \"data\": { ... } }`. Message send endpoints return the message object at the top level (not wrapped).\n- **Errors** use `{ \"error\": { \"code\": string, \"message\": string, \"details\": object } }`. Authentication failures at the middleware boundary use a flatter shape `{ \"error\": string, \"message\": string, \"status\": number }`.","contact":{"name":"Blooio Support","email":"help@blooio.com","url":"https://docs.blooio.com"}},"servers":[{"url":"https://api.blooio.com/v4","description":"Production"}],"security":[{"BearerApiKey":[]}],"x-blooio-status":"beta","tags":[{"name":"Account","description":"Authenticated account, sender numbers, and contact cards."},{"name":"Location","description":"Find My friend locations (device passthrough)."},{"name":"Channels","description":"Sending surfaces (iMessage/Blooio, Twilio, WhatsApp, RCS, AMB) and their capabilities."},{"name":"Priorities","description":"Channel routing / number pools: ordered sets of channels walked by priority for the agnostic send."},{"name":"Contacts","description":"Contacts, their channel identities, capabilities, timeline, and tags."},{"name":"Chats","description":"Conversations and chat-level actions (typing, read, contact card, background)."},{"name":"Messages","description":"Send messages and read message history, status, events, and reactions."},{"name":"Polls","description":"Native iMessage polls (Blooio channels)."},{"name":"Groups","description":"Group chats on group-capable channels (Blooio, WhatsApp)."},{"name":"Webhooks","description":"Webhook subscriptions and delivery attempts for inbound + lifecycle events."},{"name":"Events","description":"Unified activity feed across messages, polls, groups, reactions, typing, payments, and messaging-safety state changes."},{"name":"Phone Numbers","description":"Phone number lookup and geocoding (enterprise plans)."},{"name":"API Keys","description":"Programmatic API key management — create keys, list them, deprecate them, and assign them to channels.\n\n**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."},{"name":"AI","description":"Model-backed assessment of conversations. The Bloobability gauge scores, per policy, how confident it is that the policy applies to a conversation, and returns a calibrated probability alongside a frozen accept/defer decision so you can automate on it."}],"paths":{"/me":{"get":{"tags":["Account"],"summary":"Get the authenticated account","description":"Returns the authenticated organization, linked devices/numbers, usage, and API key validity.","operationId":"getMe","responses":{"200":{"description":"Account context","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/me/priority":{"get":{"tags":["Account"],"summary":"Get the key's default priority","description":"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).","operationId":"getMyDefaultPriority","responses":{"200":{"description":"The default priority (or null)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"oneOf":[{"$ref":"#/components/schemas/Priority"},{"type":"null"}]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/me/numbers":{"get":{"tags":["Account"],"summary":"List sender numbers","description":"Lists the phone numbers / sender addresses available to this API key.","operationId":"listNumbers","responses":{"200":{"description":"Sender numbers","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","additionalProperties":true}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/me/numbers/{number}/contact-card":{"get":{"tags":["Account"],"summary":"Get a number's contact card","operationId":"getContactCard","parameters":[{"$ref":"#/components/parameters/NumberParam"}],"responses":{"200":{"description":"Contact card","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemEnvelope"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"put":{"tags":["Account"],"summary":"Update a number's contact card","description":"Updates the contact card (name, avatar, sharing settings) shared from this number. At least one field is required.","operationId":"updateContactCard","parameters":[{"$ref":"#/components/parameters/NumberParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"first_name":{"type":"string"},"last_name":{"type":"string"},"avatar":{"type":"string","description":"Public image URL or data URI."},"sharing":{"type":"object","properties":{"enabled":{"type":"boolean"},"audience":{"type":"integer","enum":[0,1],"description":"0 = Contacts Only, 1 = Always Ask"},"name_format":{"type":"integer","enum":[0,1],"description":"0 = First & Last, 1 = First Only"}}}}}}}},"responses":{"200":{"description":"Updated contact card","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemEnvelope"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/me/numbers/{number}/contact-card/avatar":{"post":{"tags":["Account"],"summary":"Upload a contact card avatar","description":"Uploads an avatar image for the number's contact card. Max 10MB.","operationId":"uploadContactCardAvatar","parameters":[{"$ref":"#/components/parameters/NumberParam"}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"avatar":{"type":"string","format":"binary"},"first_name":{"type":"string","description":"Optional. Sent to the device with the photo so the write is not photo-only. Recommended: without it the device keeps whatever name it currently has."},"last_name":{"type":"string","description":"Optional. See first_name."}},"required":["avatar"]}}}},"responses":{"200":{"description":"Avatar uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemEnvelope"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/me/numbers/{number}/call-forwarding":{"post":{"tags":["Account"],"summary":"Request call forwarding","description":"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.","operationId":"requestCallForwarding","parameters":[{"$ref":"#/components/parameters/NumberParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"forward_to":{"type":"string","pattern":"^\\+1[2-9]\\d{9}$","description":"US E.164 number to forward calls to."}},"required":["forward_to"]}}}},"responses":{"200":{"description":"Call forwarding requested","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemEnvelope"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/location/contacts":{"get":{"tags":["Location"],"summary":"List Find My contacts","description":"Lists Find My friends sharing their location with the linked Apple account.","operationId":"listLocationContacts","responses":{"200":{"description":"Find My contacts","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","additionalProperties":true}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/location/contacts/refresh":{"get":{"tags":["Location"],"summary":"Refresh Find My locations (GET)","description":"Convenience GET form of the refresh endpoint. Forces a refresh of Find My location data from the device.","operationId":"refreshLocationContactsGet","responses":{"200":{"description":"Refresh result","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemEnvelope"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}},"post":{"tags":["Location"],"summary":"Refresh Find My locations","description":"Forces a refresh of Find My location data from the device.","operationId":"refreshLocationContacts","responses":{"200":{"description":"Refresh result","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemEnvelope"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/location/contacts/{handle}":{"get":{"tags":["Location"],"summary":"Get one Find My contact","operationId":"getLocationContact","parameters":[{"name":"handle","in":"path","required":true,"schema":{"type":"string"},"description":"Contact handle to resolve — a phone number in E.164 format, an email address, or an Apple ID. URL-encode the value."}],"responses":{"200":{"description":"Find My contact","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemEnvelope"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/channels":{"get":{"tags":["Channels"],"summary":"List channels","description":"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.","operationId":"listChannels","parameters":[{"name":"type","in":"query","schema":{"$ref":"#/components/schemas/ChannelType"},"description":"Filter by channel type."},{"name":"status","in":"query","schema":{"$ref":"#/components/schemas/ChannelStatus"},"description":"Filter by channel status."},{"$ref":"#/components/parameters/LimitParam"},{"$ref":"#/components/parameters/CursorParam"}],"responses":{"200":{"description":"A page of channels","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Channel"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}},"example":{"data":[{"id":"ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b","type":"blooio","address":"+15551234567","status":"active","capabilities":{"protocols":["imessage","sms","rcs"],"content":["text","media"]},"created_at":1718000000000}],"has_more":false,"next_cursor":null}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/channels/blooio/available":{"get":{"tags":["Channels"],"summary":"List available Blooio numbers","description":"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.","operationId":"listAvailableBlooioNumbers","parameters":[{"name":"type","in":"query","schema":{"type":"string","enum":["shared","dedicated","inbound"],"default":"shared"},"description":"Plan family to browse."},{"name":"area_code","in":"query","schema":{"type":"string"},"description":"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`."},{"name":"country","in":"query","schema":{"type":"string"},"description":"Filter inventory by ISO country code (e.g. `US`)."},{"$ref":"#/components/parameters/LimitParam"}],"responses":{"200":{"description":"Available inventory or an area-code quote","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","description":"Inventory rows (browse mode) or quote lines (when `area_code` is supplied).","items":{"oneOf":[{"type":"object","description":"Inventory row (browse mode). The full number is never returned — only a masked form.","properties":{"masked_national":{"type":["string","null"],"description":"National format with only the area code revealed, e.g. `(801) ***-****`."},"area_code":{"type":["string","null"]},"country_code":{"type":["string","null"],"description":"E.164 country calling code, e.g. `1`."},"phone_number_country":{"type":["string","null"],"description":"ISO country code, e.g. `US`."},"location":{"type":"string","description":"Geocoded `City, ST` label for NANPA numbers, when resolvable. Omitted otherwise."}}},{"type":"object","description":"Quote line (when `area_code` is supplied).","properties":{"area_code":{"type":"string"},"matched":{"type":"boolean","description":"`true` when the code is in stock (provisioned free)."},"custom_order":{"type":"boolean","description":"Present and `true` when the code needs a custom order (custom area-code fee applies)."},"auto_assigned":{"type":"boolean","description":"Present when no specific code was requested for this line and one is auto-assigned."},"zip_code":{"type":"string"}}}]}},"matched_count":{"type":"integer","description":"Quote mode only: requested area codes found in stock."},"custom_order_count":{"type":"integer","description":"Quote mode only: requested area codes needing a custom (ticketed, billed) order."},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}},"examples":{"browse":{"summary":"Browse shared inventory","value":{"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}},"quote":{"summary":"Area-code quote","value":{"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}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/channels/blooio/purchases":{"post":{"tags":["Channels"],"summary":"Purchase Blooio numbers","description":"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`.\n\n**Idempotency is required** — send a unique `Idempotency-Key` header; replaying the same key returns the same purchase rather than charging twice.\n\nRules 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.","operationId":"purchaseBlooioNumbers","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string"},"description":"Unique key that makes the billable purchase safe to retry."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["plan"],"properties":{"plan":{"type":"string","enum":["shared_nc","shared_com","dedicated_com","dedicated_ent","inbound_basic"],"description":"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":{"type":"integer","minimum":1,"default":1,"description":"Number of new lines (dedicated/inbound). Shared is always 1."},"area_codes":{"type":"array","items":{"type":"string"},"description":"Preferred 3-digit US area codes (dedicated/inbound). Out-of-stock codes incur a custom fee and open a ticket."},"zip_codes":{"type":"array","items":{"type":"string"},"description":"Preferred ZIP codes (dedicated/inbound), used when an area code is not specified."}}},"example":{"plan":"dedicated_com","quantity":2,"area_codes":["415","628"]}}}},"responses":{"202":{"description":"Purchase accepted; provisioning proceeds asynchronously.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"purchase_id":{"type":"string"},"status":{"type":"string","enum":["provisioning","action_required"]},"action_url":{"type":["string","null"],"description":"Present when `status` is `action_required`: complete card authentication here."}}}}},"example":{"data":{"purchase_id":"idem_abc123","status":"provisioning"}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"Payment required or failed. `code` is `no_payment_method` (no saved card) or `payment_failed` (the card was declined).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"Conflicts with a purchase rule, e.g. `shared_number_limit`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/channels/blooio/purchases/{purchaseId}":{"get":{"tags":["Channels"],"summary":"Get purchase status","description":"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.","operationId":"getBlooioPurchase","parameters":[{"name":"purchaseId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Purchase status","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"purchase_id":{"type":"string"},"status":{"type":"string"},"allocations":{"type":"array","items":{"type":"object"}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/channels/{channel}":{"get":{"tags":["Channels"],"summary":"Get a channel","operationId":"getChannel","parameters":[{"$ref":"#/components/parameters/ChannelIdParam"}],"responses":{"200":{"description":"A channel","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Channel"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"tags":["Channels"],"summary":"Remove (unsubscribe) a Blooio number","description":"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.","operationId":"removeBlooioNumber","parameters":[{"$ref":"#/components/parameters/ChannelIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["reasons"],"properties":{"reasons":{"type":"array","items":{"type":"string"},"description":"One or more churn reason codes/labels for the removal."}}},"example":{"reasons":["too_expensive","no_longer_needed"]}}}},"responses":{"200":{"description":"The line was removed.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"phone_number":{"type":"string"},"channel_id":{"type":["string","null"]},"reasons":{"type":"array","items":{"type":"string"}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/channels/{channel}/capabilities":{"get":{"tags":["Channels"],"summary":"Get channel capabilities","description":"Returns supported protocols, content types, actions, and interactive features for a channel.","operationId":"getChannelCapabilities","parameters":[{"$ref":"#/components/parameters/ChannelIdParam"}],"responses":{"200":{"description":"Channel capabilities","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"channel_id":{"type":"string"},"type":{"$ref":"#/components/schemas/ChannelType"},"capabilities":{"$ref":"#/components/schemas/ChannelCapabilities"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/channels/{channel}/settings":{"get":{"tags":["Channels"],"summary":"Get channel settings","description":"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`.","operationId":"getChannelSettings","parameters":[{"$ref":"#/components/parameters/ChannelIdParam"}],"responses":{"200":{"description":"Channel settings","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ChannelSettings"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}},"patch":{"tags":["Channels"],"summary":"Update channel settings","description":"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`.","operationId":"updateChannelSettings","parameters":[{"$ref":"#/components/parameters/ChannelIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChannelSettingsUpdate"},"examples":{"autoMarkRead":{"summary":"Blooio: enable auto mark-read","value":{"auto_mark_read":true}},"autoShareContact":{"summary":"Blooio: enable auto share contact (dedicated + contact card)","value":{"auto_share_contact":true}}}}}},"responses":{"200":{"description":"Updated channel settings","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ChannelSettings"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/channels/{channel}/profile":{"put":{"tags":["Channels"],"summary":"Update channel profile","description":"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.","operationId":"updateChannelProfile","parameters":[{"$ref":"#/components/parameters/ChannelIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChannelProfile"},"example":{"display_name":"Blooio Support","about":"We reply within minutes.","email":"support@blooio.com"}}}},"responses":{"200":{"description":"Updated channel, including `profile`","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Channel"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/channels/{channel}/messages":{"post":{"tags":["Channels"],"summary":"Send a message on a specific channel","description":"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).","operationId":"sendMessageToChannel","parameters":[{"$ref":"#/components/parameters/ChannelIdParam"},{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendToChannelRequest"},"examples":{"text":{"summary":"Simple text","value":{"to":"+15551234567","text":"Hello from Blooio!"}}}}}},"responses":{"200":{"description":"Dry-run preview","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendResult"}}}},"201":{"description":"Message accepted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendResult"}}}},"207":{"description":"Multi-recipient fan-out with mixed results.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FanOutResult"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/SafetyBlocked"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"429":{"$ref":"#/components/responses/ConversationLimited"},"501":{"$ref":"#/components/responses/NotImplemented"}}}},"/priorities":{"get":{"tags":["Priorities"],"summary":"List priorities","description":"Lists channel priorities owned by the calling API key.","operationId":"listPriorities","parameters":[{"$ref":"#/components/parameters/LimitParam"},{"$ref":"#/components/parameters/CursorParam"}],"responses":{"200":{"description":"A page of priorities","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Priority"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}},"post":{"tags":["Priorities"],"summary":"Create a priority","description":"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.","operationId":"createPriority","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePriorityRequest"}}}},"responses":{"201":{"description":"Created priority","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Priority"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/priorities/{priorityId}":{"get":{"tags":["Priorities"],"summary":"Get a priority","operationId":"getPriority","parameters":[{"$ref":"#/components/parameters/PriorityIdParam"}],"responses":{"200":{"description":"A priority","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Priority"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"patch":{"tags":["Priorities"],"summary":"Update a priority","description":"Updates a priority's name, default flag, and/or channel set. When `channels` is supplied it replaces the priority's channels wholesale.","operationId":"updatePriority","parameters":[{"$ref":"#/components/parameters/PriorityIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePriorityRequest"}}}},"responses":{"200":{"description":"Updated priority","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Priority"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}},"delete":{"tags":["Priorities"],"summary":"Delete a priority","operationId":"deletePriority","parameters":[{"$ref":"#/components/parameters/PriorityIdParam"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/contacts":{"get":{"tags":["Contacts"],"summary":"List contacts","description":"Lists contacts. Cursor pagination is the default. Supplying `offset`, `q`, `tag`, `sort`, or `filter` switches to offset pagination and includes `pagination.total`.","operationId":"listContacts","parameters":[{"name":"identifier","in":"query","schema":{"type":"string"},"description":"Filter to the contact owning this phone/email."},{"name":"q","in":"query","schema":{"type":"string"},"description":"Case-insensitive search across contact names, identities, and tags."},{"name":"tag","in":"query","schema":{"type":"string"},"description":"Case-insensitive tag filter."},{"name":"sort","in":"query","schema":{"type":"string","enum":["recent","oldest","name_asc","name_desc"]}},{"name":"filter","in":"query","schema":{"type":"string","enum":["none","has_tags","no_tags","inbound_last","outbound_last"]}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0},"description":"Zero-based offset. Cannot be combined with cursor."},{"$ref":"#/components/parameters/LimitParam"},{"$ref":"#/components/parameters/CursorParam"}],"responses":{"200":{"description":"A page of contacts","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Contact"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]},"pagination":{"type":"object","properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}},"post":{"tags":["Contacts"],"summary":"Create a contact","description":"Creates a contact, optionally with one identity (phone/email on a channel type).","operationId":"createContact","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateContactRequest"}}}},"responses":{"201":{"description":"Created contact","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Contact"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/contacts/{contactId}":{"get":{"tags":["Contacts"],"summary":"Get a contact","operationId":"getContact","parameters":[{"$ref":"#/components/parameters/ContactIdParam"}],"responses":{"200":{"description":"A contact","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Contact"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"patch":{"tags":["Contacts"],"summary":"Update a contact","operationId":"updateContact","parameters":[{"$ref":"#/components/parameters/ContactIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}},"responses":{"200":{"description":"Updated contact","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Contact"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}},"delete":{"tags":["Contacts"],"summary":"Delete a contact","description":"Soft-deletes a contact and its identities.","operationId":"deleteContact","parameters":[{"$ref":"#/components/parameters/ContactIdParam"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/contacts/{contactId}/merge":{"post":{"tags":["Contacts"],"summary":"Merge a contact","description":"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.","operationId":"mergeContact","parameters":[{"$ref":"#/components/parameters/ContactIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["source_contact_id"],"properties":{"source_contact_id":{"type":"string","description":"The contact to merge into the survivor, prefixed with `ct_`. Must differ from the path contact.","example":"ct_01HZ0000000000000000000000"}}}}}},"responses":{"200":{"description":"The merged survivor contact, now including the re-parented identities.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Contact"}}}}}},"400":{"description":"The source equals the survivor, or an id is malformed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"message":{"type":"string"},"status":{"type":"integer"},"code":{"type":"string"}}},"example":{"error":"ApiError","message":"Cannot merge a contact into itself","status":400,"code":"cannot_merge_self"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The source contact has already been merged into a different contact.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"message":{"type":"string"},"status":{"type":"integer"},"code":{"type":"string"}}},"example":{"error":"ApiError","message":"Contact has already been merged","status":409,"code":"contact_already_merged"}}}},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/contacts/{contactId}/identities":{"get":{"tags":["Contacts"],"summary":"List contact identities","operationId":"listContactIdentities","parameters":[{"$ref":"#/components/parameters/ContactIdParam"}],"responses":{"200":{"description":"Identities","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ContactIdentity"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"post":{"tags":["Contacts"],"summary":"Attach an identity","description":"Attaches a phone/email identity (on a channel type) to a contact.","operationId":"attachContactIdentity","parameters":[{"$ref":"#/components/parameters/ContactIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachIdentityRequest"}}}},"responses":{"201":{"description":"Attached identity","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ContactIdentity"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/contacts/{contactId}/identities/{identityId}":{"patch":{"tags":["Contacts"],"summary":"Update an identity","description":"Renames a contact identity in place. An optional name updates the parent contact in the same request.","operationId":"updateContactIdentity","parameters":[{"$ref":"#/components/parameters/ContactIdParam"},{"name":"identityId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the contact identity, prefixed with `cid_`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["identifier"],"properties":{"identifier":{"type":"string"},"name":{"type":["string","null"]}}}}}},"responses":{"200":{"description":"Updated identity","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ContactIdentity"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}},"delete":{"tags":["Contacts"],"summary":"Detach an identity","operationId":"detachContactIdentity","parameters":[{"$ref":"#/components/parameters/ContactIdParam"},{"name":"identityId","in":"path","required":true,"schema":{"type":"string"},"description":"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":{"description":"Detached","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"detached":{"type":"boolean"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/contacts/{contactId}/capabilities":{"get":{"tags":["Contacts"],"summary":"Get contact capabilities","description":"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.","operationId":"getContactCapabilities","parameters":[{"$ref":"#/components/parameters/ContactIdParam"}],"responses":{"200":{"description":"Capabilities per identity","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"identifier":{"type":"string"},"channel_id":{"type":["string","null"]},"capabilities":{"type":"object","additionalProperties":true}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"No active iMessage (blooio) channel is provisioned for this API key, so live iMessage/SMS/RCS detection is unavailable.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"message":{"type":"string"},"status":{"type":"integer"},"code":{"type":"string"}}},"example":{"error":"ApiError","message":"No active iMessage (blooio) channel is provisioned for this API key. Live per-number iMessage/SMS/RCS detection requires an active iMessage channel; provision one or contact support to enable it.","status":409,"code":"no_active_blooio_channel"}}}}}}},"/contacts/{contactId}/timeline":{"get":{"tags":["Contacts"],"summary":"Get contact timeline","description":"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.","operationId":"getContactTimeline","parameters":[{"$ref":"#/components/parameters/ContactIdParam"},{"$ref":"#/components/parameters/LimitParam"},{"$ref":"#/components/parameters/CursorParam"},{"name":"include_events","in":"query","required":false,"schema":{"type":"boolean","default":false},"description":"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":{"description":"Timeline page","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TimelineItem"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/contacts/{contactId}/tags":{"get":{"tags":["Contacts"],"summary":"List contact tags","operationId":"listContactTags","parameters":[{"$ref":"#/components/parameters/ContactIdParam"}],"responses":{"200":{"description":"Tags","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"string"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"post":{"tags":["Contacts"],"summary":"Add contact tags","operationId":"addContactTags","parameters":[{"$ref":"#/components/parameters/ContactIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}},"tag":{"type":"string"}}}}}},"responses":{"200":{"description":"Merged tags","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"string"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/contacts/{contactId}/tags/{tag}":{"delete":{"tags":["Contacts"],"summary":"Remove a contact tag","operationId":"removeContactTag","parameters":[{"$ref":"#/components/parameters/ContactIdParam"},{"name":"tag","in":"path","required":true,"schema":{"type":"string"},"description":"The tag to remove from the contact. URL-encode values that contain spaces or special characters."}],"responses":{"200":{"description":"Remaining tags","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"string"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/groups":{"get":{"tags":["Groups"],"summary":"List groups","operationId":"listGroups","parameters":[{"$ref":"#/components/parameters/LimitParam"},{"$ref":"#/components/parameters/CursorParam"}],"responses":{"200":{"description":"A page of groups","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Group"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}},"post":{"tags":["Groups"],"summary":"Create a group","description":"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.\n\nOn 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.","operationId":"createGroup","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGroupRequest"}}}},"responses":{"201":{"description":"Created group","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Group"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/groups/{groupId}":{"get":{"tags":["Groups"],"summary":"Get a group","description":"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.","operationId":"getGroup","parameters":[{"$ref":"#/components/parameters/GroupIdParam"}],"responses":{"200":{"description":"A group","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Group"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"patch":{"tags":["Groups"],"summary":"Update a group","operationId":"updateGroup","parameters":[{"$ref":"#/components/parameters/GroupIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}},"responses":{"200":{"description":"Updated group","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Group"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}},"delete":{"tags":["Groups"],"summary":"Delete a group","operationId":"deleteGroup","parameters":[{"$ref":"#/components/parameters/GroupIdParam"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/groups/{groupId}/members":{"get":{"tags":["Groups"],"summary":"List group members","description":"For a merged group id, lists the members of the surviving group.","operationId":"listGroupMembers","parameters":[{"$ref":"#/components/parameters/GroupIdParam"}],"responses":{"200":{"description":"Members","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/GroupMember"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/groups/{groupId}/icon":{"post":{"tags":["Groups"],"summary":"Set group icon","description":"Uploads a group icon. Max 10MB; image/jpeg|png|gif|webp|heic|heif. Requires the group chat to be linked on a device.","operationId":"setGroupIcon","parameters":[{"$ref":"#/components/parameters/GroupIdParam"}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"icon":{"type":"string","format":"binary"}},"required":["icon"]}}}},"responses":{"200":{"description":"Icon set","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"icon_url":{"type":["string","null"]}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"}}},"delete":{"tags":["Groups"],"summary":"Remove group icon","operationId":"removeGroupIcon","parameters":[{"$ref":"#/components/parameters/GroupIdParam"}],"responses":{"200":{"description":"Icon removed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"removed":{"type":"boolean"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"}}}},"/chats":{"get":{"tags":["Chats"],"summary":"List chats","description":"Lists conversations, most recent first. Filter by channel type, state, channel, or contact.","operationId":"listChats","parameters":[{"name":"type","in":"query","schema":{"$ref":"#/components/schemas/ChannelType"}},{"name":"state","in":"query","schema":{"$ref":"#/components/schemas/ChatState"}},{"name":"channel_id","in":"query","schema":{"type":"string"}},{"name":"contact_id","in":"query","schema":{"type":"string"}},{"$ref":"#/components/parameters/LimitParam"},{"$ref":"#/components/parameters/CursorParam"}],"responses":{"200":{"description":"A page of chats","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Chat"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}},"post":{"tags":["Chats"],"summary":"Find or create a chat","description":"Finds or creates a 1:1 chat with a recipient on a channel. Returns the chat (with `created` indicating whether it was newly created).","operationId":"createChat","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateChatRequest"}}}},"responses":{"200":{"description":"Existing chat","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChatCreated"}}}},"201":{"description":"Created chat","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChatCreated"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/chats/{chatId}":{"get":{"tags":["Chats"],"summary":"Get a chat","description":"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.","operationId":"getChat","parameters":[{"$ref":"#/components/parameters/ChatIdParam"}],"responses":{"200":{"description":"A chat","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Chat"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"patch":{"tags":["Chats"],"summary":"Update a chat","description":"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 AMB conversation, and no other protocol has an equivalent.","operationId":"updateChat","parameters":[{"$ref":"#/components/parameters/ChatIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"state":{"type":"string","enum":["open","closed"],"description":"Apple Messages for Business chats only. `closed` blocks further sends to the chat with 409 `chat_closed_by_user`; an AMB chat also closes itself when the customer ends the conversation, and reopens when they message again."}}}}}},"responses":{"200":{"description":"Updated chat","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Chat"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/chats/{chatId}/typing":{"post":{"tags":["Chats"],"summary":"Start/stop typing indicator","description":"Shows or clears a business-to-customer typing indicator. Supported on Blooio channels and Apple Messages for Business (AMB).","operationId":"setTyping","parameters":[{"$ref":"#/components/parameters/ChatIdParam"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"state":{"type":"string","enum":["started","stopped"],"default":"started"}}}}}},"responses":{"200":{"description":"Typing state applied","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"chat_id":{"type":"string"},"state":{"type":"string"},"at":{"type":"integer"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}},"delete":{"tags":["Chats"],"summary":"Stop typing indicator","description":"Clears the business-to-customer typing indicator. Supported on Blooio channels and Apple Messages for Business (AMB).","operationId":"stopTyping","parameters":[{"$ref":"#/components/parameters/ChatIdParam"}],"responses":{"200":{"description":"Typing stopped","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"chat_id":{"type":"string"},"state":{"type":"string"},"at":{"type":"integer"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"}}}},"/chats/{chatId}/read":{"post":{"tags":["Chats"],"summary":"Mark chat as read","description":"Sends a read receipt. Blooio channels only.","operationId":"markChatRead","parameters":[{"$ref":"#/components/parameters/ChatIdParam"}],"responses":{"200":{"description":"Marked read","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"chat_id":{"type":"string"},"read":{"type":"boolean"},"at":{"type":"integer"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"}}}},"/chats/{chatId}/contact-card":{"post":{"tags":["Chats"],"summary":"Share contact card","description":"Shares the sender's contact card into a chat. Blooio channels only.","operationId":"shareContactCard","parameters":[{"$ref":"#/components/parameters/ChatIdParam"}],"responses":{"200":{"description":"Shared","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"chat_id":{"type":"string"},"shared":{"type":"boolean"},"at":{"type":"integer"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"}}}},"/chats/{chatId}/polls":{"post":{"tags":["Polls"],"summary":"Send a poll","description":"Sends a native iMessage poll to a chat. Requires a title and 2+ options. Blooio channels only.","operationId":"sendPoll","parameters":[{"$ref":"#/components/parameters/ChatIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendPollRequest"}}}},"responses":{"201":{"description":"Poll queued","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Poll"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/chats/{chatId}/polls/{pollId}/vote":{"post":{"tags":["Polls"],"summary":"Vote on a poll","description":"Casts or toggles a vote by `option_index` (0-based) or `option` text. Blooio channels only.","operationId":"votePoll","parameters":[{"$ref":"#/components/parameters/ChatIdParam"},{"name":"pollId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the poll, prefixed with `msg_` (a poll shares the id of its underlying message)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VotePollRequest"}}}},"responses":{"200":{"description":"All votes toggled off","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PollVoteResult"}}}}}},"201":{"description":"Vote recorded","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PollVoteResult"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/chats/{chatId}/polls/{pollId}":{"get":{"tags":["Polls"],"summary":"Get poll results","operationId":"getPollResults","parameters":[{"$ref":"#/components/parameters/ChatIdParam"},{"name":"pollId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the poll, prefixed with `msg_` (a poll shares the id of its underlying message)."}],"responses":{"200":{"description":"Poll results","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PollResults"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/chats/{chatId}/background":{"get":{"tags":["Chats"],"summary":"Get chat background","description":"Reads the chat's background image (Blooio channels). Reads live from the device.","operationId":"getChatBackground","parameters":[{"$ref":"#/components/parameters/ChatIdParam"}],"responses":{"200":{"description":"Background info","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"chat_id":{"type":"string"},"has_background":{"type":"boolean"},"background_id":{"type":["string","null"]},"background_version":{"type":["integer","null"]},"background_url":{"type":["string","null"]}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"put":{"tags":["Chats"],"summary":"Set chat background","description":"Uploads a chat background image. Max 10MB; image/jpeg|png|gif|webp|heic|heif. Blooio channels only.","operationId":"setChatBackground","parameters":[{"$ref":"#/components/parameters/ChatIdParam"}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"background":{"type":"string","format":"binary"}},"required":["background"]}}}},"responses":{"200":{"description":"Background set","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"chat_id":{"type":"string"},"background_id":{"type":"string"},"background_url":{"type":["string","null"]}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"tags":["Chats"],"summary":"Remove chat background","operationId":"removeChatBackground","parameters":[{"$ref":"#/components/parameters/ChatIdParam"}],"responses":{"200":{"description":"Background removed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"chat_id":{"type":"string"},"removed":{"type":"boolean"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/chats/{chatId}/messages":{"get":{"tags":["Messages"],"summary":"List chat messages","description":"Lists messages in a chat, newest first by default. Use `order=asc` for oldest first.","operationId":"listChatMessages","parameters":[{"$ref":"#/components/parameters/ChatIdParam"},{"name":"order","in":"query","schema":{"type":"string","enum":["asc","desc"],"default":"desc"}},{"$ref":"#/components/parameters/LimitParam"},{"$ref":"#/components/parameters/CursorParam"}],"responses":{"200":{"description":"A page of messages","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Message"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"post":{"tags":["Messages"],"summary":"Send a message to a chat","description":"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`).","operationId":"sendMessageToChat","parameters":[{"$ref":"#/components/parameters/ChatIdParam"},{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendToChatRequest"}}}},"responses":{"200":{"description":"Dry-run preview","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendResult"}}}},"201":{"description":"Message accepted. The message object is returned with its current `status`; a synchronously rejected send returns `status: \"failed\"` with an `error` object (still 201, since the message was created).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendResult"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/SafetyBlocked"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"429":{"$ref":"#/components/responses/ConversationLimited"},"501":{"$ref":"#/components/responses/NotImplemented"}}}},"/messages":{"post":{"tags":["Messages"],"summary":"Send a message","description":"The primary endpoint for starting a message. The common send is entirely scalar: `{ \"from\": \"+15551230001\", \"to\": \"+15551234567\", \"text\": \"Hello\" }`.\n\n`from` is one optional string:\n\n1. **Phone number** — `\"from\": \"+15551230001\"` selects your exact numbered channel with that address.\n2. **Alias** — `\"from\": \"support\"` selects the non-numbered channel (AMB/RCS Business) with that organization-wide alias.\n3. **Channel id** — `\"from\": \"ch_...\"` is the advanced exact-channel form.\n4. **Automatic** — omit `from` to use the key's default priority / automatic routing.\n\nAdvanced 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.\n\nPriority routing is history-first and content-aware. Poll content currently routes only through Blooio. Cross-type AMB routing requires a contact with an AMB 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.","operationId":"sendMessage","parameters":[{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"},"examples":{"routed_text":{"summary":"Simple routed text","value":{"to":"+15551234567","text":"Hello from Blooio!"}},"exact_number":{"summary":"Send from an exact number","value":{"from":"+18582849901","to":"+15551234567","text":"Hello!"}},"alias":{"summary":"Send from an aliased sender","value":{"from":"support","to":"opaque-customer-id","text":"How can we help?"}},"media_with_caption":{"summary":"Media with a caption","value":{"from":"+18582849901","to":"+15551234567","text":"Check this out","attachments":["https://example.com/photo.jpg"]}},"blooio_type":{"summary":"Constrain routing to Blooio","value":{"channel_type":"blooio","to":"+15551234567","text":"Hello from Blooio!"}},"poll":{"summary":"Route a Blooio poll","value":{"channel_type":"blooio","to":"+15551234567","poll":{"title":"Choose one","options":["A","B"]}}},"dry_run":{"summary":"Validate/route only","value":{"to":"+15551234567","text":"Test","dry_run":true}}}}}},"responses":{"200":{"description":"Dry-run preview","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendResult"}}}},"201":{"description":"Message accepted. The message object is returned with its current `status`; a synchronously rejected send returns `status: \"failed\"` with an `error` object (still 201, since the message was created).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendResult"}}}},"207":{"description":"Multi-recipient fan-out with mixed results (some recipients sent, some failed). See per-recipient items in `data`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FanOutResult"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/SafetyBlocked"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"description":"Validation failed, or a multi-recipient fan-out in which every recipient failed (in which case the body is a `FanOutResult` rather than an error envelope).","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/FanOutResult"}]}}}},"429":{"$ref":"#/components/responses/ConversationLimited"},"501":{"$ref":"#/components/responses/NotImplemented"}}}},"/chats/{chatId}/messages/{messageId}":{"get":{"tags":["Messages"],"summary":"Get a message","operationId":"getChatMessage","parameters":[{"$ref":"#/components/parameters/ChatIdParam"},{"$ref":"#/components/parameters/MessageIdParam"}],"responses":{"200":{"description":"A message","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Message"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/chats/{chatId}/messages/{messageId}/status":{"get":{"tags":["Messages"],"summary":"Get message status","description":"Lightweight delivery status of a message.","operationId":"getMessageStatus","parameters":[{"$ref":"#/components/parameters/ChatIdParam"},{"$ref":"#/components/parameters/MessageIdParam"}],"responses":{"200":{"description":"Message status","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MessageStatus"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/chats/{chatId}/messages/{messageId}/events":{"get":{"tags":["Messages"],"summary":"List message events","description":"Lifecycle events for a message (sent, delivered, read, reactions, ...).","operationId":"listMessageEvents","parameters":[{"$ref":"#/components/parameters/ChatIdParam"},{"$ref":"#/components/parameters/MessageIdParam"},{"$ref":"#/components/parameters/LimitParam"},{"$ref":"#/components/parameters/CursorParam"}],"responses":{"200":{"description":"Message events","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MessageEvent"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/chats/{chatId}/messages/{messageId}/reactions":{"post":{"tags":["Messages"],"summary":"React to a message","description":"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.","operationId":"addReaction","parameters":[{"$ref":"#/components/parameters/ChatIdParam"},{"$ref":"#/components/parameters/MessageIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"reaction":{"type":"string","description":"e.g. '+love' to add, '-love' to remove.","examples":["+love","+like","+laugh","+emphasize","+question","+dislike"]}},"required":["reaction"]}}}},"responses":{"200":{"description":"Reaction applied","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"message_id":{"type":"string"},"reaction":{"type":"string"},"at":{"type":"integer"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/webhooks":{"get":{"tags":["Webhooks"],"summary":"List webhooks","operationId":"listWebhooks","parameters":[{"$ref":"#/components/parameters/LimitParam"},{"$ref":"#/components/parameters/CursorParam"}],"responses":{"200":{"description":"A page of webhooks","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}},"post":{"tags":["Webhooks"],"summary":"Create a webhook","description":"Subscribes an HTTPS endpoint to events. Returns the signing secret once, on creation.","operationId":"createWebhook","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookRequest"}}}},"responses":{"201":{"description":"Created webhook (includes signing_secret once)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WebhookWithSecret"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/webhooks/versions":{"get":{"tags":["Webhooks"],"summary":"List webhook payload versions","description":"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.","operationId":"listWebhookVersions","responses":{"200":{"description":"Supported payload versions, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookApiVersion"}}}},"example":{"data":[{"version":"2026-10-01","latest":true,"released_at":"2026-10-01","summary":"Removes fields left over from v2 payloads and chat_guid, and reports message errors by their public code.","changes":["Removed event, timestamp, external_id, internal_id, participants and legacy_reaction_id from typing.*, message.reaction, contact.shared and poll.voted. Use the envelope's type, created_at and id, and data.contact, data.group and data.channel_address.","Removed the flat group_id and group_name wherever data carries a group object (group.* events keep group_id). Read group.group_id and group.name.","Removed chat_guid. Use chat_id and group.group_id to tell conversations apart.","error.code on message events is one of the public error codes (for example service_unavailable or delivery_unconfirmed), the same code the REST API returns. 2026-09-01 keeps the previous codes; error.message is the public description on both."]},{"version":"2026-09-01","latest":false,"released_at":"2026-09-01","summary":"Baseline: the payload every subscription received before versioning.","changes":[]}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/webhooks/{webhookId}":{"get":{"tags":["Webhooks"],"summary":"Get a webhook","operationId":"getWebhook","parameters":[{"$ref":"#/components/parameters/WebhookIdParam"}],"responses":{"200":{"description":"A webhook","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Webhook"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"patch":{"tags":["Webhooks"],"summary":"Update a webhook","description":"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.","operationId":"updateWebhook","parameters":[{"$ref":"#/components/parameters/WebhookIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebhookRequest"}}}},"responses":{"200":{"description":"Updated webhook","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Webhook"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}},"delete":{"tags":["Webhooks"],"summary":"Delete a webhook","operationId":"deleteWebhook","parameters":[{"$ref":"#/components/parameters/WebhookIdParam"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/webhooks/{webhookId}/secret/rotate":{"post":{"tags":["Webhooks"],"summary":"Rotate webhook signing secret","description":"Rotates and returns a new signing secret (once).","operationId":"rotateWebhookSecret","parameters":[{"$ref":"#/components/parameters/WebhookIdParam"}],"responses":{"200":{"description":"New secret","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"signing_secret":{"type":"string"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/webhooks/{webhookId}/deliveries":{"get":{"tags":["Webhooks"],"summary":"List webhook deliveries","description":"Recent delivery attempts for a webhook. Also available at `/webhooks/{webhookId}/logs`.","operationId":"listWebhookDeliveries","parameters":[{"$ref":"#/components/parameters/WebhookIdParam"},{"$ref":"#/components/parameters/LimitParam"},{"$ref":"#/components/parameters/CursorParam"}],"responses":{"200":{"description":"A page of deliveries","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/webhooks/{webhookId}/deliveries/{deliveryId}/replay":{"post":{"tags":["Webhooks"],"summary":"Replay a webhook delivery","description":"Re-sends a past delivery. Also available at `/webhooks/{webhookId}/logs/{deliveryId}/replay`.","operationId":"replayWebhookDelivery","parameters":[{"$ref":"#/components/parameters/WebhookIdParam"},{"name":"deliveryId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the webhook delivery attempt, prefixed with `wdel_`. Returned by the List webhook deliveries endpoint."}],"responses":{"201":{"description":"Replay queued","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"replay_of":{"type":"string"},"status":{"type":"string"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/events":{"get":{"tags":["Events"],"summary":"List events","description":"Unified activity feed (message lifecycle, polls, groups, reactions, typing, payments, `safety.state_changed`, `safety.number_banned`). Filter by type, chat, or message.","operationId":"listEvents","parameters":[{"name":"type","in":"query","schema":{"type":"string"},"description":"Event type or wildcard, e.g. `message.received` or `message.*`."},{"name":"chat_id","in":"query","schema":{"type":"string"}},{"name":"message_id","in":"query","schema":{"type":"string"}},{"$ref":"#/components/parameters/LimitParam"},{"$ref":"#/components/parameters/CursorParam"}],"responses":{"200":{"description":"A page of events","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Event"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/events/{eventId}":{"get":{"tags":["Events"],"summary":"Get an event","operationId":"getEvent","parameters":[{"name":"eventId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the event, prefixed with `evt_`. Returned by the List events endpoint."}],"responses":{"200":{"description":"An event","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Event"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/phone-numbers/lookup":{"get":{"tags":["Phone Numbers"],"summary":"Look up a phone number","description":"Returns parsed info / geocoding for a phone number. Requires an enterprise plan.","operationId":"lookupPhoneNumber","parameters":[{"name":"number","in":"query","required":true,"schema":{"type":"string"},"description":"Phone number in E.164."}],"responses":{"200":{"description":"Lookup result","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemEnvelope"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}},"post":{"tags":["Phone Numbers"],"summary":"Look up a phone number (POST)","description":"Same as the GET form, accepting the number in a JSON body. Returns parsed info / geocoding for a phone number. Requires an enterprise plan.","operationId":"lookupPhoneNumberPost","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"number":{"type":"string","description":"Phone number in E.164."}},"required":["number"]}}}},"responses":{"200":{"description":"Lookup result","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemEnvelope"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/phone-numbers/batch":{"post":{"tags":["Phone Numbers"],"summary":"Batch phone number lookup","description":"Looks up up to 100 phone numbers. Requires an enterprise plan.","operationId":"batchLookupPhoneNumbers","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"numbers":{"type":"array","items":{"type":"string"},"maxItems":100}},"required":["numbers"]}}}},"responses":{"200":{"description":"Lookup results","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","additionalProperties":true}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/chats/{chatId}/participants":{"get":{"tags":["Chats"],"summary":"List chat participants","operationId":"listChatParticipants","x-blooio-status":"planned","parameters":[{"$ref":"#/components/parameters/ChatIdParam"}],"responses":{"501":{"$ref":"#/components/responses/NotImplemented"}}},"post":{"tags":["Chats"],"summary":"Add a chat participant","operationId":"addChatParticipant","x-blooio-status":"planned","parameters":[{"$ref":"#/components/parameters/ChatIdParam"}],"responses":{"501":{"$ref":"#/components/responses/NotImplemented"}}}},"/chats/{chatId}/participants/{identityId}":{"delete":{"tags":["Chats"],"summary":"Remove a chat participant","operationId":"removeChatParticipant","x-blooio-status":"planned","parameters":[{"$ref":"#/components/parameters/ChatIdParam"},{"name":"identityId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the contact identity, prefixed with `cid_`. Returned by the List contact identities endpoint."}],"responses":{"501":{"$ref":"#/components/responses/NotImplemented"}}}},"/attachments":{"post":{"tags":["Messages"],"summary":"Register an attachment","operationId":"createAttachment","x-blooio-status":"planned","responses":{"501":{"$ref":"#/components/responses/NotImplemented"}}}},"/attachments/{attachmentId}":{"get":{"tags":["Messages"],"summary":"Get an attachment","operationId":"getAttachment","x-blooio-status":"planned","parameters":[{"name":"attachmentId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the attachment, as referenced on a message's content."}],"responses":{"501":{"$ref":"#/components/responses/NotImplemented"}}}},"/templates":{"get":{"tags":["Messages"],"summary":"List templates","operationId":"listTemplates","x-blooio-status":"planned","responses":{"501":{"$ref":"#/components/responses/NotImplemented"}}},"post":{"tags":["Messages"],"summary":"Create a template","operationId":"createTemplate","x-blooio-status":"planned","responses":{"501":{"$ref":"#/components/responses/NotImplemented"}}}},"/templates/{templateId}":{"get":{"tags":["Messages"],"summary":"Get a template","operationId":"getTemplate","x-blooio-status":"planned","parameters":[{"name":"templateId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the message template. Returned when you create a template or by the List templates endpoint."}],"responses":{"501":{"$ref":"#/components/responses/NotImplemented"}}},"delete":{"tags":["Messages"],"summary":"Delete a template","operationId":"deleteTemplate","x-blooio-status":"planned","parameters":[{"name":"templateId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the message template. Returned when you create a template or by the List templates endpoint."}],"responses":{"501":{"$ref":"#/components/responses/NotImplemented"}}}},"/api-keys":{"get":{"tags":["API Keys"],"summary":"List API keys","description":"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.\n\n**Blooio Apps (OAuth) only.** Requires the `apikeys:read` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`.","operationId":"listApiKeys","responses":{"200":{"description":"The organization's API keys.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}},"post":{"tags":["API Keys"],"summary":"Create an API key","description":"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.\n\nA 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`.\n\n**Blooio Apps (OAuth) only.** Requires the `apikeys:manage` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`.","operationId":"createApiKey","requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyRequest"}}}},"responses":{"201":{"description":"The created API key. The `api_key` value is shown only in this response.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKey"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"$ref":"#/components/responses/Conflict"}}}},"/api-keys/{apiKey}":{"get":{"tags":["API Keys"],"summary":"Get an API key","description":"Fetches a single API key's metadata by its value.\n\n**Blooio Apps (OAuth) only.** Requires the `apikeys:read` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`.","operationId":"getApiKey","parameters":[{"$ref":"#/components/parameters/ApiKeyParam"}],"responses":{"200":{"description":"The API key metadata.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKey"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"tags":["API Keys"],"summary":"Deprecate an API key","description":"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.\n\n**Blooio Apps (OAuth) only.** Requires the `apikeys:manage` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`.","operationId":"deprecateApiKey","parameters":[{"$ref":"#/components/parameters/ApiKeyParam"}],"responses":{"200":{"description":"The key was deprecated.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"api_key":{"type":"string"},"deprecated":{"type":"boolean"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api-keys/{apiKey}/channels":{"get":{"tags":["API Keys"],"summary":"List an API key's channels","description":"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.\n\n**Blooio Apps (OAuth) only.** Requires the `apikeys:read` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`.","operationId":"listApiKeyChannels","parameters":[{"$ref":"#/components/parameters/ApiKeyParam"}],"responses":{"200":{"description":"The lines owned by this key.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string","description":"The owned line in E.164, e.g. `+15551234567`. Usable as the `{channel}` reference on the assign/unassign endpoints and as `from` when sending."},"type":{"type":"string","description":"Provider of the line (`blooio` or `twilio`)."},"banned":{"type":"boolean"}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api-keys/{apiKey}/channels/{channel}":{"put":{"tags":["API Keys"],"summary":"Assign a channel to an API key","description":"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.\n\nThe 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`.\n\nOnly 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`).\n\n**Blooio Apps (OAuth) only.** Requires the `apikeys:manage` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`.","operationId":"assignChannelToApiKey","parameters":[{"$ref":"#/components/parameters/ApiKeyParam"},{"$ref":"#/components/parameters/ChannelIdParam"}],"responses":{"200":{"description":"The channel is now owned by the key.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKeyChannelAssignment"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}},"delete":{"tags":["API Keys"],"summary":"Unassign a channel from an API key","description":"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.\n\n**Blooio Apps (OAuth) only.** Requires the `apikeys:manage` scope. API-key and dashboard authentication are rejected with `403 oauth_token_required`.","operationId":"unassignChannelFromApiKey","parameters":[{"$ref":"#/components/parameters/ApiKeyParam"},{"$ref":"#/components/parameters/ChannelIdParam"}],"responses":{"200":{"description":"The channel is no longer owned by the key.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKeyChannelAssignment"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/ai/bloobability/assess":{"post":{"tags":["AI"],"summary":"Assess a conversation against policies","description":"Answers one question, per policy: **how confident is the model that this policy applies to this conversation?** The number you want is `policies[].calibrated_probability`.\n\n## Two passes, only the gauge is required\n- The **gauge** scores **every policy you send, independently** (a yes/no plus a decision margin read from the served logprobs), and a frozen two-tier calibration map turns that margin into `calibrated_probability`, `verdict`, `calibration_tier`, and `accepted`. This is the whole product and the common, fastest path.\n- The **matcher** is a separate, optional pass. Set `include_evidence: true` and it runs **in parallel** with the gauge and attaches `evidence_ids` (the supporting message IDs). It adds roughly no wall-clock latency but is a second full inference, so leave it off unless you need provenance.\n\nThe call is **stateless**: nothing is persisted, `reference_id` is not an idempotency key, and replaying the same request re-runs inference. Policies come back in the **same order you sent them** — `policies[i]` in the response corresponds to `policies[i]` in the request.\n\n## Limitations and correct use\n- **Domain.** Tuned for customer-support conversations in English. Other languages, other conversation genres, and non-conversational text are unmeasured.\n- **Estimating a rate.** To measure how often a policy applies across many conversations, **average `calibrated_probability` over the cohort** — do not count `yes` verdicts. The model deliberately favors precision over recall, so verdict counts undercount the true rate; the calibrated probabilities do not.\n- **Tracking a trend.** Reported movement is a conservative floor — magnitude is compressed, so the *direction* of change is more reliable than its *size*. Compare periods using the **same policy `id` and `description`** throughout; changing a definition (or refitting per period) mid-series can invert the trend.\n- **Unseen policies (`pooled_unseen`).** Probabilities are usable for your own policies, but there is no validated `accepted` decision and trend fidelity is unmeasured — keep a human in the loop.\n- **Automation vs. review.** `accepted` is intentionally strict; plan for a meaningful share of decisions (roughly a third to two fifths) to fall outside it, and route those to human review — see `evidence_ids`.\n- **Labels are model-derived.** Scores reflect a model's judgment of your policy description, not human-verified ground truth. See the [Conversation insights guide](https://docs.blooio.com/guides/conversation-insights) for the full method.","operationId":"assessBloobability","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BloobabilityAssessRequest"},"example":{"reference_id":"order-42","conversation":[{"id":"m1","role":"customer","content":"Any chance of a refund?"},{"id":"m2","role":"agent","content":"Let me check your order."}],"policies":[{"id":"refund_request","description":"The customer asks for money back."}],"include_evidence":false}}}},"responses":{"200":{"description":"Per-policy assessment, in the same order as the request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BloobabilityAssessResponse"},"example":{"reference_id":"order-42","policies":[{"id":"refund_request","calibrated_probability":0.977,"verdict":"yes","calibration_tier":"roster","accepted":true}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"502":{"description":"The gauge endpoint returned a response whose yes/no logprobs could not be read, so no calibrated number can be produced. This indicates a serving misconfiguration rather than a problem with your request; retry, and contact support if it persists.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"securitySchemes":{"BearerApiKey":{"type":"http","scheme":"bearer","description":"Blooio API key as a bearer token, e.g. `Authorization: Bearer bl_live_...`."}},"parameters":{"LimitParam":{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Maximum number of items to return per page. Must be between 1 and 200; defaults to 50. Combine with `cursor` to paginate."},"CursorParam":{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque pagination cursor. Pass the `next_cursor` value from a previous response to fetch the next page; omit it on the first request."},"IdempotencyKeyHeader":{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string"},"description":"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."},"ChannelIdParam":{"name":"channel","in":"path","required":true,"schema":{"type":"string"},"description":"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_...`)."},"ContactIdParam":{"name":"contactId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the contact, prefixed with `ct_`. Returned when you create a contact or by the List contacts endpoint."},"ChatIdParam":{"name":"chatId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the chat, prefixed with `chat_`. Returned by the List chats endpoint."},"PriorityIdParam":{"name":"priorityId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the priority, prefixed with `priority_`. Returned when you create a priority or by the List priorities endpoint."},"MessageIdParam":{"name":"messageId","in":"path","required":true,"schema":{"type":"string"},"description":"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."},"GroupIdParam":{"name":"groupId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the group, prefixed with `grp_`. Returned by the List groups endpoint."},"WebhookIdParam":{"name":"webhookId","in":"path","required":true,"schema":{"type":"string"},"description":"Unique identifier of the webhook subscription, prefixed with `wh_`. Returned when you create a webhook or by the List webhooks endpoint."},"NumberParam":{"name":"number","in":"path","required":true,"schema":{"type":"string"},"description":"Sender phone number in E.164 format, e.g. `+15551234567`."},"ApiKeyParam":{"name":"apiKey","in":"path","required":true,"schema":{"type":"string"},"description":"The API key value (prefixed with `api_`), as returned when the key was created or by the List API keys endpoint."}},"responses":{"Unauthorized":{"description":"Authentication failed or missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}},"Forbidden":{"description":"Authenticated but not allowed (e.g. blocked key or plan gate).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"}}}},"BadRequest":{"description":"Malformed request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"Resource not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Conflict":{"description":"Conflicting state (e.g. chat closed, idempotency reuse, channel not active).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"UnprocessableEntity":{"description":"Validation failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotImplemented":{"description":"Endpoint is defined but not implemented yet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_implemented","message":"This v4 endpoint is defined but not implemented yet.","details":{"resource":"templates.list"}}}}}},"SafetyBlocked":{"description":"Blocked by messaging safety. The error `code` identifies the gate: `safety_account_review` (the sending number is blocked pending review), `safety_reply_only` (outbound is restricted to conversations with prior inbound), `safety_new_conversations_paused` (brand-new conversations are temporarily paused; replies are unaffected), or `conversation_content_restricted` (links, media, and attachments are not allowed before the recipient's first written reply, or in a re-engagement of an inactive conversation — an emoji reaction does not unlock them). Safety actions clear automatically as the underlying pattern ages out; current per-number state is delivered via the `safety.state_changed` webhook event.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"conversation_content_restricted","message":"Links, media, and attachments are not allowed until the recipient replies. Send plain text first."}}}}},"ConversationLimited":{"description":"Conversation-state limit reached. The error `code` identifies the rule: `conversation_awaiting_reply` (up to 3 messages may be sent before the recipient responds at all), `conversation_streak_limit` (consecutive-message cap since their last response), or `conversation_inactive_paused` (no response in 14+ days and the single re-engagement message was already sent). Every response body carries `conversation_state` — one of `new`, `acknowledged` (reacted but never wrote back), `active`, `established`, `trusted`, or `inactive` — and the streak errors also carry `current_streak` and `limit`, so you can see which rung of the ladder a thread is on.\n\nThe cap rises with the thread's written history, and an **emoji reaction counts as a response**: a tapback clears the consecutive-message counter and keeps a thread from going inactive, so the limit lifts the moment the recipient reacts. A reaction is not treated as a reply, though — only writing back raises the cap and unlocks links, media, and attachments (see `conversation_content_restricted`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"conversation_awaiting_reply","message":"This conversation is awaiting a reply. Up to 3 messages may be sent before the recipient responds."}}}}}},"schemas":{"Error":{"type":"object","description":"Standard v4 error envelope returned by route handlers.","properties":{"error":{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable error code."},"message":{"type":"string"},"details":{"type":"object","additionalProperties":true}},"required":["code","message"]}},"required":["error"]},"AuthError":{"type":"object","description":"Flat error shape returned by the authentication middleware (401/403).","properties":{"error":{"type":"string"},"message":{"type":"string"},"status":{"type":"integer"},"code":{"type":"string"}}},"ItemEnvelope":{"type":"object","description":"Generic single-item envelope.","properties":{"data":{"type":"object","additionalProperties":true}}},"ChannelType":{"type":"string","enum":["blooio","twilio","whatsapp","whatsapp_business","rcs_business","amb"]},"ChannelStatus":{"type":"string","enum":["pending_verification","active","suspended","banned","deprecated"]},"ChatState":{"type":"string","enum":["open","closed","blocked","deprecated"]},"Direction":{"type":"string","enum":["inbound","outbound"]},"ChannelCapabilities":{"type":"object","description":"What a channel can do. Shape varies by channel type.","properties":{"protocols":{"type":"array","items":{"type":"string"}},"content":{"type":"array","items":{"type":"string"}},"actions":{"type":"array","items":{"type":"string"}},"interactive":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"Channel":{"type":"object","properties":{"id":{"type":"string","description":"Channel id (ch_...)."},"type":{"$ref":"#/components/schemas/ChannelType"},"address":{"type":["string","null"],"description":"The channel's real address: the phone number for numbered channels, or the provider address for non-numbered ones. Usable directly as `from`."},"alias":{"type":"string","description":"Movable, organization-wide name for a non-numbered channel (currently AMB/RCS Business). Usable directly as `from`. Omitted for numbered channels."},"status":{"$ref":"#/components/schemas/ChannelStatus"},"capabilities":{"$ref":"#/components/schemas/ChannelCapabilities"},"profile":{"$ref":"#/components/schemas/ChannelProfile"},"settings":{"type":"object","additionalProperties":true,"description":"Inline per-channel settings for channel types that support them (currently Blooio numbers). Each setting value plus a `can_<key>` flag for whether this channel may enable it. Omitted for channel types without settings. Full read/write via `GET`/`PATCH /channels/{channel}/settings`.","example":{"auto_mark_read":false,"auto_share_contact":false,"can_auto_mark_read":true,"can_auto_share_contact":true}},"created_at":{"type":"integer","description":"Epoch milliseconds."}}},"ChannelSettings":{"type":"object","description":"Per-channel settings envelope. `settings` and `available` shapes vary by channel type; the Blooio (P2P numbers) provider exposes `auto_mark_read` and `auto_share_contact`. `available[key]` is whether THIS channel may turn `key` on (type-gated).","properties":{"channel_id":{"type":"string","description":"Channel id (ch_...)."},"type":{"$ref":"#/components/schemas/ChannelType"},"settings":{"type":"object","additionalProperties":true,"description":"Current setting values for this channel (keys are per channel type).","example":{"auto_mark_read":false,"auto_share_contact":false}},"available":{"type":"object","additionalProperties":{"type":"boolean"},"description":"Whether this channel may turn each setting on (type-gated).","example":{"auto_mark_read":true,"auto_share_contact":true}}}},"ChannelSettingsUpdate":{"type":"object","additionalProperties":true,"description":"Partial update of a channel's settings; keys are per channel type and validated by the channel's provider. Blooio (P2P numbers) accepts `auto_mark_read` and `auto_share_contact` and rejects any other key (422 invalid_settings).","properties":{"auto_mark_read":{"type":"boolean","description":"Blooio: mark a chat read on the device as soon as a message arrives. Dedicated + inbound lines only."},"auto_share_contact":{"type":"boolean","description":"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."}}},"ChannelProfile":{"type":"object","additionalProperties":false,"description":"Stored channel profile. Omitted on GET when no fields are set. Null or empty string on PUT clears a field.","properties":{"display_name":{"type":["string","null"]},"about":{"type":["string","null"]},"email":{"type":["string","null"]},"avatar":{"type":["string","null"]}}},"ContactIdentity":{"type":"object","properties":{"id":{"type":"string","description":"Identity id (cid_...)."},"contact_id":{"type":"string"},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"identifier":{"type":"string","description":"Phone (E.164) or email."},"channel_id":{"type":["string","null"],"description":"Required for AMB identities."},"created_at":{"type":"integer"}}},"Contact":{"type":"object","properties":{"id":{"type":"string","description":"Contact id (ct_...)."},"name":{"type":["string","null"]},"created_at":{"type":"integer"},"tags":{"type":"array","items":{"type":"string"}},"last_message_time":{"type":["integer","null"]},"last_direction":{"type":["string","null"],"enum":["inbound","outbound",null]},"last_text":{"type":["string","null"]},"last_status":{"type":["string","null"]},"identities":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"identifier":{"type":"string"},"channel_id":{"type":["string","null"]},"created_at":{"type":"integer"}}}}}},"TimelineItem":{"type":"object","description":"A single polymorphic entry in a contact's cross-channel timeline. The `type` discriminates the shape of `object`.","properties":{"type":{"type":"string","enum":["message","message_event","chat_event"],"description":"`message`: a message the contact sent or received. `message_event`: a lifecycle event for a message (sent/delivered/read/failed/reaction). `chat_event`: any other chat/group/typing event. Event entries only appear when `include_events=true`."},"occurred_at":{"type":"integer","description":"Epoch-millisecond timestamp the entry occurred at; the feed is ordered by this descending."},"id":{"type":"string","description":"The underlying object id — a `msg_` message id for `message` entries, an `evt_` event id otherwise. Also the pagination sort key within a given `occurred_at`."},"chat_id":{"type":["string","null"]},"channel_id":{"type":["string","null"]},"channel_type":{"type":["string","null"],"description":"The channel type this entry occurred on. Always set for `message` entries; may be null for channel-agnostic events."},"object":{"type":"object","additionalProperties":true,"description":"The full entry payload. For `message` entries: id, chat_id, channel_id, channel_type, direction, type, text, status, created_at. For event entries: id, type, chat_id, channel_id, channel_type, data, occurred_at."}}},"Chat":{"type":"object","properties":{"id":{"type":"string","description":"Chat id (chat_...)."},"channel_id":{"type":"string"},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"contact_id":{"type":["string","null"]},"identity_id":{"type":["string","null"]},"group_id":{"type":["string","null"]},"contact":{"oneOf":[{"$ref":"#/components/schemas/ContactSummary"},{"type":"null"}],"description":"The person on the other side of a 1:1 chat. `null` in a group chat; see `group`."},"group":{"oneOf":[{"$ref":"#/components/schemas/GroupSummary"},{"type":"null"}],"description":"The group, when this is a group chat. `null` in a 1:1 chat."},"state":{"$ref":"#/components/schemas/ChatState"},"capabilities":{"type":"object","additionalProperties":true},"window_expires_at":{"type":["integer","null"]},"last_message_at":{"type":["integer","null"]},"created_at":{"type":"integer"}}},"ChatCreated":{"type":"object","properties":{"id":{"type":"string"},"channel_id":{"type":"string"},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"contact_id":{"type":["string","null"]},"identity_id":{"type":["string","null"]},"state":{"$ref":"#/components/schemas/ChatState"},"created":{"type":"boolean","description":"True if the chat was newly created."}}},"Message":{"type":"object","properties":{"id":{"type":"string","description":"Message id (msg_...)."},"chat_id":{"type":"string"},"channel_id":{"type":"string"},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"channel_address":{"type":["string","null"],"description":"The address of your own line on this channel (the sending/receiving number or alias, e.g. `+15559876543`). Same value regardless of direction; `sender`/`recipient` tell you which side of the conversation it is on."},"protocol":{"type":"string","enum":["pending","unknown","imessage","sms","rcs","whatsapp","whatsapp_business","amb"],"description":"Transport state or resolved wire service; never null. `pending` = accepted and dispatched, wire service not resolved yet; `imessage` / `rcs` / `sms` = the device-confirmed transport (iMessage blue bubble, RCS, or SMS/MMS green bubble); `whatsapp` = sent over P2P WhatsApp; `whatsapp_business` = sent over the WhatsApp Business Platform (WABA); `amb` = sent over Apple Messages for Business; `unknown` = accepted but the transport could not be resolved before the tracking window closed (see `error`)."},"direction":{"$ref":"#/components/schemas/Direction"},"sender":{"type":["string","null"],"description":"Who the message is FROM, direction-aware. On `inbound` this is the other party (the contact who messaged you, or in a group the participant who wrote it); on `outbound` this is your own line (`channel_address`). This is the SAME field delivered on the message webhooks, so a message reads identically via REST or webhook."},"recipient":{"type":["string","null"],"description":"Who the message is TO, direction-aware. On `inbound` this is your own line (`channel_address`); on `outbound` this is the other party. The mirror of `sender`. Matches the message webhook shape."},"contact":{"oneOf":[{"$ref":"#/components/schemas/ContactSummary"},{"type":"null"}],"description":"The other party (never your own line), regardless of direction — so you always know who the message is with. On an inbound group message it is the member who sent it; `null` on a message you sent to a group. Matches the message webhook shape."},"group":{"oneOf":[{"$ref":"#/components/schemas/GroupSummary"},{"type":"null"}],"description":"The group, when the message is in a group chat. `null` in a 1:1 chat. Matches the message webhook shape."},"type":{"type":"string","description":"Content type, e.g. text, media, poll."},"text":{"type":["string","null"],"description":"The plain text of the message — what the recipient actually sees. On a rich-text message the styling delimiters are NOT included here; see `formatted_text`."},"formatted_text":{"type":"string","description":"Markdown for a rich-text (bold/italic/underline/strikethrough) message. Omitted entirely when the message carries no styling, so its presence is how you detect rich text.\n\nPresent in both directions: on an outbound send made with `format: \"markdown\"`, and on an inbound iMessage whose sender styled their text — so styling a customer applied in Messages arrives here even though your integration never asked for it.\n\nAlways a normalized re-serialization of the message's actual styling rather than an echo of the source string: bold is spelled `**`, italic `*`, underline `++`, strikethrough `~~`, and any character that would otherwise read as a delimiter is backslash-escaped. Re-sending this value verbatim with `format: \"markdown\"` reproduces the same styled message. Blooio iMessage only. This is the SAME field delivered on the message webhooks, so a message reads identically via REST or webhook."},"status":{"type":"string","description":"Delivery lifecycle state: `queued` (accepted, awaiting dispatch), `sent` (handed to the channel; protocol resolves around here), `delivered` (delivery receipt received), `read` (read receipt received), `failed` (see `error`), `superseded` (row replaced by an idempotency retry or a poll-vote supersession). Inbound messages surface via the event feed / webhooks with `received`."},"reply_to_message_id":{"type":["string","null"]},"error":{"$ref":"#/components/schemas/MessageError"},"attachments":{"type":"array","description":"Ordered attachments on the message. Each entry is retrievable directly: `url` (servable, decrypted URL — inbound Apple/AMB media is downloaded, decrypted, and re-hosted before this is populated), `media_type` (content type), `size` (bytes), and an optional `caption`. Always present (empty array when there are none). Matches the webhook `attachments` shape exactly.","items":{"type":"object","properties":{"url":{"type":["string","null"]},"media_type":{"type":["string","null"]},"size":{"type":["integer","null"]},"caption":{"type":["string","null"]}}}},"interactive":{"type":"object","description":"Structured interactive content, present on interactive messages. For an inbound reply this is the customer's actual selection (`role: reply`): `kind` (quick_reply/list_picker/time_picker/form/…), `chosen` (the selected option titles / time slots / flattened answers) and, for Form Messages, a structured `responses` array (per-page question + typed answers per §17.4) — not just a display string, and never null on an interactive reply. For an outbound send it is the prompt that was sent (`role: prompt`). The message's `text` remains a deterministic flattened convenience summary alongside this structure. This is the SAME object delivered on the `message.received` webhook, so a message reads identically via REST or webhook.","additionalProperties":true},"imessage_app":{"type":"object","description":"Present on `imessage_app` messages (custom app bubbles): the app's `bundle_id`, `team_id` and `app_name`, the app-state `url` its extension reads, `caption` / `subcaption`, and the bubble's `session_id`. The same object the message webhooks carry.","additionalProperties":true},"routing":{"type":"object","description":"Apple Messages for Business only: the entry-point `intent` / `group` Apple attached to this message when the customer opened the conversation from a link or QR code. Present only on that message.","additionalProperties":true},"created_at":{"type":"integer"},"updated_at":{"type":"integer"}}},"MessageStatus":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","description":"Delivery lifecycle state: `queued` (accepted, awaiting dispatch), `sent` (handed to the channel; protocol resolves around here), `delivered` (delivery receipt received), `read` (read receipt received), `failed` (see `error`), `superseded` (row replaced by an idempotency retry or a poll-vote supersession). Inbound messages surface via the event feed / webhooks with `received`."},"protocol":{"type":"string","enum":["pending","unknown","imessage","sms","rcs","whatsapp","whatsapp_business","amb"],"description":"Transport state or resolved wire service; never null. `pending` = accepted and dispatched, wire service not resolved yet; `imessage` / `rcs` / `sms` = the device-confirmed transport (iMessage blue bubble, RCS, or SMS/MMS green bubble); `whatsapp` = sent over P2P WhatsApp; `whatsapp_business` = sent over the WhatsApp Business Platform (WABA); `amb` = sent over Apple Messages for Business; `unknown` = accepted but the transport could not be resolved before the tracking window closed (see `error`)."},"error":{"$ref":"#/components/schemas/MessageError"},"created_at":{"type":"integer"},"updated_at":{"type":"integer"}}},"MessageErrorCode":{"type":"string","enum":["delivery_unconfirmed","service_unavailable","number_banned","recipient_unreachable","recipient_blocked","attachment_unavailable","attachment_failed","message_too_large","invalid_content","group_unavailable","send_failed"],"description":"Why a message on a `blooio` channel failed. `delivery_unconfirmed`: it may have been sent; wait for a later status before resending. `service_unavailable`: the sending number was temporarily unavailable and the message wasn't sent; retry after a short wait. `number_banned`: the sending number is restricted from sending. `recipient_unreachable`: the recipient can't be reached at this address. `recipient_blocked`: sending to this recipient isn't allowed. `attachment_unavailable`: an attachment couldn't be downloaded from its URL. `attachment_failed`: an attachment couldn't be processed. `message_too_large`: the message or its attachments are too large. `invalid_content`: the content can't be sent as given. `group_unavailable`: the group conversation couldn't be reached on this number. `send_failed`: any other failure. New codes may be added; treat one you don't recognize like `send_failed`."},"MessageError":{"type":["object","null"],"description":"Why the message failed; null unless it failed. On a `blooio` channel `code` is a `MessageErrorCode` and `message` describes it. On any other channel both are the provider's own error. See [Errors](/errors#v4-message-errors).","properties":{"code":{"anyOf":[{"$ref":"#/components/schemas/MessageErrorCode"},{"type":["string","null"],"description":"A provider channel's own error code."}]},"message":{"type":["string","null"],"description":"A description of the failure."}},"required":["code","message"]},"MessageEvent":{"type":"object","properties":{"id":{"type":"string","description":"Event id (evt_...)."},"message_id":{"type":"string"},"kind":{"type":"string","description":"Bare lifecycle kind, e.g. sent, delivered, read, reaction."},"occurred_at":{"type":"integer"},"metadata":{"type":"object","additionalProperties":true,"description":"The event's data, as `GET /events` returns it: a `reaction` carries the same fields as a `message.reaction` webhook in the latest payload version, a `failed` event is its `{ code, message }` error, and other kinds carry the data recorded with them."}}},"MessageSendResult":{"type":"object","description":"Returned at the top level (not wrapped in `data`) by send endpoints.","properties":{"id":{"type":"string"},"chat_id":{"type":"string"},"channel_id":{"type":"string"},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"protocol":{"type":"string","enum":["pending","unknown","imessage","sms","rcs","whatsapp","whatsapp_business","amb"],"description":"Transport state or resolved wire service; never null. `pending` = accepted and dispatched, wire service not resolved yet; `imessage` / `rcs` / `sms` = the device-confirmed transport (iMessage blue bubble, RCS, or SMS/MMS green bubble); `whatsapp` = sent over P2P WhatsApp; `whatsapp_business` = sent over the WhatsApp Business Platform (WABA); `amb` = sent over Apple Messages for Business; `unknown` = accepted but the transport could not be resolved before the tracking window closed (see `error`)."},"from":{"type":["string","null"],"description":"The resolved sender as a human value: the sending channel's phone number (numbered channels) or alias (non-numbered channels). This is the primary sender reference; `channel_id` remains available for exact follow-up and audit."},"direction":{"$ref":"#/components/schemas/Direction"},"type":{"type":"string"},"status":{"type":"string","description":"Initial lifecycle state for the send: normally `queued` (accepted for delivery), or `failed` if rejected before dispatch. Later transitions (`sent` → `delivered` / `read`, or `failed`) are reported via the message status endpoint and `message.*` webhooks."},"group_id":{"type":"string","description":"Existing group id for a group-targeted send."},"hybrid":{"type":"object","description":"Hybrid routing phase and optional transition placement.","additionalProperties":true},"error":{"$ref":"#/components/schemas/MessageError"},"fallback":{"type":"object","description":"Present when a send could not be delivered on the requested channel and a different channel should be tried.","properties":{"recommended":{"type":"boolean","description":"True when falling back to another channel is recommended."},"reason":{"type":"string"}}},"to":{"type":"string","description":"Recipient identifier. Present only on per-recipient items inside a fan-out (FanOutResult) response."},"dry_run":{"type":"boolean"},"would_send":{"type":"boolean"},"preview":{"type":"object","additionalProperties":true},"poll":{"type":"object","properties":{"title":{"type":"string"},"options":{"type":"array","items":{"type":"string"}}}},"routing":{"$ref":"#/components/schemas/RoutingMetadata"}}},"RoutingMetadata":{"type":"object","description":"How Blooio selected the concrete sender channel.","properties":{"mode":{"type":"string","enum":["explicit","priority","hybrid"]},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"number":{"type":"string"},"alias":{"type":"string"},"priority_id":{"type":"string"},"priority":{"type":"integer"}}},"FanOutResult":{"type":"object","description":"Returned when a multi-recipient send fans out to individual sends.","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MessageSendResult"}},"fan_out":{"type":"boolean"},"sent":{"type":"integer"},"failed":{"type":"integer"},"dry_run":{"type":"boolean"},"routing":{"$ref":"#/components/schemas/RoutingMetadata"}}},"Group":{"type":"object","properties":{"id":{"type":"string","description":"Group id (grp_...)."},"channel_id":{"type":"string"},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"name":{"type":["string","null"]},"icon_url":{"type":["string","null"]},"chat_id":{"type":["string","null"]},"chat_linked":{"type":"boolean","description":"Whether the group has an iMessage conversation on at least one of your numbers. `false` until the first message to the group opens one."},"created_at":{"type":"integer"},"members":{"type":"array","items":{"$ref":"#/components/schemas/GroupMember"}}}},"GroupMember":{"type":"object","properties":{"identity_id":{"type":"string"},"contact_id":{"type":["string","null"]},"role":{"type":"string"},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"identifier":{"type":"string"},"name":{"type":["string","null"],"description":"The member's contact name. `null` when none is on file."}}},"ContactSummary":{"type":"object","description":"Who a chat or message is with.","required":["contact_id","identifier","name"],"properties":{"contact_id":{"type":["string","null"],"description":"The contact (ct_...). `null` when no contact record matches the identifier."},"identifier":{"type":"string","description":"The contact's identifier (phone number, email, or handle)."},"name":{"type":["string","null"],"description":"The contact's name. `null` when none is on file. On a webhook it is a snapshot from when the event happened; read the contact for current details."}}},"GroupSummary":{"type":"object","required":["group_id","name"],"properties":{"group_id":{"type":"string","description":"Group id (grp_...)."},"name":{"type":["string","null"],"description":"The group's name. `null` when it has none."}}},"Webhook":{"type":"object","properties":{"id":{"type":"string","description":"Webhook id (wh_...)."},"url":{"type":"string","format":"uri"},"event_types":{"type":"array","items":{"type":"string"},"description":"The events this webhook receives. `[\"*\"]` for every webhook created since event filtering was retired; subscriptions registered before that keep their original list, which is frozen and cannot be changed."},"status":{"type":"string","enum":["active","disabled"]},"scope":{"type":"string","enum":["organization","api_key","integration"],"description":"Which lines this subscription receives events for. `organization` covers every line; the other two are limited to the lines that owner holds."},"api_key":{"type":["string","null"],"description":"The owning API key when `scope` is `api_key`, otherwise null."},"integration_id":{"type":["string","null"],"description":"The owning integration when `scope` is `integration`, otherwise null."},"channel_id":{"type":["string","null"]},"channel_type":{"type":["string","null"]},"api_version":{"allOf":[{"$ref":"#/components/schemas/WebhookApiVersionId"}],"description":"The payload version this subscription receives, also sent as `api_version` in every event envelope; see `GET /webhooks/versions`."},"created_at":{"type":"integer"}}},"WebhookApiVersionId":{"type":"string","enum":["2026-10-01","2026-09-01"],"description":"A webhook payload version, as a release date. `2026-10-01` is the latest."},"WebhookApiVersion":{"type":"object","properties":{"version":{"$ref":"#/components/schemas/WebhookApiVersionId"},"latest":{"type":"boolean","description":"True for exactly one version: the one new subscriptions get."},"released_at":{"type":"string","format":"date"},"summary":{"type":"string"},"changes":{"type":"array","items":{"type":"string"},"description":"What this version changed relative to the one before it. Empty for the baseline."}},"required":["version","latest","released_at","summary","changes"]},"WebhookWithSecret":{"allOf":[{"$ref":"#/components/schemas/Webhook"},{"type":"object","properties":{"signing_secret":{"type":"string","description":"Returned once, on creation. Store it to verify signatures."}}}]},"WebhookDelivery":{"type":"object","properties":{"id":{"type":"string","description":"Delivery id (wdel_...)."},"webhook_id":{"type":"string"},"event_id":{"type":"string"},"event_type":{"type":"string"},"status":{"type":"string"},"attempt_count":{"type":"integer"},"response_status":{"type":["integer","null"]},"last_attempt_at":{"type":["integer","null"]},"next_attempt_at":{"type":["integer","null"]},"created_at":{"type":"integer"}}},"Event":{"type":"object","properties":{"id":{"type":"string","description":"Event id (evt_...)."},"type":{"type":"string","description":"e.g. message.delivered, poll.voted."},"message_id":{"type":["string","null"]},"chat_id":{"type":["string","null"]},"channel_type":{"type":["string","null"]},"occurred_at":{"type":"integer"},"data":{"type":"object","additionalProperties":true,"description":"`typing.*`, `contact.shared`, `message.reaction`, `poll.*` and `group.*` carry the same data as the webhook in the latest payload version, with `contact`, `group` and `channel_address`, and without the fields that version removes. Names in `contact` and `group` are read when you make the request. A `message.failed` event's data is its `{ code, message }` error (see `MessageError`). Other types carry the data recorded with them."}}},"Poll":{"type":"object","properties":{"id":{"type":"string"},"chat_id":{"type":"string"},"channel_id":{"type":"string"},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"type":{"type":"string","enum":["poll"]},"status":{"type":"string"},"poll":{"type":"object","properties":{"title":{"type":"string"},"options":{"type":"array","items":{"type":"string"}}}}}},"PollVoteResult":{"type":"object","properties":{"id":{"type":"string"},"poll_id":{"type":"string"},"voted_option":{"type":"string"},"toggled":{"type":"string","enum":["on","off"]},"active_votes":{"type":"array","items":{"type":"string"}},"active_vote_indices":{"type":"array","items":{"type":"integer"}}}},"PollResults":{"type":"object","properties":{"poll_id":{"type":"string"},"chat_id":{"type":"string"},"title":{"type":"string"},"options":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string"},"votes":{"type":"integer"}}}},"total_votes":{"type":"integer"}}},"LinkPreview":{"type":"object","properties":{"imageUrl":{"type":"string","format":"uri"},"title":{"type":"string"}}},"MessageContentFields":{"type":"object","description":"Top-level message content. Provide exactly one content field (`text` and `attachments` may be combined); the content type is inferred from the field, so no separate `type` discriminator exists. Conflicting content fields are rejected with `422 invalid_content`.\n\nUnrecognised top-level request fields (typos, unsupported fields) are rejected by default with `422 unknown_fields` naming the offending field(s), so mistakes are caught immediately instead of being silently dropped. To opt out and have unknown fields logged-and-ignored instead, send the header `X-Blooio-Strict-Fields: false` (also accepts `0`, `off`, `no`, `lenient`, `warn`).","properties":{"text":{"type":"string","description":"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":{"type":"string","enum":["plain","markdown"],"default":"plain","description":"How to interpret `text` (and each `parts[].text`). Defaults to `plain`, which sends the string exactly as given.\n\nWith `markdown`, four constructs are parsed and delivered as real iMessage rich text — the recipient sees styled text, not delimiters:\n\n| Construct | Syntax |\n| --- | --- |\n| Bold | `**bold**` or `__bold__` |\n| Italic | `*italic*` or `_italic_` |\n| Underline | `++underline++` |\n| Strikethrough | `~~strike~~` |\n\nThey 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.\n\nThe 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**`).\n\nOnly 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.\n\nRejected 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":{"type":"array","items":{"type":"string","format":"uri"},"description":"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.\n\n**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.","minItems":1},"rich_link":{"type":"object","description":"A tappable rich link bubble (Blooio / AMB).","properties":{"url":{"type":"string","format":"uri"},"title":{"type":"string"}},"required":["url"]},"app_clip":{"type":"object","description":"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=<bundleID>` 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`.","properties":{"url":{"type":"string","format":"uri","description":"The App Clip launch URL (https). Mutually exclusive with `bundle_id`; if both are given, `url` wins."},"bundle_id":{"type":"string","description":"App Clip bundle identifier (e.g. `com.example.app.Clip`), expanded to `https://appclip.apple.com/id?p=<bundle_id>`. Used when `url` is absent."},"title":{"type":"string","description":"Optional preview title override."}}},"imessage_app":{"type":"object","description":"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:<team_id>:<bundle_id>` 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`.)","required":["bundle_id","team_id","url"],"properties":{"bundle_id":{"type":"string","description":"Your iMessage app extension's bundle identifier (e.g. `com.acme.app.MessagesExtension`)."},"team_id":{"type":"string","description":"Your Apple Developer Team ID. Combined with `bundle_id` to form the balloon plugin id."},"url":{"type":"string","description":"The app-state URL your extension reads on tap (its `MSMessage.url`). Carries the state to reconstruct on the recipient device."},"app_name":{"type":"string","description":"Display name shown for the app (the `an` field). Defaults to a generic label."},"caption":{"type":"string","description":"Template-card caption shown to everyone (including recipients without the app)."},"subcaption":{"type":"string","description":"Optional template-card subcaption."},"image_url":{"type":"string","format":"uri","description":"Optional https image used as the fallback template card thumbnail. Fetched best-effort (≤256KB); a fetch failure never fails the send."},"app_store_id":{"type":"integer","minimum":1,"description":"Optional App Store id (adam id) for your app. When the recipient doesn't have the extension installed, Messages uses it to deep-link the App Store \"get this app\" fallback to the correct listing. Omitting it leaves the send unchanged."}}},"poll":{"type":"object","description":"A native Blooio poll. Routed sends automatically consider only poll-capable channels.","properties":{"title":{"type":"string","minLength":1},"options":{"type":"array","items":{"type":"string","minLength":1},"minItems":2}},"required":["title","options"]},"interactive":{"type":"object","description":"Interactive content. On Apple Messages for Business (AMB) 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.","additionalProperties":true,"required":["kind"],"properties":{"kind":{"type":"string","enum":["quick_reply","list_picker","time_picker","form","apple_pay","authenticate","imessage_app"],"description":"Which interactive experience to render."},"summary_text":{"type":"string","description":"Prompt / fallback line shown where the interactive UI can't render. Used as the quick reply prompt."},"text":{"type":"string","description":"Alias for `summary_text`."},"request_identifier":{"type":"string","description":"Optional stable id echoed back on the customer's reply. Defaults to the message id."},"received_message":{"type":"object","description":"Bubble shown to the customer before they interact. Not allowed for `quick_reply` (send a plain text bubble first instead).","additionalProperties":true,"properties":{"title":{"type":"string"},"subtitle":{"type":"string"},"style":{"type":"string","enum":["icon","small","large"]},"imageIdentifier":{"type":"string"}}},"reply_message":{"type":"object","description":"Bubble shown after the customer replies.","additionalProperties":true,"properties":{"title":{"type":"string"},"subtitle":{"type":"string"},"style":{"type":"string","enum":["icon","small","large"]},"imageIdentifier":{"type":"string"}}},"images":{"type":"array","description":"Inline PNG assets referenced by `imageIdentifier` elsewhere in the payload. Supply each as base64 `data` OR a `url` (fetched server-side). Every image — fetched or inline — is validated from its MAGIC BYTES (not the Content-Type): it must be a real PNG (`422 amb_image_not_png` / `code` on the failed send otherwise), within Apple's 200 KB per-image cap (`amb_image_too_large`) and 5 MB aggregate cap (`amb_images_total_too_large`), and within the @3x dimension ceiling — supply `expected_size: { width, height, style }` to enforce an exact @3x size, rejected with `amb_image_bad_dimensions` naming the expected size. When the resulting interactiveData would exceed Apple's ~10 KB inline ceiling, Blooio automatically uploads it and sends it by `interactiveDataRef` instead (Apple's 200 on an over-limit inline payload is not treated as conformance).","items":{"type":"object","properties":{"identifier":{"type":"string"},"data":{"type":"string","description":"Base64-encoded PNG."},"url":{"type":"string","format":"uri","description":"Public https PNG URL, fetched and validated server-side."},"expected_size":{"type":"object","description":"Optional exact @3x size to enforce for this asset's target style.","properties":{"width":{"type":"integer"},"height":{"type":"integer"},"style":{"type":"string"}}}}}},"items":{"type":"array","description":"Quick reply options (`kind` = quick_reply). Required: 1–5 items, each with a non-empty `id` and `title`. Rejected with `422 invalid_quick_reply` otherwise.","minItems":1,"maxItems":5,"items":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"}},"required":["id","title"]}},"sections":{"type":"array","description":"List picker sections (`kind` = list_picker). Required: at least one section, each with a non-empty `title` and at least one item; every item needs a non-empty `identifier` and `title`. Rejected with `422 invalid_list_picker` otherwise. Blooio normalizes the input into Apple's reliably-rendering wire shape (`listPicker.sections[].items[]` with a per-item `style`, under a live-layout envelope) — the shape confirmed to both render the bubble and populate options on tap. May also be supplied as `list` (an array of sections or an object with a `sections` array).","minItems":1,"items":{"type":"object","properties":{"title":{"type":"string"},"order":{"type":"integer"},"multipleSelection":{"type":"boolean","description":"Allow selecting more than one item in this section."},"items":{"type":"array","minItems":1,"items":{"type":"object","properties":{"identifier":{"type":"string"},"title":{"type":"string"},"subtitle":{"type":"string"},"order":{"type":"integer"},"style":{"type":"string","description":"Row style. Defaults to `default` when omitted.","enum":["default"]},"imageIdentifier":{"type":"string"}},"required":["identifier","title"]}}},"required":["title","items"]}},"list":{"type":"object","description":"Alias for `sections` (`kind` = list_picker). Accepts `{ \"sections\": [...] }` (or a bare array of sections). Same validation and normalization as `sections`.","additionalProperties":true},"event":{"type":"object","description":"Time picker event (`kind` = time_picker). Required: at least one `timeslots` entry, each with a `startTime` (ISO-8601) and a positive `duration` (seconds). Rejected with `422 invalid_time_picker` otherwise.","additionalProperties":true,"properties":{"identifier":{"type":"string"},"title":{"type":"string"},"location":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string"}}},"timezoneOffset":{"type":"integer"},"timeslots":{"type":"array","items":{"type":"object","properties":{"identifier":{"type":"string"},"startTime":{"type":"string","description":"ISO-8601 start time; normalized to Apple's `+0000` format on send."},"duration":{"type":"integer","description":"Slot duration in seconds."}},"required":["startTime","duration"]}}}},"dynamic":{"type":"object","description":"Apple dynamic interactive message (multi-page form) object, forwarded verbatim (`kind` = form). Required: at least one page under `pages` (or `data.pages`); also carries `startPageIdentifier`, `requestIdentifier`, `showSummary`, etc. Rejected with `422 invalid_form` otherwise. May also be supplied under `form`.","additionalProperties":true},"form":{"type":"object","description":"Alias for `dynamic` (`kind` = form).","additionalProperties":true},"payment":{"type":"object","description":"Apple Pay payment request object, forwarded verbatim (`kind` = apple_pay). Required: a `paymentRequest` object with `total` (`label` + numeric `amount`), `currencyCode` (ISO-4217), and `countryCode` (ISO-3166). Rejected with `422 invalid_apple_pay` otherwise. Apple additionally gates delivery on the merchant configuration.","additionalProperties":true},"authenticate":{"type":"object","description":"OAuth2 authentication request object, forwarded verbatim (`kind` = authenticate). Required: an `oauth2` object with `responseType`, `scope`, and `redirectURI`. Rejected with `422 invalid_authenticate` otherwise. Sent as interactive data version 2.0.","additionalProperties":true},"oauth2":{"type":"object","description":"Shorthand for `authenticate.oauth2` (`kind` = authenticate). Requires `responseType`, `scope`, and `redirectURI`.","additionalProperties":true},"app":{"type":"object","description":"Custom iMessage app metadata (`kind` = imessage_app). These App Store fallback fields (shown by Messages when the extension isn't installed) may also be supplied flat on the interactive object as `app_id` / `app_name` / `app_icon`; both forms reach the wire.","properties":{"app_id":{"type":"integer"},"app_name":{"type":"string"},"app_icon":{"type":"string"},"bid":{"type":"string"},"url":{"type":"string"}}},"app_id":{"description":"App Store id for the iMessage app fallback (`kind` = imessage_app). Flat alias for `app.app_id`.","type":["integer","string"]},"app_name":{"type":"string","description":"App Store name for the iMessage app fallback (`kind` = imessage_app). Flat alias for `app.app_name`."},"app_icon":{"type":"string","description":"Base64 PNG icon for the iMessage app fallback (`kind` = imessage_app). Flat alias for `app.app_icon`."},"bid":{"type":"string","description":"iMessage extension bundle id. Required for `kind` = imessage_app (or supply it under `app.bid`); rejected with `422 invalid_imessage_app` otherwise."},"url":{"type":"string","description":"Custom app URL / query string (`kind` = imessage_app)."},"session_identifier":{"type":"string"},"use_live_layout":{"type":"boolean"}}},"invitation":{"type":"object","description":"Apple Messages for Business (AMB) 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 AMB id. Requires prior Invitations approval from Apple and explicit customer opt-in.","additionalProperties":true,"required":["template_id","reference_id"],"properties":{"template_id":{"type":"string","description":"The Apple-managed notification template id authorized for your business (e.g. `binaryChoice.engage.withImage`)."},"reference_id":{"type":"string","maxLength":1000,"description":"Your business context id for this invitation (order number, case id, etc.). Required by Apple and echoed back on the CloseSession webhook. Max 1000 characters; must not contain quotation marks or apostrophes."},"parameters":{"type":"object","additionalProperties":true,"description":"Template parameter values required by the chosen template (e.g. `brandName`, and `brandLogo` as a base64 PNG). For known templates the required parameters are enforced before sending — e.g. `binaryChoice.engage.withImage` requires `brandName` + `brandLogo` — and a missing one is rejected with `422 invalid_invitation` naming the field (rather than being accepted and then rejected by Apple with `400 Invalid payload`)."},"locale":{"type":"string","description":"BCP-47 locale for the template. Defaults to `en-us`."}}},"template":{"type":"object","description":"Template content (WhatsApp Business).","properties":{"template_id":{"type":"string"}},"additionalProperties":true},"parts":{"type":"array","description":"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.","items":{"type":"object","properties":{"text":{"type":"string"},"url":{"type":"string","format":"uri"}}},"minItems":1},"reply_to":{"type":"string","description":"Message id to reply to. Applies to the whole outgoing message."},"effect":{"type":"string","description":"iMessage screen/bubble effect (Blooio, text only)."},"link_preview":{"$ref":"#/components/schemas/LinkPreview"},"badge":{"type":"string","enum":["sent_with_siri","sent_with_facetime"],"description":"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":{"type":"boolean","description":"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)."}}},"Recipient":{"description":"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.","oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"contact_id":{"type":"string","description":"Contact id (ct_...) — routed sends only."}},"required":["contact_id"],"additionalProperties":false},{"type":"object","properties":{"group_id":{"type":"string","description":"Existing group id (grp_...) — exact sender or a hybrid sender that owns the group's channel."}},"required":["group_id"],"additionalProperties":false}]},"PriorityChannel":{"type":"object","description":"A channel in a priority. Channels sharing a `priority` (and type) form a pool.","properties":{"channel_id":{"type":"string","description":"Channel id (ch_...)."},"type":{"$ref":"#/components/schemas/ChannelType"},"address":{"type":["string","null"],"description":"The channel's real-world address (phone number for numbered channels)."},"alias":{"type":"string","description":"Organization-wide alias, when the channel has one (AMB/RCS Business)."},"priority":{"type":"integer","minimum":1,"description":"Tier (drag order). Lower runs first; same priority + same type = a pool."}},"required":["channel_id","priority"]},"Priority":{"type":"object","properties":{"id":{"type":"string","description":"Priority id (priority_...)."},"name":{"type":["string","null"]},"is_default":{"type":"boolean","description":"Whether this is the key's default priority (used by the agnostic send)."},"channels":{"type":"array","items":{"$ref":"#/components/schemas/PriorityChannel"}},"created_at":{"type":"integer"},"updated_at":{"type":"integer"}}},"CreatePriorityRequest":{"type":"object","properties":{"name":{"type":"string"},"is_default":{"type":"boolean","description":"Make this the key's default priority. Defaults to true for the key's first priority."},"channels":{"type":"array","minItems":1,"description":"Channels in the priority. Each item is a channel id string or `{ channel_id, priority }`. Channels sharing a priority must be the same type.","items":{"oneOf":[{"type":"string"},{"type":"object","properties":{"channel_id":{"type":"string"},"priority":{"type":"integer","minimum":1}},"required":["channel_id"]}]}}},"required":["channels"]},"UpdatePriorityRequest":{"type":"object","description":"Any subset of fields. When `channels` is present it replaces the priority's channels wholesale.","properties":{"name":{"type":"string"},"is_default":{"type":"boolean"},"channels":{"type":"array","minItems":1,"items":{"oneOf":[{"type":"string"},{"type":"object","properties":{"channel_id":{"type":"string"},"priority":{"type":"integer","minimum":1}},"required":["channel_id"]}]}}}},"ApiKey":{"type":"object","description":"An organization API key. The `api_key` field is the secret bearer value — it is returned in full by the create and list endpoints and must be stored securely.","properties":{"api_key":{"type":"string","description":"The secret key value (prefixed `api_`). Send it as `Authorization: Bearer <api_key>` on API requests."},"name":{"type":["string","null"],"description":"Human-readable label for the key."},"type":{"type":["string","null"],"description":"Optional key type/category tag."},"created_at":{"type":["integer","null"],"description":"Creation time (epoch milliseconds)."},"valid_until":{"type":"integer","description":"Expiry time (epoch milliseconds), or `-1` when the key never expires."},"blocked":{"type":"boolean","description":"Whether the key is blocked (suspended)."},"deprecated":{"type":"boolean","description":"Whether the key has been deprecated (revoked)."}}},"CreateApiKeyRequest":{"type":"object","description":"Optional attributes for the new key. All fields are optional; an empty body mints an unnamed, non-expiring key.","properties":{"name":{"type":"string","description":"Human-readable label for the key."},"type":{"type":"string","description":"Optional key type/category tag."},"valid_until":{"type":["integer","null"],"description":"Expiry time (epoch milliseconds). Pass `null` or `-1` (or omit) for a key that never expires."}}},"ApiKeyChannelAssignment":{"type":"object","description":"Result of assigning/unassigning a channel to an API key.","properties":{"api_key":{"type":"string"},"channel_id":{"type":"string","description":"The resolved channel id (`ch_...`) the line belongs to."},"address":{"type":"string","description":"The line in E.164 that was (un)assigned."},"success":{"type":"boolean"},"reassigned":{"type":"boolean","description":"Present on assign: true when the line was taken from a previous owner."},"unchanged":{"type":"boolean","description":"Present on assign: true when the key already owned the line (no-op)."}}},"From":{"type":"string","description":"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."},"SendMessageRequest":{"allOf":[{"$ref":"#/components/schemas/MessageContentFields"},{"type":"object","description":"Send to a recipient. `from` is one optional string (phone number, alias, or channel id); omit it for automatic routing. `priority_id`, `channel_type`, and `hybrid` are advanced routing controls, each mutually exclusive with an explicit `from`.","properties":{"from":{"$ref":"#/components/schemas/From"},"priority_id":{"type":"string","description":"Route through an explicit priority (priority_...). Mutually exclusive with `from`."},"channel_type":{"$ref":"#/components/schemas/ChannelType","description":"Constrain automatic/priority routing to one channel type. Mutually exclusive with `from`."},"hybrid":{"oneOf":[{"type":"boolean"},{"type":"string"}],"description":"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":{"$ref":"#/components/schemas/Recipient"},"dry_run":{"type":"boolean","description":"Validate and route only; do not send."}},"required":["to"]}]},"SendToChannelRequest":{"allOf":[{"$ref":"#/components/schemas/MessageContentFields"},{"type":"object","description":"Channel-scoped send. The sender comes from the path, so the body carries no `from` or routing controls.","properties":{"to":{"$ref":"#/components/schemas/Recipient"},"dry_run":{"type":"boolean","description":"Validate and route only; do not send."}},"required":["to"]}]},"SendToChatRequest":{"allOf":[{"$ref":"#/components/schemas/MessageContentFields"},{"type":"object","properties":{"dry_run":{"type":"boolean"}}}]},"CreateChatRequest":{"type":"object","properties":{"channel_id":{"type":"string"},"to":{"type":"string","description":"Recipient identifier (phone/email/opaque provider id)."}},"required":["channel_id","to"]},"CreateContactRequest":{"type":"object","properties":{"name":{"type":["string","null"]},"identifier":{"type":"string","description":"Phone (E.164) or email to attach as the first identity."},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"channel_id":{"type":"string","description":"Required when channel_type is amb."}}},"AttachIdentityRequest":{"type":"object","properties":{"identifier":{"type":"string"},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"channel_id":{"type":"string","description":"Required when channel_type is amb."}},"required":["identifier"]},"CreateGroupRequest":{"type":"object","properties":{"channel_id":{"type":"string","description":"Must be a group-capable channel (blooio/whatsapp)."},"members":{"type":"array","items":{"oneOf":[{"type":"string"},{"type":"object","properties":{"identifier":{"type":"string"}},"required":["identifier"]}]},"minItems":1,"description":"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":{"type":"string"}},"required":["channel_id","members"]},"SendPollRequest":{"type":"object","properties":{"title":{"type":"string"},"options":{"type":"array","items":{"type":"string"},"minItems":2}},"required":["title","options"]},"VotePollRequest":{"type":"object","description":"Provide option_index (0-based) or option text.","properties":{"option_index":{"type":"integer","description":"0-based option index."},"option":{"type":"string","description":"Option text."}}},"CreateWebhookRequest":{"type":"object","properties":{"url":{"type":"string","format":"uri","description":"HTTPS endpoint."},"event_types":{"type":"array","items":{"type":"string"},"deprecated":true,"description":"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":{"type":"string","description":"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":{"type":"string","description":"Scope the subscription to the lines this integration owns. Mutually exclusive with `api_key`."},"channel_id":{"type":"string"},"channel_type":{"$ref":"#/components/schemas/ChannelType"},"api_version":{"allOf":[{"$ref":"#/components/schemas/WebhookApiVersionId"}],"description":"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."}},"required":["url"]},"UpdateWebhookRequest":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"event_types":{"type":"array","items":{"type":"string"},"deprecated":true,"description":"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":{"type":"string","enum":["active","disabled"]},"api_version":{"allOf":[{"$ref":"#/components/schemas/WebhookApiVersionId"}],"description":"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`."}}},"BloobabilityMessage":{"type":"object","description":"One conversation message, in order. Only `id`, `role`, and `content` are forwarded to the model; any other field is dropped at the boundary so it cannot perturb the assessment.","required":["id","role","content"],"properties":{"id":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9_.:-]*$","maxLength":128,"description":"The evidence ID the model can cite back in `evidence_ids`. Must match `^[A-Za-z0-9][A-Za-z0-9_.:-]*$` (≤128 chars) and be unique across the conversation."},"role":{"type":"string","enum":["customer","agent","system"],"description":"Who sent the message."},"content":{"type":"string","minLength":1,"description":"The message text. Must be non-empty."}}},"BloobabilityPolicy":{"type":"object","description":"A policy to assess the conversation against.","required":["id","description"],"properties":{"id":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9_.:-]*$","maxLength":128,"description":"The policy ID. This is the **calibration key**: an `id` matching a policy the calibration map was fitted on yields the `roster` tier; a novel `id` yields `pooled_unseen`. Must match `^[A-Za-z0-9][A-Za-z0-9_.:-]*$` (≤128 chars) and be unique across the request."},"description":{"type":"string","minLength":1,"description":"What the model actually judges the conversation against. Keep it consistent with what the `id` implies — a description that means something different from a known `id` produces a \"trusted\" `roster` label on a number that is not really trustworthy."}}},"BloobabilityAssessRequest":{"type":"object","required":["conversation","policies"],"properties":{"reference_id":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9_.:-]*$","maxLength":128,"description":"Optional. Your own reference, echoed back verbatim so you can reconcile a response with your records. It is **not** sent to the model and does not affect the result, and it is **not** an idempotency key. If you omit it, it is simply absent from the response — the server never mints one."},"conversation":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/BloobabilityMessage"},"description":"The messages to assess, in order. Non-empty."},"policies":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/BloobabilityPolicy"},"description":"The policies to score, each independently. Non-empty."},"include_evidence":{"type":"boolean","default":false,"description":"When `true`, the matcher runs in parallel with the gauge and each policy gains an `evidence_ids` array. Off by default."}}},"BloobabilityPolicyResult":{"type":"object","required":["id","calibrated_probability","verdict","calibration_tier"],"properties":{"id":{"type":"string","description":"The policy ID from your request."},"calibrated_probability":{"type":"number","minimum":0,"maximum":1,"description":"**The answer.** The calibrated probability (0–1) that this policy applies to the conversation."},"verdict":{"type":"string","enum":["yes","no"],"description":"The gauge's discrete call — does this policy apply? `calibrated_probability` says how confident; the accept rule is computed against this side."},"calibration_tier":{"type":"string","enum":["roster","pooled_unseen"],"description":"How trustworthy the number is. `roster`: a policy the calibration map was fitted on, with its own per-label offset — `accepted` is meaningful. `pooled_unseen`: a novel policy scored by the pooled map with no per-label offset — the probability is usable but `accepted` is omitted."},"accepted":{"type":"boolean","description":"The frozen accept/defer decision, frozen at the release's validated risk bound. `true` means the confidence on the predicted side clears the frozen per-verdict threshold. Deliberately strict (a confident `0.879` `yes` is still not accepted). **Omitted for `pooled_unseen`** — treat a missing `accepted` as \"no validated decision,\" never as `false`."},"evidence_ids":{"type":"array","items":{"type":"string"},"description":"Supporting message IDs from the **matcher** pass — present only when `include_evidence: true`. This is an independent judgment from the gauge's score, so a policy can have a high `calibrated_probability` but an empty `evidence_ids`."}}},"BloobabilityAssessResponse":{"type":"object","required":["policies"],"properties":{"reference_id":{"type":"string","description":"Verbatim echo of your `reference_id`. Absent if you did not send one."},"policies":{"type":"array","items":{"$ref":"#/components/schemas/BloobabilityPolicyResult"},"description":"One result per requested policy, in the same order as `request.policies`."}}}}},"x-blooio-webhook-event-notes":{"message.reaction":"A tapback (classic reaction like `love`/`like` or an emoji) was added to or removed from a message. Payload carries `reaction`, `action` (`add`/`remove`), the reacted `message_id`, `original_text`, the `sender` (the person who reacted — in a group, the member who tapped — or your channel for a reaction you sent; `recipient` is the other side), and — crucially for multi-image sends — `part_index`: WHICH part of a multipart/carousel message was reacted to (0-based, counting the message's parts). The enriched payload also includes the message's full ordered `attachments` array and, when the tapback landed on an image, `reacted_attachment`: the exact `{ index, url, media_type, size, caption }` that was reacted to (null when the reaction targeted a text/caption part or a non-multipart message). So a reaction 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\". `group` is `{ group_id, name }` in a group chat and `null` in a 1:1 chat. Like the rest of the body, `contact` describes the reacted message: in a group it is the member who wrote that message (`null` when you wrote it), while `sender` is who reacted.","message.pending":"An outbound message row was accepted but no device has taken it yet. Emitted only by the v1/v2 and GHL send paths, which persist the row before dispatching it; a native `/v4` send inserts straight as `queued` and so never reports a pending state. `message.queued` is the first lifecycle event common to every send path.","safety.state_changed":"Fires when a sending number's warm-up tier or safety action changes. Payload carries `tier`, `previous_tier`, `action`, `previous_action` (one of `none`, `queue`, `slow`, `pause_new`, `reply_only`, `review`), and `reasons`.","safety.number_banned":"Fires when Apple bans a dedicated or shared sending number. Payload carries `channel_id`, `phone_number`, `allocation_type` (`dedicated` or `shared`), and `banned_at`.","number.purchase.completed":"A number purchase finished provisioning. Because purchases are asynchronous (the charge triggers a Stripe invoice whose `invoice.paid` provisions the lines), this is how you learn the order succeeded. A purchase may provision several lines, so the payload reports all of them: `purchase_id`, `status` (`completed`), `lines` (one entry per provisioned line, each `{ phone_number, channel_id }`), and the flat `phone_numbers` (E.164) and `channel_ids` (`ch_` ids) arrays covering every provisioned line. When lines are owned by different API keys, every owning key's subscription receives the event.","number.purchase.action_required":"The saved card needs additional authentication (3DS/SCA) before the purchase can complete. Payload carries `purchase_id`, `status` (`action_required`), and `action_url` — open it to finish authentication. Once completed, provisioning proceeds and a `number.purchase.completed` event follows.","number.purchase.failed":"A number purchase failed terminally; no lines were provisioned. Payload carries `purchase_id`, `status` (`failed`), and `reason`.","number.removed":"A line was removed/unsubscribed (via `DELETE /channels/{channel}` or the dashboard). Payload carries `phone_number`, `channel_id`, `binding_id`, and `reasons` (the churn reason labels).","group.merged":"A new group's first message landed in an iMessage conversation another of your groups already holds with the same people on that number, so the new group was folded into it (see Create a group). `group_id` / `chat_id` are the surviving group and its chat; `source_group_id` / `source_chat_id` are the ids returned when the group was created. Also carries `channel_id`, the `message_id` that opened the conversation (now in the surviving chat), and the full surviving `group` object. Replies in that conversation arrive under the survivor."},"x-blooio-webhook-event-types":["message.received","message.queued","message.pending","message.sent","message.delivered","message.failed","message.read","message.reaction","poll.created","poll.received","poll.voted","payment.received","payment.completed","payment.failed","group.name_changed","group.icon_changed","group.merged","contact.shared","contact.merged","typing.started","typing.stopped","safety.state_changed","safety.number_banned","chat.closed","identity.verified","number.purchase.completed","number.purchase.action_required","number.purchase.failed","number.removed","handoff.created","handoff.customer_reply","handoff.acknowledged","handoff.resolved"]}