openapi: "3.1.0" info: title: Petasos Partner API version: "1.3.0" description: | Programmatic access to Petasos for sending WhatsApp campaign messages and tracking their delivery status. ## Authentication Every `/v1` request requires an API key, passed in one of two headers: - `Authorization: Bearer wk_` - `X-API-Key: wk_` ## Getting an API key For campaigns/devices/messages below: from your Partner Dashboard, go to **Settings → API Keys → New key**. For the Rooms API (Iris live chat) instead: generate a separate key from Iris's own Settings page — see "Rooms API" below, that key type is fully independent from this one. Either way, the raw key (`wk_...`) is shown once, at creation — copy it immediately, since it cannot be retrieved again afterward (only its prefix remains visible in the dashboard). Treat it as a secret: store it on your own backend, and never call these endpoints from client-side or browser code. A key can optionally be restricted to specific source IPs or CIDR ranges (shared allowlist across all your keys, any app); by default it accepts requests from anywhere. ## Creating a campaign Every send goes through a campaign — there is no endpoint for a one-off send outside one. You can create one from your dashboard, or directly from your own backend: - **`GET /v1/devices`** / **`GET /v1/device-groups`** — look up a `device_id` or `device_group_id` to assign as the campaign's sender. - **`POST /v1/campaigns`** — creates the campaign. `source` is always `"api"` — there's no field for it, and no way to override it — since this endpoint only ever exists to feed the send endpoints below. Comes back `status: "draft"`; activate it from your dashboard's Campaigns page before it accepts sends. - **`GET /v1/campaigns`** — list your API-mode campaigns (`status=active` by default). ## Sending a message Create an API-mode campaign (above) from your dashboard or via `POST /v1/campaigns`, activate it, then send either: - **`POST /v1/campaigns/{id}/messages`** — one specific recipient per call, sent any time. - **`POST /v1/campaigns/{id}/broadcasts`** — every contact carrying a given label, in one call. This queues the send and returns immediately; the fan-out happens in the background, so the call stays fast no matter how large the label is. Poll `GET /v1/campaigns/{id}/broadcasts/{broadcast_id}` for progress. Both accept an optional image/PDF attachment the same way — see the field tables below. ## Managing a device An internal admin still provisions each device (picks its name, assigns a server) — partners can't create, delete, or reassign one through the API, only your dashboard's admin can. Once a device exists, your own backend can drive its connection lifecycle by name (the same name shown in your dashboard — there's no internal ID to track): - **`GET /v1/devices/{name}/qr`** — starts/refreshes the WhatsApp session and returns a QR code to scan, or `connected: true` if it's already paired. Poll this while showing the QR to pair. - **`POST /v1/devices/{name}/disconnect`** — unlinks the currently-paired number, keeping the device slot (name, group, history) ready for a fresh QR scan via the endpoint above. - **`GET /v1/devices/{name}/status`** — a live check against the device's current WhatsApp connection, not just the last value your dashboard showed. ## Checking delivery status A single-recipient send returns a `trace_id`. Poll `GET /v1/messages/{trace_id}` with that value to get its current status. For a broadcast, poll `GET /v1/campaigns/{id}/broadcasts/{broadcast_id}` for aggregate progress, or `GET /v1/campaigns/{id}/broadcasts/{broadcast_id}/messages` for each individual recipient's own trace_id and status. ## Rooms API (Iris live chat) Read and reply to your Iris conversations from your own backend. As of 2026-07-23 this uses its **own separate API key**, generated in Iris's own Settings page (not the Partner Dashboard) — a key generated there only ever carries `manage_rooms`, and a Petasos-generated key carries everything except `manage_rooms`. The two key types share this same `/v1` path space and this same spec, but are otherwise fully independent, including rate limits (see below): - **`GET /v1/rooms`** — list rooms, most recently active first. - **`GET /v1/rooms/{id}`** — one room plus its message history. - **`POST /v1/rooms/{id}/messages`** — send a reply into an open room. Sent by "the API," not a specific human agent (`sender_user_id` is `null` in the response) — unlike a reply typed in the Iris UI, this is never blocked by an agent clock-in check. - **`POST /v1/rooms/{id}/close`** — close a room. Idempotent. A Chat Webhook (configured in Iris's own Settings, independent from the Status Webhook above) can notify your backend of new chat messages instead of polling — see Iris's own API docs page for its payload shape. ## Scopes | Scope | Required for | |---|---| | `send_message` | `POST /v1/campaigns`, `GET /v1/campaigns`, `POST /v1/campaigns/{id}/messages`, `POST /v1/campaigns/{id}/broadcasts` | | `read_status` | `GET /v1/messages/{trace_id}`, `GET /v1/campaigns/{id}/broadcasts/{broadcast_id}`, `GET /v1/campaigns/{id}/broadcasts/{broadcast_id}/messages` | | `manage_devices` | `GET /v1/devices`, `GET /v1/device-groups`, `GET /v1/devices/{name}/qr`, `POST /v1/devices/{name}/disconnect`, `GET /v1/devices/{name}/status` | | `manage_rooms` | `GET /v1/rooms`, `GET /v1/rooms/{id}`, `POST /v1/rooms/{id}/messages`, `POST /v1/rooms/{id}/close` | ## Idempotency POST endpoints accept an `Idempotency-Key` header. Repeating the same key within 24 hours returns the original response instead of creating a duplicate send. ## Rate limits Requests are limited per partner, with a default of 300 requests/minute — but Petasos-app keys (campaigns/devices/messages) and Iris-app keys (Rooms API) draw from two **independent** buckets, each shared across every key of that app you hold (minting more keys of one app doesn't raise that app's ceiling, and heavy Rooms API traffic can't starve your campaign sends, or vice versa). Each response includes `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers for whichever bucket that request drew from; exceeding it returns an immediate `429`. ## Errors Every error response has the shape `{ "success": false, "error": "", "error_code": "" }`. `error_code` is a stable, machine-readable identifier safe to branch on in code — `error` is a human-readable message that may be reworded over time. A `500` always returns the generic `internal_error` code with no further detail (the real cause is logged on our side); if one persists, contact support with the `X-Request-Id` response header from that call. | error_code | HTTP status | Meaning | |---|---|---| | `validation_error` | 400 | Request body failed field validation | | `invalid_request_body` | 400 | Body isn't valid JSON | | `invalid_campaign_id` / `invalid_broadcast_id` / `invalid_trace_id` | 400 | A path parameter isn't a valid UUID | | `campaign_not_api_mode` | 400 | Campaign's source isn't `"api"` | | `campaign_not_active` | 400 | Campaign hasn't been activated, or is paused/completed | | `campaign_device_required` | 400 | Campaign has no device assigned | | `campaign_device_ambiguous` | 400 | Set exactly one of `device_id`/`device_group_id`, not both | | `device_or_group_not_found` | 400 | `device_id`/`device_group_id` doesn't exist, or belongs to another partner (`POST /campaigns`) | | `scheduled_at_in_past` | 400 | `scheduled_at` must be in the future | | `recipient_opted_out` | 400 | Recipient has previously opted out | | `no_recipients` | 400 | No contacts matched the given label | | `media_required` | 400 | `media_mimetype` missing, or neither media field set when required | | `media_invalid_type` | 400 | Only `image/*` and `application/pdf` are accepted | | `device_no_server` | 400 | Device is awaiting server assignment from an admin — not connectable yet | | `send_limit_exceeded` | 400 | Partner is in demo mode and this send would exceed its daily/weekly/monthly send cap — not returned for a production partner | | `unauthorized` | 401 | Missing or invalid API key | | `forbidden` / `ip_not_allowed` | 403 | Key lacks the required scope, or the request's source IP isn't on the key's allowlist | | `api_access_not_on_plan` | 403 | The partner's current plan doesn't include API access (e.g. after a downgrade) — the key is still valid, the plan isn't. Applies to every endpoint, within ~60s of a plan change | | `campaign_not_found` / `label_not_found` / `broadcast_not_found` / `message_not_found` / `device_not_found` / `room_not_found` | 404 | Resource doesn't exist, or belongs to another partner | | `room_not_open` | 400 | Room is closed — only open rooms can receive a reply | | `message_too_long` | 400 | Message body exceeds the 1200 character limit (`POST /rooms/{id}/messages`) | | `rate_limited` | 429 | Per-partner request rate exceeded | | `internal_error` | 500 | Unexpected failure — no further detail is ever included | servers: - url: /v1 description: Partner API security: - ApiKeyAuth: [] components: securitySchemes: ApiKeyAuth: type: apiKey in: header name: X-API-Key schemas: Error: type: object properties: success: type: boolean example: false error: type: string description: Human-readable message — may be reworded over time. error_code: type: string description: Stable, machine-readable code. See the Errors table above. example: "campaign_not_active" MessageStatus: type: string enum: [queued, sent, delivered, read, failed, dead_lettered, opted_out_skipped] BroadcastStatus: type: string enum: [pending, processing, completed, error] Room: type: object properties: id: { type: string, format: uuid } device_id: { type: string, format: uuid } contact_id: { type: string, format: uuid } contact_phone: { type: string } contact_name: { type: string, nullable: true } is_group: { type: boolean, description: "True for a WhatsApp group room — only ever populated when the partner's account has Group Chat enabled." } device_session: { type: string } status: { type: string, enum: [open, closed] } assigned_user_id: { type: string, format: uuid, nullable: true } last_message_at: { type: string, format: date-time } closed_at: { type: string, format: date-time, nullable: true } ChatMessage: type: object properties: id: { type: string, format: uuid } room_id: { type: string, format: uuid } direction: { type: string, enum: [inbound, outbound] } body: { type: string } sender_user_id: type: string format: uuid nullable: true description: null for an inbound message, or any outbound reply not sent by a human agent (e.g. via this API). status: { type: string, enum: [sent, delivered, read, failed, received] } message_type: { type: string, enum: [text, image, sticker, document] } media_url: { type: string } media_mimetype: { type: string } media_filename: { type: string } created_at: { type: string, format: date-time } SendCampaignMessageRequest: type: object required: [phone] properties: phone: type: string description: Recipient phone number (E.164 preferred). Required. example: "+628123456789" variables: type: object additionalProperties: type: string description: Optional. Template variables substituted into the campaign's message body. example: name: "Budi" scheduled_at: type: string format: date-time nullable: true description: Optional. Sends at a future time instead of immediately. Must be in the future. media_url: type: string description: > Optional. A file you already host, used as-is with no storage on our side. Mutually exclusive with media_base64. Requires media_mimetype. media_base64: type: string description: > Optional. Raw file data — we decode and store it once, then send from our own URL. Mutually exclusive with media_url. Requires media_mimetype. media_mimetype: type: string description: Required whenever media_url or media_base64 is set. Only image/* and application/pdf are accepted. example: "image/jpeg" media_filename: type: string description: Optional display filename for the attachment. example: "promo.jpg" SendCampaignMessageMultipartRequest: type: object required: [phone] description: > The multipart/form-data alternative to SendCampaignMessageRequest — upload a file directly instead of media_url/media_base64. variables is a JSON-object string field here, not nested form fields. properties: phone: type: string description: Recipient phone number (E.164 preferred). Required. example: "+628123456789" file: type: string format: binary description: Optional. The image/PDF to send, up to 16MB. variables: type: string description: 'Optional. Template variables as a JSON object string, e.g. {"name":"Budi"}.' scheduled_at: type: string format: date-time description: Optional. Sends at a future time instead of immediately. Must be in the future. media_mimetype: type: string description: Optional — inferred from the uploaded file's Content-Type if omitted. media_filename: type: string description: Optional — inferred from the uploaded file's name if omitted. SendCampaignMessageResponse: type: object properties: success: type: boolean data: type: object properties: trace_id: type: string description: > Opaque lookup token, {partner_prefix}-{uuid} — the only identifier you need to track this message. Poll it via GET /v1/messages/:trace_id. status: type: string enum: [queued, scheduled, failed] description: > "failed" (still returned with HTTP 201) means the call succeeded but a required template variable was missing. It's visible in the campaign's traffic list rather than surfaced as an error. MessageStatusResponse: type: object properties: success: type: boolean data: type: object properties: trace_id: type: string description: > The only identifier you need — internal identifiers (message id, provider message id) are never exposed here. status: $ref: "#/components/schemas/MessageStatus" queued_at: type: string format: date-time description: When this message was accepted into the queue. sent_at: type: string format: date-time nullable: true delivered_at: type: string format: date-time nullable: true read_at: type: string format: date-time nullable: true description: > Set when the recipient opens the message (blue ticks) or plays a voice note. Reached from "delivered" — not set for every message, since not every recipient opens WhatsApp with read receipts on. failed_reason: type: string CreateBroadcastRequest: type: object required: [label_name] properties: label_name: type: string description: > Send to every contact carrying this label, identified by name (not ID — labels are unique per account by name, and your dashboard never shows a label's ID). Required. example: "vip-customers" variables: type: object additionalProperties: type: string description: > Optional. Uniform defaults applied to every recipient — each contact's own stored fields override these on a per-key conflict (a contact-specific {name} always wins). example: promo_code: "SAVE20" scheduled_at: type: string format: date-time nullable: true description: Optional. Sends at a future time instead of immediately. Must be in the future. media_url: type: string description: > Optional. One shared attachment for the whole broadcast. A file you already host, used as-is. Mutually exclusive with media_base64. Requires media_mimetype. media_base64: type: string description: > Optional. Raw file data — we decode and store it once, then send from our own URL. Mutually exclusive with media_url. Requires media_mimetype. media_mimetype: type: string description: Required whenever media_url or media_base64 is set. Only image/* and application/pdf are accepted. example: "image/jpeg" media_filename: type: string description: Optional display filename for the attachment. example: "promo.jpg" CreateBroadcastMultipartRequest: type: object required: [label_name] description: > The multipart/form-data alternative to CreateBroadcastRequest — upload the shared attachment directly instead of media_url/media_base64. properties: label_name: type: string description: Send to every contact carrying this label, identified by name. Required. example: "vip-customers" file: type: string format: binary description: Optional. One shared attachment for the whole broadcast, up to 16MB. variables: type: string description: 'Optional. Uniform defaults as a JSON object string, e.g. {"promo_code":"SAVE20"}.' scheduled_at: type: string format: date-time description: Optional. Sends at a future time instead of immediately. Must be in the future. BroadcastResponse: type: object properties: success: type: boolean data: type: object properties: broadcast_id: type: string format: uuid campaign_id: type: string format: uuid label_id: type: string format: uuid description: The resolved label's internal ID — informational only, not something you need to track. label_name: type: string status: $ref: "#/components/schemas/BroadcastStatus" contact_count: type: integer description: Number of contacts resolved for this label at creation time. sent: type: integer description: Filled in once processing completes. scheduled: type: integer description: Recipients written as 'scheduled' instead of sent immediately (only when scheduled_at is set). skipped_opted_out: type: integer description: Recipients skipped because they'd previously opted out. failed: type: integer description: Recipients whose message was missing a required template variable. created_at: type: string format: date-time completed_at: type: string format: date-time nullable: true BroadcastMessagesResponse: type: object properties: success: type: boolean data: type: object properties: items: type: array items: type: object properties: trace_id: type: string phone: type: string status: $ref: "#/components/schemas/MessageStatus" total: type: integer DeviceConnectResponse: type: object properties: success: type: boolean data: type: object properties: connected: type: boolean description: True if the device is already paired — qr_image_base64 is omitted in that case. raw_status: type: string description: >- Normalized connection status: starting, awaiting_scan, connected, disconnected, failed, not_found, or unknown. retrying: type: boolean description: True if the last connection attempt failed and a fresh QR is being prepared — poll again shortly. qr_image_base64: type: string description: Base64-encoded QR code image (PNG) to scan. Only present when connected is false and a code is ready. DeviceStatusResponse: type: object properties: success: type: boolean data: type: object properties: is_connected: type: boolean phone_number: type: string description: The paired WhatsApp number, once connected. name: type: string description: The WhatsApp account display name, once connected. raw_status: type: string description: >- Normalized connection status: starting, awaiting_scan, connected, disconnected, failed, not_found, or unknown. Device: type: object properties: id: { type: string, format: uuid } name: { type: string, description: "The device's name, exactly as shown in your dashboard — pass this as `:name` to the Connect/Disconnect/Status endpoints below." } phone_number: { type: string, description: "Nullable/empty until paired." } health_score: { type: number } connected_since: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } petasos_enabled: { type: boolean, description: "Admin-set. If false, this device can't be assigned to a new campaign (device_disabled_for_petasos) and any send already assigned to it will fail." } iris_enabled: { type: boolean, description: "Admin-set. If false, inbound messages on this device never open/append to an Iris room." } DeviceGroup: type: object properties: id: { type: string, format: uuid } name: { type: string } strategy: { type: string, enum: [failover, round_robin] } devices: type: array items: $ref: "#/components/schemas/Device" description: >- Only currently-connected members — a disconnected/pending device isn't usable for dispatch, so it's omitted here. Check your dashboard for the full roster including disconnected devices. created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } Campaign: type: object properties: id: { type: string, format: uuid } name: { type: string } template: { type: string } status: { type: string, enum: [draft, scheduled, active, executed, paused, completed, failed, expired] } source: { type: string, enum: [api], description: "Always \"api\" for a campaign created through this endpoint." } device_id: { type: string, format: uuid, nullable: true } device_group_id: { type: string, format: uuid, nullable: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } CreateCampaignRequest: type: object required: [name, template] properties: name: type: string description: Campaign name, shown in your dashboard. example: "July Promo" template: type: string maxLength: 1200 description: >- Message body template — {field} placeholders substituted per-recipient at send time. Max 1200 characters (error_code validation_error if the template itself is over). Separately, if a resolved per-recipient message (template + merged variables) ends up over 1200 characters after substitution, that one recipient's message is created with status "failed" (same as a missing template variable) rather than rejecting the whole campaign — check status/failed_reason via the message-status endpoints. example: "Hi {name}, enjoy 20% off this week!" device_id: type: string format: uuid description: Sending device. Exactly one of device_id/device_group_id required. device_group_id: type: string format: uuid description: Sending device group. Exactly one of device_id/device_group_id required. paths: /devices: get: summary: List your devices description: | Every device provisioned for your account — the way to look up a `device_id` to pass into `POST /campaigns`, without ever needing to track an internal ID anywhere else. Requires scope: `manage_devices` operationId: listDevices responses: "200": description: Device list content: application/json: schema: type: object properties: success: type: boolean data: type: object properties: items: type: array items: $ref: "#/components/schemas/Device" "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the manage_devices scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "429": description: Rate limit exceeded (error_code rate_limited) /device-groups: get: summary: List your device groups description: | Every device group (ordered device pool) on your account — the way to look up a `device_group_id` to pass into `POST /campaigns` instead of a single `device_id`. Each group's `devices` list only includes currently-connected members. Requires scope: `manage_devices` operationId: listDeviceGroups responses: "200": description: Device group list content: application/json: schema: type: object properties: success: type: boolean data: type: object properties: items: type: array items: $ref: "#/components/schemas/DeviceGroup" "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the manage_devices scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "429": description: Rate limit exceeded (error_code rate_limited) /campaigns: post: summary: Create an API-mode campaign description: | Creates a campaign from your own backend instead of the dashboard. `source` is always `"api"` — there's no field for it, and no way to override it — since this endpoint only ever exists to feed `POST /campaigns/{id}/messages` and `.../broadcasts` afterward. Comes back `status: "draft"`; activate it from your dashboard's Campaigns page before it accepts sends. Has no `label_ids`/`contact_ids`/scheduling fields — those only apply to dashboard/csv campaigns. Requires scope: `send_message` operationId: createCampaign parameters: - name: Idempotency-Key in: header schema: type: string description: Client-generated unique key. Repeating the same key within 24h returns the cached response. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateCampaignRequest" responses: "201": description: Campaign created, status "draft" content: application/json: schema: type: object properties: success: type: boolean data: $ref: "#/components/schemas/Campaign" "400": description: See the Errors table above (validation_error, campaign_device_required, campaign_device_ambiguous, device_or_group_not_found) content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the send_message scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "429": description: Rate limit exceeded (error_code rate_limited) get: summary: List your API-mode campaigns description: | Always scoped to `source: "api"` — dashboard/csv campaigns aren't returned here. Defaults to `status=active` (the only status that can actually accept sends), but any status can be requested explicitly. Requires scope: `send_message` operationId: listCampaigns parameters: - name: status in: query schema: type: string enum: [draft, scheduled, active, executed, paused, completed, failed, expired] default: active - name: limit in: query schema: type: integer default: 50 - name: offset in: query schema: type: integer default: 0 responses: "200": description: Campaign list content: application/json: schema: type: object properties: success: type: boolean data: type: object properties: items: type: array items: $ref: "#/components/schemas/Campaign" total: type: integer "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the send_message scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "429": description: Rate limit exceeded (error_code rate_limited) /campaigns/{id}/messages: post: summary: Send a message through an API-mode campaign description: | The API-mode counterpart to a dashboard/CSV Run — one recipient per call, sent any time, each with its own template variables, schedule, and optional media attachment. Unlike dashboard/CSV campaigns, which share a single attachment across the whole campaign, API-mode lets every call attach its own image or PDF. The campaign must belong to your account, have `source: "api"`, and be `status: "active"` — activate it from the dashboard first, since `draft` or `paused` campaigns reject sends with a 400. Messages are sent through the device the campaign was created with. Requires scope: `send_message` **Fields** | Field | Type | Required | Description | |---|---|---|---| | `phone` | string | Yes | Recipient phone number (E.164 preferred) | | `variables` | object | No | Template variables, e.g. `{"name": "Budi"}` | | `scheduled_at` | date-time | No | Send later instead of immediately; must be in the future | | `media_url` | string | No | A file you already host — mutually exclusive with `media_base64` | | `media_base64` | string | No | Raw file data we store once — mutually exclusive with `media_url` | | `media_mimetype` | string | Required with either media field | `image/*` or `application/pdf` only | | `media_filename` | string | No | Display filename for the attachment | **Or, send `multipart/form-data` instead** to upload a file directly — no hosting, no base64 encoding. In Postman: Body → form-data → add a `file` field, pick a file. Fields: `phone`, `file` (the attachment), `variables` (a JSON-object *string*, not nested form fields), `scheduled_at`, `media_mimetype` (optional — inferred from the file if omitted), `media_filename` (optional — inferred from the file if omitted). operationId: sendCampaignMessage parameters: - name: id in: path required: true schema: type: string format: uuid description: Campaign ID - name: Idempotency-Key in: header schema: type: string description: Client-generated unique key. Repeating the same key within 24h returns the cached response. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/SendCampaignMessageRequest" multipart/form-data: schema: $ref: "#/components/schemas/SendCampaignMessageMultipartRequest" responses: "201": description: Message created — see the `status` field; a missing template variable still returns 201 with status "failed" content: application/json: schema: $ref: "#/components/schemas/SendCampaignMessageResponse" "400": description: See the Errors table above (validation_error, campaign_not_api_mode, campaign_not_active, scheduled_at_in_past, recipient_opted_out, media_required, media_invalid_type, etc.) content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the send_message scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "404": description: Campaign not found, or belongs to another partner (error_code campaign_not_found) "429": description: Rate limit exceeded (error_code rate_limited) /campaigns/{id}/broadcasts: post: summary: Send a message to every contact carrying a label description: | Sends to an entire audience in one call instead of looping per recipient yourself. The call only resolves the label's contact count and queues the job — it responds immediately with `202 Accepted` regardless of label size, and the actual fan-out (personalizing each recipient's message from their own stored fields, skipping anyone opted out, sending through your device's queue) happens in the background. Poll the returned `broadcast_id` via `GET /campaigns/{id}/broadcasts/{broadcast_id}` for progress. Same eligibility rules as a single send: the campaign must belong to your account, have `source: "api"`, and be `status: "active"`. Requires scope: `send_message` **Fields** | Field | Type | Required | Description | |---|---|---|---| | `label_name` | string | Yes | Send to every contact carrying this label, by name (labels are unique per account by name — the dashboard never shows a label's ID) | | `variables` | object | No | Uniform defaults; each contact's own fields override these | | `scheduled_at` | date-time | No | Send later instead of immediately; must be in the future | | `media_url` | string | No | One shared attachment for the whole broadcast — mutually exclusive with `media_base64` | | `media_base64` | string | No | Raw file data we store once — mutually exclusive with `media_url` | | `media_mimetype` | string | Required with either media field | `image/*` or `application/pdf` only | | `media_filename` | string | No | Display filename for the attachment | **Or, send `multipart/form-data` instead** to upload the shared attachment directly — same idea as the single-send endpoint. Fields: `label_name`, `file`, `variables` (a JSON-object *string*), `scheduled_at`. operationId: createBroadcast parameters: - name: id in: path required: true schema: type: string format: uuid description: Campaign ID - name: Idempotency-Key in: header schema: type: string description: Client-generated unique key. Repeating the same key within 24h returns the cached response. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateBroadcastRequest" multipart/form-data: schema: $ref: "#/components/schemas/CreateBroadcastMultipartRequest" responses: "202": description: Broadcast queued — status starts "pending" content: application/json: schema: $ref: "#/components/schemas/BroadcastResponse" "400": description: See the Errors table above (validation_error, campaign_not_api_mode, campaign_not_active, scheduled_at_in_past, no_recipients, media_required, media_invalid_type, etc.) content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the send_message scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "404": description: Campaign or label not found, or belongs to another partner (error_code campaign_not_found / label_not_found) "429": description: Rate limit exceeded (error_code rate_limited) /campaigns/{id}/broadcasts/{broadcast_id}: get: summary: Get a broadcast's progress description: | Returns the current status and recipient counts for a broadcast created via `POST /campaigns/{id}/broadcasts`. Requires scope: `read_status` operationId: getBroadcastStatus parameters: - name: id in: path required: true schema: type: string format: uuid description: Campaign ID - name: broadcast_id in: path required: true schema: type: string format: uuid responses: "200": description: Broadcast status content: application/json: schema: $ref: "#/components/schemas/BroadcastResponse" "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the read_status scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "404": description: Broadcast not found — including if it belongs to a different campaign than the one in the path (error_code broadcast_not_found) /campaigns/{id}/broadcasts/{broadcast_id}/messages: get: summary: List a broadcast's recipient messages description: | Returns the trace_id/phone/status of every message this broadcast fanned out. No internal identifiers (message id, provider message id) are included — trace_id is the only one you need. Requires scope: `read_status` operationId: listBroadcastMessages parameters: - name: id in: path required: true schema: type: string format: uuid description: Campaign ID - name: broadcast_id in: path required: true schema: type: string format: uuid - name: limit in: query schema: type: integer default: 50 - name: offset in: query schema: type: integer default: 0 responses: "200": description: Broadcast recipient messages content: application/json: schema: $ref: "#/components/schemas/BroadcastMessagesResponse" "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the read_status scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "404": description: Broadcast not found — including if it belongs to a different campaign than the one in the path (error_code broadcast_not_found) /messages/{trace_id}: get: summary: Get a message's delivery status description: | Returns the current status for a message, identified by the `trace_id` returned when it was sent. No internal identifiers or event timeline are included — just the flat current state. Requires scope: `read_status` operationId: getMessageStatus parameters: - name: trace_id in: path required: true schema: type: string format: uuid responses: "200": description: Message status content: application/json: schema: $ref: "#/components/schemas/MessageStatusResponse" "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the read_status scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "404": description: Message not found (error_code message_not_found) /devices/{name}/qr: get: summary: Connect a device (get a QR code to scan) description: | Starts or refreshes the device's WhatsApp session and returns a QR code to scan with the WhatsApp app (Linked Devices → Link a Device). Returns `connected: true` instead if the device is already paired. Poll this endpoint (e.g. every 2-3 seconds) while showing the QR to the person pairing it — the code refreshes periodically and this call keeps the session alive and up to date. `retrying: true` means the last attempt failed and a fresh code is being prepared; just keep polling. The device must already exist — created by an internal admin, same as your dashboard. This endpoint only drives its connection state, it never creates one. Requires scope: `manage_devices` operationId: connectDevice parameters: - name: name in: path required: true schema: type: string description: The device's name, exactly as shown in your dashboard. responses: "200": description: Connection state — see connected/qr_image_base64/retrying content: application/json: schema: $ref: "#/components/schemas/DeviceConnectResponse" "400": description: Device is awaiting server assignment from an admin (error_code device_no_server) content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the manage_devices scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "404": description: No device with that name on your account (error_code device_not_found) "429": description: Rate limit exceeded (error_code rate_limited) /devices/{name}/disconnect: post: summary: Disconnect a device description: | Unlinks the currently-paired WhatsApp number from the device, the same as unlinking it from WhatsApp's own Linked Devices screen. The device slot itself — its name, group, and history — is kept, so `GET /v1/devices/{name}/qr` can pair it again (with the same or a different number) at any time. Requires scope: `manage_devices` operationId: disconnectDevice parameters: - name: name in: path required: true schema: type: string description: The device's name, exactly as shown in your dashboard. - name: Idempotency-Key in: header schema: type: string description: Client-generated unique key. Repeating the same key within 24h returns the cached response. responses: "200": description: Disconnected content: application/json: schema: type: object properties: success: type: boolean "400": description: Device is awaiting server assignment from an admin (error_code device_no_server) content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the manage_devices scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "404": description: No device with that name on your account (error_code device_not_found) "429": description: Rate limit exceeded (error_code rate_limited) /devices/{name}/status: get: summary: Get a device's live connection status description: | Checks the device's current WhatsApp connection directly, rather than returning the last value your dashboard happened to show — useful for confirming a pairing actually completed, or detecting a disconnect before your next send. Requires scope: `manage_devices` operationId: getDeviceStatus parameters: - name: name in: path required: true schema: type: string description: The device's name, exactly as shown in your dashboard. responses: "200": description: Live device status content: application/json: schema: $ref: "#/components/schemas/DeviceStatusResponse" "400": description: Device is awaiting server assignment from an admin (error_code device_no_server) content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the manage_devices scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "404": description: No device with that name on your account (error_code device_not_found) "429": description: Rate limit exceeded (error_code rate_limited) /rooms: get: summary: List rooms description: | Lists your Iris rooms, most recently active first. Requires scope: `manage_rooms` operationId: listRooms parameters: - name: status in: query schema: type: string enum: [open, closed] description: Omit to return both. - name: device_id in: query schema: type: string format: uuid description: Filter to one device. - name: limit in: query schema: type: integer default: 50 - name: offset in: query schema: type: integer default: 0 responses: "200": description: Room list content: application/json: schema: type: object properties: success: type: boolean data: type: object properties: rooms: type: array items: $ref: "#/components/schemas/Room" total: type: integer "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the manage_rooms scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "429": description: Rate limit exceeded (error_code rate_limited) /rooms/{id}: get: summary: Get a room and its messages description: | Requires scope: `manage_rooms` operationId: getRoom parameters: - name: id in: path required: true schema: type: string format: uuid - name: limit in: query schema: type: integer default: 100 - name: offset in: query schema: type: integer default: 0 responses: "200": description: Room detail content: application/json: schema: type: object properties: success: type: boolean data: allOf: - $ref: "#/components/schemas/Room" - type: object properties: messages: type: array items: $ref: "#/components/schemas/ChatMessage" messages_total: type: integer "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the manage_rooms scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "404": description: No room with that ID on your account (error_code room_not_found) "429": description: Rate limit exceeded (error_code rate_limited) /rooms/{id}/messages: post: summary: Send a reply into a room description: | Sent by "the API," not a specific human agent — `sender_user_id` is `null` in the response, and unlike a reply typed in the Iris UI, this is never blocked by an agent clock-in check. Requires scope: `manage_rooms` operationId: sendRoomMessage parameters: - name: id in: path required: true schema: type: string format: uuid requestBody: required: true content: application/json: schema: type: object properties: body: type: string maxLength: 1200 description: >- Message text / caption. At least one of body/media_url is required. Max 1200 characters (error_code message_too_long). media_url: type: string description: >- Hosted image/document URL to attach — this endpoint has no file-upload mode and no separate media_mimetype field; the mimetype is inferred from the URL's file extension (image/* or application/pdf). responses: "201": description: Sent content: application/json: schema: type: object properties: success: type: boolean data: $ref: "#/components/schemas/ChatMessage" "400": description: >- Room is closed (error_code room_not_open), neither body nor media_url given (error_code validation_error), body exceeds 1200 characters (error_code message_too_long), media_url's extension isn't recognized as a supported mimetype (error_code media_required), or an unsupported mimetype was inferred (error_code invalid_media_type) content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the manage_rooms scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "404": description: No room with that ID on your account (error_code room_not_found) "429": description: Rate limit exceeded (error_code rate_limited) /rooms/{id}/close: post: summary: Close a room description: | Idempotent — closing an already-closed room is a no-op success. Requires scope: `manage_rooms` operationId: closeRoom parameters: - name: id in: path required: true schema: type: string format: uuid responses: "200": description: Closed content: application/json: schema: type: object properties: success: type: boolean data: type: object properties: message: type: string example: "room closed" "401": description: Missing or invalid API key (error_code unauthorized) "403": description: Key lacks the manage_rooms scope, or the source IP isn't on the key's allowlist (error_code forbidden / ip_not_allowed) "404": description: No room with that ID on your account (error_code room_not_found) "429": description: Rate limit exceeded (error_code rate_limited)