# Wapisimo API Documentation > Wapisimo is a multi-tenant WhatsApp API. Create phone connections, pair them by scanning a QR code, then send and receive WhatsApp messages over HTTP. Inbound activity is pushed to your webhooks and retained in a queryable event history. Base URL: `https://api.wapisimo.dev/v1` Human-readable version: https://app.wapisimo.dev/docs/ ## Contents - [Authentication](#authentication) - [Account Endpoints](#account-endpoints) - [Phone Endpoints](#phone-endpoints) - [Official Cloud API](#official-cloud-api) - [Message Templates](#message-templates) - [Event History](#event-history) - [Event Samples](#event-samples) - [Phone Groups](#phone-groups) - [Selection Strategies](#selection-strategies) - [Contact Affinity](#contact-affinity) - [Send via Group](#send-via-group) - [Media Types](#media-types) - [Webhook Events](#webhook-events) - [Natural Mode](#natural-mode) - [Rate Limits](#rate-limits) - [Status Codes](#status-codes) ## Authentication All requests authenticate with a bearer token in the `Authorization` header. ```http Authorization: Bearer YOUR_API_KEY ``` There are three kinds of key, and which one a route accepts is part of that route's contract: - **Account key** — found in dashboard Settings. Covers everything the account owns. Required for creating and deleting phones, and for the account-wide event read. Rotate it from the same screen if it leaks: the replacement is live immediately and the old key starts returning `401` on the next request, with no grace period. - **Phone key** — returned when you create a phone. Scoped to that one phone. - **Group key** — belongs to a phone group, and is scoped to that group's members. Routes shaped `/v1/{phone_id}/...` accept either that phone's key or the account key. Routes shaped `/v1/{group_id}/...` accept either that group's key or the account key. `/v1/phones` and `/v1/events` accept the account key only, because a per-phone key must not be able to widen its own scope to the rest of the account. `X-API-Key` is used internally by Wapisimo. It is not a customer-facing header and will not authenticate you. A missing, unknown, or wrong-scope key returns `401`. A malformed instance id in the path also returns `401` rather than `404`, so a wrong id and a wrong key are indistinguishable to the caller by design. ## Account Endpoints Manage the phones in your account. These require the account key. ### Create Phone `POST /v1/phones` Auth: Account key only Create a new phone in your account. It starts disconnected and must be paired by fetching a QR code and scanning it from WhatsApp on the handset. **Body parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_number` | string | yes | Digits only, country code included, no "+". | | `name` | string | no | Label shown in the dashboard. | **Request** ```json { "phone_number": "5215512345678", "name": "Support line" } ``` **Response (201)** ```json { "phone_number": "5215512345678", "name": "Support line", "id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "api_key": "a91c4e77-2b58-4f03-9d16-6c8e2a4b5f90" } ``` **Notes** - The number comes back as `phone_number` here, but as `number` everywhere else. This response is assembled by hand rather than returned from the row, and the naming has never been reconciled. - The `api_key` in the response is this phone's key. You do not have to store it — List Phones returns it too — but treat it as a credential either way. - Creating a phone consumes a subscription slot. ### List Phones `GET /v1/phones` Auth: Account key only List every phone in the account that has not been deleted. **Response (200)** ```json [ { "id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "number": "5215512345678", "name": "Support line", "status": "connected", "provider": "cloud_api", "api_key": "a91c4e77-2b58-4f03-9d16-6c8e2a4b5f90", "user_id": "d2b8f1a6-3c07-4e95-b8d4-0f6a2c9e1b53" } ] ``` **Notes** - `status` is `connected` or `disconnected`. Deleted phones are omitted from this list. - `provider` is `cloud_api` for official WhatsApp Cloud API connections, or `baileys` for QR-linked (WhatsApp Web) phones. - A newly created phone is `disconnected` until somebody scans its QR code. - Each phone's `api_key` is included, so a lost phone key can be recovered here with the account key. - The response can include fields not listed above. Anything undocumented may change without notice and should not be built on. ### Delete Phone `DELETE /v1/phones/{phone_id}` Auth: Account key only Close the WhatsApp session and soft-delete the phone. It stops appearing in List Phones, stops counting toward active usage, and frees a subscription slot. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | The phone to delete. | **Response (200)** ```json { "message": "Phone deleted" } ``` **Notes** - Not reversible. Reconnecting that number means creating a new phone and pairing it again. ## Phone Endpoints Send messages and manage a single phone. These accept that phone's key or the account key. ### Send Text Message `POST /v1/{phone_id}/send` Auth: Phone key or account key Queue a text message. The response means accepted for delivery, not delivered — watch the event history or your webhook for the outcome. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | Sending phone. A group id also works; see Send via Group. | **Body parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `to` | string | yes | Recipient in international format. | | `message` | string | yes | Text body. Required unless `location` is given. | **Request** ```json { "to": "+5215512345678", "message": "Hello from Wapisimo!" } ``` **Response (202)** ```json { "status": "queued", "message": "Message has been queued for delivery", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34" } ``` **Notes** - `202` means queued. A message can still fail afterwards — WhatsApp may reject it, or the phone may not be connected. - QR-linked phones: messages drain at roughly one per second per phone to stay under WhatsApp rate limiting, and with Natural Mode on (the default) delivery is deliberately delayed further. Cloud API phones bypass both — the send goes straight to Meta, which does its own throttling. - Official Cloud API phones: inside the 24-hour customer-service window the text sends as-is. Outside the window, if the text matches one of your approved templates it is converted to that template automatically (variables extracted from the text); otherwise WhatsApp rejects it. See Send Template Message to send a template explicitly. - The text is matched against a template two ways: its BODY on its own, and the whole rendered message (header, body, footer). So both the body text you would have sent before, and a message copied verbatim out of a conversation, resolve to the same template. A dynamic TEXT header is only recoverable from the second form, because that is the only one that contains its value. - Some templates can never be reached from text alone: a media header needs an asset, and a dynamic-URL, copy-code, flow, catalog or MPM button needs a parameter. None of those appear anywhere in a sentence, so those templates are skipped rather than sent half-filled for WhatsApp to reject. To reach one, either send it explicitly (see Send Template Message) or pass a `template` object alongside your `message` carrying just those values — with no `name`, it supplies the un-inferable parts for whichever template the text matches. ### Send Media Message `POST /v1/{phone_id}/send` Auth: Phone key or account key Queue an image, video, audio file, or document. The file is fetched from your URL and forwarded to WhatsApp. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | Sending phone or group id. | **Body parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `to` | string | yes | Recipient in international format. | | `mediaUrl` | string | yes | Publicly reachable URL. Wapisimo fetches it server-side. | | `mediaType` | string | yes | One of `image`, `video`, `audio`, `document`. | | `message` | string | no | Caption. | **Request** ```json { "to": "+5215512345678", "mediaUrl": "https://example.com/invoice.pdf", "mediaType": "document", "message": "Your invoice" } ``` **Response (202)** ```json { "status": "queued", "message": "Message has been queued for delivery", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34" } ``` **Notes** - `mediaUrl` must be reachable without authentication — the fetch carries no credentials of yours. ### Send Location Message `POST /v1/{phone_id}/send` Auth: Phone key or account key Queue a location pin. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | Sending phone or group id. | **Body parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `to` | string | yes | Recipient in international format. | | `location` | object | yes | `latitude` and `longitude` as numbers; optional `name` and `address` labels. | **Request** ```json { "to": "+5215512345678", "location": { "latitude": 19.4326, "longitude": -99.1332, "name": "Zócalo", "address": "Ciudad de México" } } ``` **Response (202)** ```json { "status": "queued", "message": "Message has been queued for delivery", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34" } ``` **Notes** - A send must carry `message`, `location`, or a named `template`. Sending none of them returns `400`. (A `template` with no `name` only supplies extra values for a text send, so it does not count on its own.) ### Verify Number `GET /v1/verify` Auth: Any key Check whether a number is registered on WhatsApp. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone` | string | yes | Number to check, digits with country code. | **Response (200)** ```json { "hasWhatsApp": true, "phone": "5215512345678@s.whatsapp.net", "details": [ { "jid": "5215512345678@s.whatsapp.net", "exists": true } ] } ``` **Notes** - `verify` sits where an instance id normally goes, so the path is `/v1/verify`, not `/v1/{phone_id}/verify`. ### Get QR Code `GET /v1/{phone_id}/qr` Auth: Phone key or account key Fetch the pairing QR code for a phone. Scan it from WhatsApp on the handset (Settings → Linked devices) to move the phone to `connected`. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | Phone to pair. | **Response (200)** ```json { "status": "disconnected", "qr_code": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg..." } ``` **Notes** - QR codes are short-lived. Poll for a fresh one if the user does not scan in time. - Rate limited to 10 requests per minute. ### List WhatsApp Groups `GET /v1/{phone_id}/groups` Auth: Phone key or account key List the WhatsApp group chats this phone belongs to, with metadata and participants. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | Connected phone to read from. | **Response (200)** ```json [ { "id": "120363000000000000@g.us", "subject": "Team chat", "owner": "5215512345678@s.whatsapp.net", "creation": 1672531200, "size": 25, "participants": [ { "id": "5215512345678@s.whatsapp.net", "admin": "superadmin" }, { "id": "5215587654321@s.whatsapp.net", "admin": null } ] } ] ``` **Notes** - These are WhatsApp group chats, which are a different thing from Wapisimo phone groups. WhatsApp group ids end in `@g.us`. - Requires the phone to be connected. ### List Webhooks `GET /v1/{phone_id}/webhook` Auth: Phone key or account key List the webhooks registered for a phone. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | Phone whose webhooks to list. | **Response (200)** ```json [ { "id": "8c2d5f19-7a3b-4e60-9f81-2d4c6b8a0e75", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "url": "https://example.com/hooks/whatsapp", "name": "Order updates", "type": "new_messages", "createdAt": "2026-07-30T05:38:03.105Z", "status": "active", "lastSeen": null } ] ``` **Notes** - `status` and `lastSeen` are legacy columns that nothing currently maintains. They are returned because the row is selected whole, but do not treat them as delivery health — read the event history instead. ### Add Webhook `POST /v1/{phone_id}/webhook` Auth: Phone key or account key Register a URL to receive this phone's events. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | Phone to attach the webhook to. | **Body parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `url` | string | yes | HTTPS endpoint that receives POSTed events. | | `type` | string | no | `new_messages` (default) for inbound messages only, `all` for every event, or `proxy` (official Cloud API phones) to forward Meta's raw webhook payload verbatim. | | `name` | string | no | Label shown in the dashboard. | **Request** ```json { "url": "https://example.com/hooks/whatsapp", "type": "new_messages", "name": "Order updates" } ``` **Response (200)** ```json { "success": true, "webhook": { "id": "8c2d5f19-7a3b-4e60-9f81-2d4c6b8a0e75", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "url": "https://example.com/hooks/whatsapp", "name": "Order updates", "type": "new_messages", "createdAt": "2026-07-30T05:38:03.105Z", "status": null, "lastSeen": null } } ``` **Notes** - The created row is nested under `webhook`, not returned at the top level. - `type` is constrained to `new_messages`, `all`, or `proxy`; anything else is rejected with `400`. Omitting it gives `new_messages`. - `proxy` (official Cloud API phones only) delivers Meta's Cloud API webhook payload rather than the normalized Wapisimo shape, so an existing Cloud API integration keeps working as-is. `new_messages`/`all` deliver the normalized shape. - A `proxy` delivery only carries a verifiable `X-Hub-Signature-256` when Meta's delivery covered exactly one of your numbers. When Meta batches several numbers into one delivery, each phone receives a rebuilt body containing only its own `entry.changes` — which no longer matches Meta's signature, so the header is omitted rather than sent wrong. Verify it when present; do not require it. - Connection events reach every webhook on the phone regardless of `type`. - Rate limited to 60 requests per hour. ### Delete Webhook `DELETE /v1/{phone_id}/webhook/{webhook_id}` Auth: Phone key or account key Remove a registered webhook. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | Phone the webhook belongs to. | | `webhook_id` | uuid | yes | Webhook to remove. | **Response (200)** ```json { "success": true } ``` **Notes** - Idempotent in effect: deleting an id that does not exist on this phone still returns `success: true`. ### Resync Phone `POST /v1/{phone_id}/resync` Auth: Phone key or account key Tear down the WhatsApp session, clear stored pairing credentials, and start a fresh one so a new QR code can be issued. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | Phone to resync. | **Response (200)** ```json { "message": "Phone resynced successfully" } ``` **Notes** - Destructive: this discards pairing credentials, so somebody has to scan a new QR code before the phone can send again. - To recover a phone that merely dropped its connection, call Get QR Code first — it resumes the stored session without forcing a re-scan. ## Official Cloud API A phone is either **QR-linked** (`provider: "baileys"`, paired by scanning a code) or an **official Cloud API** connection (`provider: "cloud_api"`, your own Meta app and WhatsApp Business Account). List Phones reports which. Everything in this reference works on both unless it says otherwise; what follows is only where they diverge. | | QR-linked | Official Cloud API | | --- | --- | --- | | Connecting | Get QR Code, scanned from the handset | callback URL verified in Meta's dashboard | | Checking health | phone `status`, re-pair with Get QR Code | Check Cloud Connection | | Messaging outside the 24-hour window | no restriction | an approved template is required | | Send pacing | ~1/second per phone, plus Natural Mode | straight to Meta, which does its own throttling | | Inbound media | mirrored to a Wapisimo URL | a Meta `media_id` you fetch from Graph yourself | ### The callback URL Each Cloud API connection gets its own callback URL, which the dashboard generates when you add the phone: ``` https://api.wapisimo.dev/v1/cloud/wh/{callback_secret} ``` You do not call this endpoint — Meta does. Paste it into your app's WhatsApp webhook configuration along with the verify token from the same screen, and subscribe the app to your WABA. The `{callback_secret}` is what identifies the connection, so treat the whole URL as a credential: anyone holding it knows where your events go. **Verification (`GET`).** Meta sends `hub.mode`, `hub.verify_token` and `hub.challenge`. A matching verify token echoes the challenge back with `200`; anything else is `403`. A successful handshake also marks the phone `connected`, so a verified webhook is what moves a new connection out of `disconnected`. **Delivery (`POST`).** Inbound messages and delivery statuses arrive here. When the connection has an App Secret stored, the `X-Hub-Signature-256` header is verified and a bad signature is rejected with `401`. Every other outcome — including an unrecognized secret — answers `200`, so Meta never retries into a backlog or disables the subscription. Deliveries are routed per number by `value.metadata.phone_number_id`, not by whichever phone owns the callback URL, so one app webhook can serve every number on the account. A number belonging to a different account is dropped rather than delivered. ### Check Cloud Connection `POST /v1/cloud/check/{phone_id}` Auth: Phone key or account key Ask Meta whether the stored token and phone number id still work, and reconcile the phone's status with the answer. Official Cloud API phones only. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | Cloud phone to check. | **Response (200)** ```json { "ok": true, "status": "connected", "display_phone_number": "+52 1 55 1234 5678", "verified_name": "Acme Support", "quality_rating": "GREEN", "warnings": [] } ``` **Notes** - This endpoint writes. A successful check sets the phone to `connected` and refreshes the stored display number; a rejection from Meta sets it to `disconnected`. - A rejection is still `200` — read `ok`, not the status code. `{"ok": false, "status": "disconnected", "error": "...", "code": 190}` is what a revoked token looks like. Missing credentials come back the same way with `"reason": "missing_credentials"`. - The one case that does not touch the phone's status is `502` `graph_unreachable`, which means Wapisimo could not reach Meta. Treat it as unknown and retry. - `warnings` flags a connection that authenticates but will not work end to end: an App Secret from a different app than the token (inbound signatures will fail), or a WABA with no subscribed app or one subscribed via a different app (no inbound at all). Empty is the healthy case. - `404` means the phone id is not a Cloud API connection. QR-linked phones use Get QR Code instead. ## Message Templates Official WhatsApp Cloud API phones send pre-approved **message templates** — the only way to message a contact outside the 24-hour customer-service window. Create and get them approved in WhatsApp Manager, list them, then send by name. (Plain `Send Text Message` also auto-converts matching text to an approved template when you are outside the window.) ### Send Template Message `POST /v1/{phone_id}/send` Auth: Phone key or account key Queue an approved WhatsApp message template. Official Cloud API phones only. Templates deliver both inside and outside the 24-hour customer-service window — they are the only way to reach a contact who has not messaged you in the last 24 hours. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | Sending cloud phone. | **Body parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `to` | string | yes | Recipient in international format. | | `template` | object | yes | `name` and `language` (e.g. `es_MX`) of an APPROVED template, plus the values its blocks need: `variables` (body placeholders {{1}}, {{2}}… in order), `header` (a TEXT header's single variable as a string, or `{ "image": { "link": "…" } }` for a media header), and `buttons` (parameters for buttons that take one). `components` is still accepted as a raw Graph components array for full control. | **Request** ```json { "to": "+5215512345678", "template": { "name": "order_update", "language": "es_MX", "header": "1234", "variables": ["Ana", "hoy"], "buttons": [ { "index": 0, "type": "url", "parameter": "1234" } ] } } ``` **Response (202)** ```json { "status": "queued", "message": "Message has been queued for delivery", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34" } ``` **Notes** - The template must already be APPROVED (list them with GET /v1/cloud/templates/{phone_id}). - `variables` fills the body placeholders in order. Only pass `header` when the template actually needs it: a static TEXT header is baked into the approved template and takes no parameter, while a media header always needs one. - `buttons` entries are `{ index, type, parameter }`. `index` is the button's position in the template (0-based) and defaults to its position in the array. `type` is `url` for a dynamic URL suffix, or `copy_code`, `quick_reply`, `flow`, `catalog`, `mpm`. Buttons that take no parameter (a static URL, a phone number) are omitted. - A FOOTER never takes a parameter, so there is no `footer` field. - Meta rejects a partially filled template, so send every value the template asks for. - Prefer the plain Send Text Message when you can: outside the window, matching text is auto-converted to an approved template, so the ordinary text call keeps working. ### List Templates `GET /v1/cloud/templates/{phone_id}` Auth: Phone key or account key List the message templates on a cloud phone's WhatsApp Business Account, with their approval status. Official Cloud API phones only. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | Cloud phone whose WABA templates to list. | **Response (200)** ```json { "templates": [ { "name": "order_update", "status": "APPROVED", "category": "UTILITY", "language": "es_MX", "components": [ { "type": "BODY", "text": "Tu pedido {{1}} va en camino, llega el {{2}}." } ] } ] } ``` **Notes** - Only templates whose `status` is `APPROVED` can be sent. - Create and edit templates in WhatsApp Manager; new ones start `PENDING` until Meta reviews them. - Returns at most 100 templates and has no pagination. A WABA with more than that gets a partial list with no indication it was truncated. Template *sending* is unaffected — the matcher behind Send Text Message pages through the whole set separately. ## Event History Every phone keeps a queryable history of what happened on it: inbound and outbound messages, and connection state changes. The same reader backs three scopes, so the filters and the response shape are identical across all of them and only the breadth changes: | Scope | Path | Key | Covers | | --- | --- | --- | --- | | Phone | `/v1/{phone_id}/events` | phone or account | one phone | | Group | `/v1/{group_id}/events` | group or account | every member of that group | | Account | `/v1/events` | account only | every phone you own | Results are paginated with a keyset cursor. Pass the `next_cursor` from a response as `cursor` on the next request, and stop when `has_more` is `false`. Do not try to parse the cursor: it is an opaque base64 token whose contents are an implementation detail. Because group and account reads merge several phones into one timeline, read `phone_id` on each row to tell them apart. On a single-phone read that field is constant and can be ignored. Rate limit: 60 requests per minute. Group reads are charged to the account rather than the group, so fanning out over many groups does not multiply your budget. ### List Events for a Phone `GET /v1/{phone_id}/events` Auth: Phone key or account key Read one phone's event history, newest first by default. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone_id` | uuid | yes | Phone whose history to read. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `conversation_id` | string | no | Exact WhatsApp conversation JID, e.g. `5215512345678@s.whatsapp.net`. Max 200 characters. Cloud API rows store a bare `wa_id` here instead, so match on digits alone for those. | | `phone_number` | string | no | Filter by contact number, digits with country code. Does not match group conversations. | | `type` | enum | no | `message` or `connection`. | | `subtype` | string | no | Event subtype such as `text` or `send_failed`. Letters, digits, `_` and `-`; max 50 characters. | | `from_me` | boolean | no | `true` for outbound, `false` for inbound. | | `since` | ISO 8601 | no | Only events at or after this timestamp. | | `until` | ISO 8601 | no | Only events at or before this timestamp. | | `phone_status` | string | no | Group and account reads only. Comma-separated `connected` and/or `disconnected`; defaults to both. Rejected with `400` on a phone read. | | `order` | enum | no | `desc` (default) or `asc`, by timestamp then event_id. | | `limit` | integer | no | Page size. Default 50, maximum 200. Out-of-range and unparseable values fall back to 50. | | `cursor` | string | no | Opaque token from the previous response's `next_cursor`. | **Response (200)** ```json { "data": [ { "event_id": "a1b2c3d4-e5f6-4890-abcd-ef1234567890", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "conversation_id": "5215512345678@s.whatsapp.net", "message_id": "3EB01234567890ABCDEF", "type": "message", "subtype": "text", "from_jid": "5215512345678@s.whatsapp.net", "from_me": false, "content": "Hello!", "media_url": null, "thumbnail": null, "timestamp": "2026-07-30T10:30:00.000Z" } ], "next_cursor": "eyJ0cyI6IjIwMjYtMDctMzBUMTA6MzA6MDAuMDAwWiIsImlkIjoiYTFiMmMzZDQtZTVmNi00ODkwLWFiY2QtZWYxMjM0NTY3ODkwIn0=", "has_more": true } ``` **Notes** - `phone_status` is rejected here with `400` — it only means something on the group and account reads. - Event rows do not include the `details` object that webhook payloads carry. - All three scopes return the same row. Event Samples shows a complete row per subtype, including which fields are empty on each. ### List Events for a Phone Group `GET /v1/{group_id}/events` Auth: Group key or account key Read the merged event history of every phone in a group, interleaved into one timeline. Use `phone_id` on each row to tell members apart. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `group_id` | uuid | yes | Phone group whose members to read. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `conversation_id` | string | no | Exact WhatsApp conversation JID, e.g. `5215512345678@s.whatsapp.net`. Max 200 characters. Cloud API rows store a bare `wa_id` here instead, so match on digits alone for those. | | `phone_number` | string | no | Filter by contact number, digits with country code. Does not match group conversations. | | `type` | enum | no | `message` or `connection`. | | `subtype` | string | no | Event subtype such as `text` or `send_failed`. Letters, digits, `_` and `-`; max 50 characters. | | `from_me` | boolean | no | `true` for outbound, `false` for inbound. | | `since` | ISO 8601 | no | Only events at or after this timestamp. | | `until` | ISO 8601 | no | Only events at or before this timestamp. | | `phone_status` | string | no | Group and account reads only. Comma-separated `connected` and/or `disconnected`; defaults to both. Rejected with `400` on a phone read. | | `order` | enum | no | `desc` (default) or `asc`, by timestamp then event_id. | | `limit` | integer | no | Page size. Default 50, maximum 200. Out-of-range and unparseable values fall back to 50. | | `cursor` | string | no | Opaque token from the previous response's `next_cursor`. | **Response (200)** ```json { "data": [ { "event_id": "b2c3d4e5-f6a7-4901-bcde-f12345678901", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "conversation_id": "5215512345678@s.whatsapp.net", "message_id": "3EB0234567890ABCDEF1", "type": "message", "subtype": "text", "from_jid": "5215512345678@s.whatsapp.net", "from_me": true, "content": "On our way", "media_url": null, "thumbnail": null, "timestamp": "2026-07-30T10:31:00.000Z" } ], "next_cursor": null, "has_more": false } ``` **Notes** - Disconnected members are included by default — a phone that dropped off last week still has history worth reading. Deleted phones never are. - Narrow the member set with `phone_status`. - This read fans out across every member, so its rate limit is keyed on the account rather than the group. Group and account reads share one 60/minute budget. - Unlike a send, an events read never selects a single member and never advances the round-robin pointer. ### List Events Across the Account `GET /v1/events` Auth: Account key only Read the merged event history of every phone in the account. The widest read available, and the right one for building an inbox or syncing to your own store. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `conversation_id` | string | no | Exact WhatsApp conversation JID, e.g. `5215512345678@s.whatsapp.net`. Max 200 characters. Cloud API rows store a bare `wa_id` here instead, so match on digits alone for those. | | `phone_number` | string | no | Filter by contact number, digits with country code. Does not match group conversations. | | `type` | enum | no | `message` or `connection`. | | `subtype` | string | no | Event subtype such as `text` or `send_failed`. Letters, digits, `_` and `-`; max 50 characters. | | `from_me` | boolean | no | `true` for outbound, `false` for inbound. | | `since` | ISO 8601 | no | Only events at or after this timestamp. | | `until` | ISO 8601 | no | Only events at or before this timestamp. | | `phone_status` | string | no | Group and account reads only. Comma-separated `connected` and/or `disconnected`; defaults to both. Rejected with `400` on a phone read. | | `order` | enum | no | `desc` (default) or `asc`, by timestamp then event_id. | | `limit` | integer | no | Page size. Default 50, maximum 200. Out-of-range and unparseable values fall back to 50. | | `cursor` | string | no | Opaque token from the previous response's `next_cursor`. | **Response (200)** ```json { "data": [ { "event_id": "c3d4e5f6-a7b8-4012-cdef-123456789012", "phone_id": "9e1d4c73-5a28-4b60-8f31-7c2e5a9d0b46", "conversation_id": "5215587654321@s.whatsapp.net", "message_id": "3EB0345678901ABCDEF2", "type": "message", "subtype": "image", "from_jid": "5215587654321@s.whatsapp.net", "from_me": false, "content": "", "media_url": "https://files.wapisimo.dev/eyJtZWRpYUtleSI6IjxvcGFxdWUtdG9rZW4-In0", "thumbnail": null, "timestamp": "2026-07-30T10:32:00.000Z" } ], "next_cursor": null, "has_more": false } ``` **Notes** - Account key only. A per-phone key is rejected with `401` rather than being narrowed to its own phone. - An account with no phones returns an empty `data` array, not an error. ## Event Samples Every row in `data` carries the same twelve fields, whichever scope you read and whatever kind of event it describes. What changes between one row and the next is only which fields hold something. The samples below are complete rows, so a field that is empty here is empty in the response too. Four things to handle when you parse one: - **Fields that do not apply to an event come back empty**, as either `null` or `""`. Treat both as absent. - **`thumbnail` is reserved** and currently returns `null` on every row. - **History rows do not carry the `details` object** that webhook payloads include. Where an event has a cause, such as a disconnect or a rejected send, `content` states it. - **`from_jid` is the author of the event, not the other party.** On an outbound row it is your own number with a device suffix; in a group chat it is the participant who spoke. The other side of the conversation is `conversation_id`. ### Inbound Text Message `type`: `message` — `subtype`: `text` Someone messaged the phone. The baseline row that most of a history read consists of. ```json { "event_id": "a1b2c3d4-e5f6-4890-abcd-ef1234567890", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "conversation_id": "5215512345678@s.whatsapp.net", "message_id": "3EB01234567890ABCDEF", "type": "message", "subtype": "text", "from_jid": "5215512345678@s.whatsapp.net", "from_me": false, "content": "Hello!", "media_url": null, "thumbnail": null, "timestamp": "2026-07-30T10:30:00.000Z" } ``` - `message_id` is WhatsApp's own id and is the same value the webhook carries, so it is what you reconcile the two against. - Message kinds without a subtype of their own, such as polls and system messages, also arrive as `text` with an empty `content`. An empty `content` does not mean an empty message. ### Outbound Message `type`: `message` — `subtype`: `text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `contact` A message the phone sent, whether through the API or from the handset itself. ```json { "event_id": "b2c3d4e5-f6a7-4901-bcde-f12345678901", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "conversation_id": "5215512345678@s.whatsapp.net", "message_id": "3EB0234567890ABCDEF1", "type": "message", "subtype": "text", "from_jid": "5215500001111:12@s.whatsapp.net", "from_me": true, "content": "On our way", "media_url": null, "thumbnail": null, "timestamp": "2026-07-30T10:31:00.000Z" } ``` - `from_jid` is your own number with a device suffix (`:12` above), which is why it should never be parsed as the recipient. Read `conversation_id` for that. - The row appears when the message actually goes out, not when you call send. A `202` means queued, so a send that is still in the queue has no row yet. - Messages typed on the handset land here too. `from_me` does not distinguish API traffic from human traffic. ### Media Message `type`: `message` — `subtype`: `image`, `video`, `audio`, `document`, `sticker` An image, video, audio note, document, or sticker. The only rows that carry a media URL. ```json { "event_id": "c3d4e5f6-a7b8-4012-cdef-123456789012", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "conversation_id": "5215512345678@s.whatsapp.net", "message_id": "3EB0345678901ABCDEF2", "type": "message", "subtype": "image", "from_jid": "5215512345678@s.whatsapp.net", "from_me": false, "content": "Optional caption", "media_url": "https://files.wapisimo.dev/eyJtZWRpYUtleSI6IjxvcGFxdWUtdG9rZW4-In0", "thumbnail": null, "timestamp": "2026-07-30T10:32:00.000Z" } ``` - The path is one opaque token, not a filename, and carries no extension. `GET` the URL and you get the decrypted file back with its real content type. - Media is fetched from WhatsApp when you request it rather than archived, so a link stops resolving once WhatsApp expires the file. Download anything you need to keep. - `media_url` can be `null` on a media row when WhatsApp provides no decryptable file, which happens with view-once placeholders and channel media. The subtype still tells you what arrived. - A caption lands in `content`. Audio, video, documents and stickers normally have none, so an empty `content` beside a populated `media_url` is the common case. ### Group Chat Message `type`: `message` — `subtype`: `text`, `image`, `video`, `audio`, `document`, `sticker` A message in a WhatsApp group conversation. For most accounts these are a large share of the history, so they are worth handling explicitly rather than as an edge case. ```json { "event_id": "d4e5f6a7-b8c9-4123-def0-234567890123", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "conversation_id": "120363000000000000@g.us", "message_id": "3EB0456789012ABCDEF3", "type": "message", "subtype": "text", "from_jid": "5215587654321@s.whatsapp.net", "from_me": false, "content": "I'll take that one", "media_url": null, "thumbnail": null, "timestamp": "2026-07-30T10:33:00.000Z" } ``` - `conversation_id` ends in `@g.us` and identifies the group. `from_jid` is the participant who spoke, so the two differ on every inbound group row. - The `phone_number` filter never matches these. It keys off the contact number behind a one-to-one chat, which a group conversation does not have. Filter groups with `conversation_id` instead. - A participant can appear as `...@lid` rather than a phone number when WhatsApp hides their number. Compare JIDs as opaque strings rather than assuming digits before the `@`. ### Reaction `type`: `message` — `subtype`: `reaction` Somebody reacted to a message. The history records that it happened, not what it was. ```json { "event_id": "e5f6a7b8-c9d0-4234-ef01-345678901234", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "conversation_id": "5215512345678@s.whatsapp.net", "message_id": "3EB0567890123ABCDEF4", "type": "message", "subtype": "reaction", "from_jid": "5215512345678@s.whatsapp.net", "from_me": false, "content": "", "media_url": null, "thumbnail": null, "timestamp": "2026-07-30T10:34:00.000Z" } ``` - The history does not store the emoji: `content` is empty on every reaction row. Read it from the webhook payload if you need it. - `message_id` belongs to the reaction, not to the message being reacted to. The history does not link the two. ### Location and Contact Shares `type`: `message` — `subtype`: `location`, `contact` A location pin or a contact card. Both are recorded as having happened, with their payload left to the webhook. ```json { "event_id": "f6a7b8c9-d0e1-4345-f012-456789012345", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "conversation_id": "5215512345678@s.whatsapp.net", "message_id": "3EB0678901234ABCDEF5", "type": "message", "subtype": "location", "from_jid": "5215512345678@s.whatsapp.net", "from_me": false, "content": "", "media_url": null, "thumbnail": null, "timestamp": "2026-07-30T10:35:00.000Z" } ``` - Coordinates are not in the row. `latitude` and `longitude` ride on the webhook payload only. - A `contact` row is the same shape with a different subtype. `displayName` and `vcards` are likewise webhook-only. - To keep the contents, take them from a webhook and store them yourself. The history records that a share happened and when. ### Send Failed `type`: `message` — `subtype`: `send_failed` A send was refused because the phone was not connected. History only, never pushed to a webhook. ```json { "event_id": "a7b8c9d0-e1f2-4456-0123-567890123456", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "conversation_id": "", "message_id": "", "type": "message", "subtype": "send_failed", "from_jid": "", "from_me": false, "content": "Message send failed - phone not connected", "media_url": null, "thumbnail": null, "timestamp": "2026-07-30T10:36:00.000Z" } ``` - The row does not identify the intended recipient, so match these against your own send log by timestamp. - These carry `from_me: false` even though the attempt was outbound, so a `from_me=true` filter will not return them. - A run of these within a few minutes is what flips the phone to `disconnected` and emails the account owner. ### Send Rejected by WhatsApp `type`: `message` — `subtype`: `send_rejected` The message left the socket and WhatsApp refused it on the acknowledgement. The failure behind "connected but nothing arrives". History only. ```json { "event_id": "b8c9d0e1-f2a3-4567-1234-678901234567", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "conversation_id": "5215512345678@s.whatsapp.net", "message_id": "3EB0789012345ABCDEF6", "type": "message", "subtype": "send_rejected", "from_jid": "5215512345678@s.whatsapp.net", "from_me": true, "content": "Message rejected by WhatsApp", "media_url": null, "thumbnail": null, "timestamp": "2026-07-30T10:37:00.000Z" } ``` - Unlike a send failure this one names the message: `message_id` is the id of the send that was refused, so it joins straight onto the outbound row you already have. - Rare, and worth alerting on: a phone can look connected while every send is refused. - Often accompanied by a `reachout_timelocked` connection row around the same time, which explains why the sends are being refused. ### Connection Lost `type`: `connection` — `subtype`: `disconnected` The phone stopped being usable. The same event also reaches every webhook on the phone. ```json { "event_id": "c9d0e1f2-a3b4-4678-2345-789012345678", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "conversation_id": null, "message_id": null, "type": "connection", "subtype": "disconnected", "from_jid": null, "from_me": false, "content": "Phone logged out - device disconnected", "media_url": null, "thumbnail": null, "timestamp": "2026-07-30T10:38:00.000Z" } ``` - Every disconnect uses the subtype `disconnected`, and `content` is what tells the causes apart: logged out from the handset, blocked by WhatsApp, a multidevice mismatch, or repeated send failures. The webhook payload names the trigger in a dedicated field. - Filter on `type=connection` to get an uptime timeline for a phone without paging through its messages. ### Sending Restricted `type`: `connection` — `subtype`: `reachout_timelocked`, `reachout_timelock_lifted` WhatsApp time-locked the number out of starting conversations. The phone stays connected and keeps receiving, so nothing else in the history looks wrong. ```json { "event_id": "d0e1f2a3-b4c5-4789-3456-890123456789", "phone_id": "3f7c1e02-9b4a-4d61-8e2f-1a5c9d0b7e34", "conversation_id": "", "message_id": "", "type": "connection", "subtype": "reachout_timelocked", "from_jid": "", "from_me": false, "content": "Account restricted from sending messages by WhatsApp", "media_url": null, "thumbnail": null, "timestamp": "2026-07-30T10:39:00.000Z" } ``` - The expiry is not in the row. `timestamp` tells you when the lock was detected; `details.time_enforcement_ends` on the webhook payload is the only place the end date appears. - A matching `reachout_timelock_lifted` row, same shape, arrives when WhatsApp releases the number. Until it does, assume the lock still holds. - Since the phone never disconnects, a monitor built on `disconnected` rows alone will not notice this. Watch for the subtype, or for a run of `send_rejected`. ### Cloud API Message `type`: `message` — `subtype`: `text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `contact`, `reaction`, `button`, `interactive`, `template`, `unknown` A message on an official Cloud API phone. Same twelve columns as every other row, but several of them are filled differently because the identifiers come from Meta rather than WhatsApp Web. ```json { "event_id": "e5f6a7b8-c9d0-4234-ef01-345678901234", "phone_id": "5c9a2f18-6d40-4e7b-9a13-8b4f0c2e7d51", "conversation_id": "5215512345678", "message_id": "wamid.HBgNNTIxNTUxMjM0NTY3OBUCABIYEjBBMUIyQzNENEU1RjYwNzg5", "type": "message", "subtype": "text", "from_jid": null, "from_me": false, "content": "Hola!", "media_url": null, "thumbnail": null, "timestamp": "2026-08-15T10:30:00.000Z" } ``` - `conversation_id` is a bare `wa_id` (digits, no `@s.whatsapp.net`), not a JID. A `conversation_id` filter written for QR-linked phones will not match these rows. Filtering by `phone_number` works on both, so prefer it. - `from_jid` is never populated on Cloud API rows. Use `from_me` to tell direction. - `message_id` is Meta's `wamid.`, which is also what a Cloud API status callback references, so the two reconcile directly. - `media_url` is always `null`, including on media rows. Meta hands us a media id rather than a file, and the history read does not expose it — take `media_id` off the webhook payload and fetch it from Graph with your own token. - Outbound template sends log `subtype: "template"` with `content` set to the template BODY as it was delivered, variables filled in. `details` carries `template` (the name), `template_language`, `template_variables`, and the rendered `template_header` / `template_footer` / `template_buttons` — which is how you tell a send was a template, and which one. Sends made before this was recorded have a `content` of `[template] your_template_name` instead. - `button` and `interactive` are Cloud-only subtypes (a reply button or a list selection); `unknown` covers a Meta message type Wapisimo does not translate yet, and arrives with an empty `content`. - A contact who has no phone number visible (a BSUID-only identity) puts the BSUID in `conversation_id` instead of a number. ## Phone Groups Phone groups combine several WhatsApp numbers behind one sending endpoint. Send to the group id and Wapisimo picks which member delivers the message. Not to be confused with WhatsApp group chats, which are conversations with several participants and are covered by List WhatsApp Groups. - Load is spread across the members instead of one number carrying everything. - Contact affinity keeps a given recipient talking to the same number. - Only connected members are eligible to send. - A group has its own API key, and webhooks configured on it apply to every member. - A group needs at least one connected phone, or a send returns `404`. ## Selection Strategies **Round robin** picks the member used least recently, tracked by a `last_used` timestamp; members never used go first. This spreads volume evenly. **Random** picks any connected member. Useful when even distribution matters less than unpredictability. Both are subordinate to contact affinity: if the recipient already has a history with one of the members, that member is chosen and the strategy does not run. ## Contact Affinity Recipients should not see your business jumping between numbers mid-conversation, so before applying the strategy Wapisimo checks whether any member has recently talked to this recipient. 1. Look up the most recent event between the recipient and any member of the group. 2. If one exists within the last **30 days**, that member sends, and the strategy is skipped. 3. Otherwise the strategy picks, and that choice becomes the new affinity. 4. After sending, the chosen member's `last_used` timestamp is updated. Consequences worth knowing: - A contact sticks to one number for up to 30 days of silence. - Affinity counts inbound events too, so a reply naturally goes back out through the number the customer messaged. - If a member disconnects, its contacts move to another member on the next send. When it reconnects those contacts stay where they were moved until affinity expires on its own. ## Send via Group Sending through a group uses the same request shape as a phone send — text, media, and location all work identically. Substitute the group id for the phone id and authenticate with the group's key: ```bash curl -X POST https://api.wapisimo.dev/v1/{group_id}/send \ -H "Authorization: Bearer YOUR_GROUP_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to": "+5215512345678", "message": "Hello from the group!"}' ``` The response is the same `202` as a phone send. It does not tell you which member was chosen; read the event history and check `phone_id` if you need to know. ## Media Types `mediaType` accepts: - `image` — JPEG, PNG, GIF, WebP - `video` — MP4, AVI, MOV, WebM - `audio` — MP3, WAV, OGG, M4A - `document` — PDF, DOC, TXT and others `mediaUrl` and `mediaType` are both required, and `message` becomes the caption. The URL must be publicly reachable: Wapisimo fetches it server-side with no credentials of yours attached. Files are processed transiently, not archived. ## Webhook Events Register a URL with Add Webhook and Wapisimo POSTs a JSON body to it as things happen. `type: "new_messages"` delivers inbound messages; `type: "all"` delivers everything below. Connection events ignore that setting and always reach every webhook on the phone, because a phone going down is something every integration needs to know about. On an official Cloud API phone a third type, `proxy`, forwards Meta's own payload instead of the shapes below; see Add Webhook. The normalized Cloud payloads are catalogued here alongside the QR-linked ones and are marked as such. Two details worth designing around: - **Webhook payloads and event-history rows are not the same shape.** Webhooks use camelCase (`subType`, `fromMe`) and a Unix `timestamp`; history rows use snake_case (`subtype`, `from_me`) and an ISO 8601 `timestamp`. Each event below is labelled with where it surfaces. - **Some events are history-only.** `send_failed` and `send_rejected` are recorded but never pushed, so an integration that only listens to webhooks will not notice a phone that has silently stopped delivering. Poll the event history for those. Respond `2xx` promptly. Deliveries are not retried, and a slow endpoint delays the events queued behind it for that phone. ### Text Message Delivered via: webhook and event history `type`: `message` — `subType`: `text` A text message was sent or received. ```json { "type": "message", "subType": "text", "from": "5215512345678@s.whatsapp.net", "message": "Hello!", "timestamp": 1753876800, "fromMe": false } ``` ### Media Message Delivered via: webhook and event history `type`: `message` — `subType`: `image`, `video`, `audio`, `document`, `sticker` An image, video, audio file, document, or sticker arrived. The file is mirrored to a Wapisimo URL. ```json { "type": "message", "subType": "image", "from": "5215512345678@s.whatsapp.net", "message": "Optional caption", "url": "https://files.wapisimo.dev/eyJtZWRpYUtleSI6IjxvcGFxdWUtdG9rZW4-In0", "timestamp": 1753876800, "fromMe": false } ``` - The path is one opaque token rather than a filename, and it is the same URL the event history returns as `media_url`. ### Location Message Delivered via: webhook and event history `type`: `message` — `subType`: `location` A contact shared a location pin. Coordinates are hoisted onto the payload. ```json { "type": "message", "subType": "location", "from": "5215512345678@s.whatsapp.net", "latitude": 19.4326, "longitude": -99.1332, "timestamp": 1753876800, "fromMe": false } ``` ### Contact Message Delivered via: webhook and event history `type`: `message` — `subType`: `contact` A contact card was shared. A single share populates `displayName` and a one-element `vcards` array; a multi-contact share fills the array. ```json { "type": "message", "subType": "contact", "from": "5215512345678@s.whatsapp.net", "displayName": "Example Contact", "vcards": [ "BEGIN:VCARD\nVERSION:3.0\nFN:Example Contact\nTEL;type=Mobile;waid=5215500000000:+52 155 0000 0000\nEND:VCARD" ], "timestamp": 1753876800, "fromMe": false } ``` ### Reaction Delivered via: webhook and event history `type`: `message` — `subType`: `reaction` Someone reacted to a message. An empty `reaction` means the reaction was removed. ```json { "type": "message", "subType": "reaction", "conversation": "5215512345678@s.whatsapp.net", "reaction": "👍", "fromMe": false } ``` - The webhook is the only place the emoji appears. A reaction is recorded in the event history too, but with an empty `content` — see the reaction sample under Event Samples. ### Message Edited Delivered via: webhook only `type`: `message` — `subType`: `edit` An already-delivered message was edited by its sender. ```json { "type": "message", "subType": "edit", "0": { "key": { "remoteJid": "5215512345678@s.whatsapp.net", "id": "3EB01234567890ABCDEF", "fromMe": false }, "update": { "message": { "conversation": "Corrected text" } } } } ``` - The raw update array is spread onto the payload, so its entries appear as numeric keys alongside `type` and `subType`. ### Delivery Status Update Delivered via: webhook only `type`: `message` — `subType`: `status_update` A delivery receipt moved an outbound message along: sent, delivered, then read. Useful for confirming that a `202` from `/send` actually landed. ```json { "type": "message", "subType": "status_update", "0": { "key": { "remoteJid": "5215512345678@s.whatsapp.net", "id": "3EB01234567890ABCDEF", "fromMe": true }, "update": { "status": 3 } } } ``` - These fire constantly. Register `type: "all"` only if you intend to consume them. ### Send Failed Delivered via: event history only `type`: `message` — `subType`: `send_failed` A send was refused before it reached WhatsApp, because the phone was not connected. The `/send` call itself returns `400`. ```json { "type": "message", "subtype": "send_failed", "content": "Message send failed - phone not connected", "from_me": false, "timestamp": "2026-07-30T10:30:00.000Z" } ``` - History only — this is recorded in the event log and is not pushed to webhooks. - A run of these is the signal that a phone needs re-pairing. ### Send Rejected by WhatsApp Delivered via: event history only `type`: `message` — `subType`: `send_rejected` WhatsApp accepted the message from the socket and then rejected it on the acknowledgement. This is the failure mode behind "connected but nothing goes out". ```json { "type": "message", "subtype": "send_rejected", "conversation_id": "5215512345678@s.whatsapp.net", "message_id": "3EB01234567890ABCDEF", "content": "Message rejected by WhatsApp", "from_me": true, "timestamp": "2026-07-30T10:30:00.000Z" } ``` - History only — not pushed to webhooks. - Only terminal errors are recorded. Intermediate delivery statuses are not, or they would swamp the log. - Frequently paired with a reach-out time-lock; check for a `reachout_timelocked` connection event around the same time. ### Chat Events Delivered via: webhook only `type`: `chats` — `subType`: `upsert`, `new`, `update`, `delete` A conversation appeared, changed, or was deleted on the linked handset. ```json { "type": "chats", "subType": "upsert", "0": { "id": "5215512345678@s.whatsapp.net", "conversationTimestamp": 1753876800, "unreadCount": 1 } } ``` - `upsert` is a chat WhatsApp has just synced to the device. `update` is a change to one already known. - `new` is a Wapisimo refinement of `update`: an update carrying a sender key hash, which in practice marks a genuinely new conversation. - `chats` events are not part of the `type` enum accepted by the event history filters — they reach webhooks only. ### Connection Lost Delivered via: webhook and event history `type`: `connection` — `subType`: `disconnected`, `logged_out`, `forbidden`, `multidevice_mismatch` The phone stopped being usable and needs attention. Delivered to every webhook regardless of its `type`. ```json { "type": "connection", "subType": "disconnected", "content": "Phone logged out - device disconnected", "timestamp": 1753876800, "details": { "trigger": "logged_out", "statusCode": 401 } } ``` - `details.trigger` carries the underlying cause: `logged_out` (unlinked from the handset), `forbidden` (blocked by WhatsApp, possibly banned), `multidevice_mismatch` (a fresh QR scan is required), or `consecutive_send_failures`. - Recovery is usually Get QR Code, which resumes the stored session. Reach for Resync Phone only when that fails. ### Sending Restricted Delivered via: webhook and event history `type`: `connection` — `subType`: `reachout_timelocked` WhatsApp has time-locked this number out of starting new conversations. The socket stays healthy and inbound messages keep flowing, so this is not a disconnect. ```json { "type": "connection", "subType": "reachout_timelocked", "content": "Account restricted from sending messages by WhatsApp", "timestamp": 1753876800, "details": { "enforcement_type": "companion_reachout", "time_enforcement_ends": "2026-08-06T10:30:00.000Z", "detected_at": "2026-07-30T10:30:00.000Z", "source": "notification" } } ``` - `details.time_enforcement_ends` is when the lock expires, and is null when WhatsApp did not tell us. - While the lock holds, `/send` fails fast with the expiry rather than accepting a message that would die silently. - Typically lifts after about seven days. ### Sending Restriction Lifted Delivered via: webhook and event history `type`: `connection` — `subType`: `reachout_timelock_lifted` WhatsApp released the restriction and the phone can start conversations again. ```json { "type": "connection", "subType": "reachout_timelock_lifted", "content": "Sending restriction lifted by WhatsApp", "timestamp": 1753876800, "details": { "source": "notification" } } ``` ### Cloud API Message Delivered via: webhook and event history `type`: `message` — `subType`: `text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `contact`, `reaction`, `button`, `interactive`, `unknown` An inbound message on an official Cloud API phone, normalized. Close to the QR-linked payload but not identical — check the differences below before reusing a parser. ```json { "type": "message", "subType": "text", "from": "5215512345678", "from_phone": "5215512345678", "from_name": "Ana", "content": "Hola!", "timestamp": 1755253800, "fromMe": false, "message_id": "wamid.HBgNNTIxNTUxMjM0NTY3OBUCABIYEjBBMUIyQzNENEU1RjYwNzg5", "provider": "cloud_api", "phone_id": "5c9a2f18-6d40-4e7b-9a13-8b4f0c2e7d51" } ``` - The text lives in `content`, not `message` — that is the one difference most likely to break a parser written against the QR-linked payload. - `from` is a bare `wa_id`, not a JID. `from_phone` repeats it, and `from_user_id` carries the BSUID when Meta sends one; for a BSUID-only contact `from` is the BSUID and `from_phone` is absent. - `provider: "cloud_api"` is on every Cloud payload, so one endpoint can serve both kinds of phone by branching on it. - Media arrives as `media_id` (Meta's id) with no Wapisimo URL — fetch it from Graph with your own token. Location, contacts, and interactive replies put their structured payload in `data`. - Redeliveries are suppressed: Meta delivers at least once, and a repeat of a `wamid` already stored does not fire the webhook again. A `proxy` webhook is the exception and mirrors Meta's own at-least-once behavior. ### Cloud API Delivery Status Delivered via: webhook only `type`: `message` — `subType`: `status` Meta reported a delivery transition for a message you sent from a Cloud API phone. ```json { "type": "message", "subType": "status", "status": "delivered", "mapped_status": "DELIVERY_ACK", "message_id": "wamid.HBgNNTIxNTUxMjM0NTY3OBUCABIYEjBBMUIyQzNENEU1RjYwNzg5", "recipient_id": "5215512345678", "timestamp": 1755253860, "fromMe": true, "provider": "cloud_api", "phone_id": "5c9a2f18-6d40-4e7b-9a13-8b4f0c2e7d51" } ``` - Only `type: "all"` webhooks receive these. `new_messages` does not. - The subtype is `status`, not the `status_update` a QR-linked phone sends. - `status` is Meta's own wording (`sent`, `delivered`, `read`, `failed`); `mapped_status` is the same transition in the vocabulary the rest of Wapisimo uses (`SERVER_ACK`, `DELIVERY_ACK`, `READ`, `ERROR`). A `failed` status carries Meta's `errors` array. - This updates the original outbound row rather than creating a new one, which is why there is no matching history sample. Correlate on `message_id`. ## Natural Mode Natural Mode makes a phone behave like a person rather than a script. It is on by default and lowers the chance the number gets flagged as automated. It applies to **QR-linked phones only**. Official Cloud API sends go straight to Meta with no typing indicator, no pre-send delay, and no read-receipt shaping — there is nothing to flag as automated, because the number is officially registered for business messaging. **What it does.** Real people do not reply the millisecond an event lands, so Natural Mode adds signals that look human: - A **typing…** indicator appears before the message arrives; voice notes show **recording…** instead. - The pre-send delay **scales with message length**, the way writing a paragraph takes longer than "ok". - Every interaction carries its own **jitter**, so two identical messages never behave identically. - Read receipts arrive after a **variable delay**, and some messages are left unread — most real conversations have a few sitting on two grey ticks. The exact mix changes as we tune it. Leaving Natural Mode on means picking up future adjustments automatically. **Keep it on** for anything conversational: support, sales replies, chatbots, outreach. The more human a number looks, the longer it stays healthy. **Turn it off** when the recipient is actively waiting and latency matters: OTPs and login codes, payment confirmations, system alerts, transactional notifications. **Toggling.** Open the phone's settings in the dashboard and flip **Natural behavior** in the Behavior card. It takes effect within seconds. There is no API for this today. **What changes.** On, outbound messages take a few seconds longer and read receipts are delayed or skipped. Off, messages send immediately. Either way webhook delivery is never delayed — inbound events reach you in real time. ## Rate Limits | Route | Limit | | --- | --- | | `/qr` | 10 requests per minute | | `/webhook` | 60 requests per hour | | `/events` (all three scopes) | 60 requests per minute | | `/send` | not request-limited; QR-linked phones drain at ~1 message/second | Other routes, including `/v1/phones`, are not rate limited today. Do not build against that: treat it as unspecified rather than guaranteed, and handle `429` everywhere. Exceeding a limit returns `429`. Limits are counted per instance id, except group event reads, which are counted against the account so that fanning out across many groups cannot multiply the budget. For QR-linked phones the `/send` queue is what keeps a number under WhatsApp's own limits: it preserves ordering and retries on failure. Cloud API sends skip the queue and go straight to Meta, which enforces its own per-number throughput. ## Status Codes | Code | Meaning | | --- | --- | | `200` | Success | | `201` | Created | | `202` | Accepted — queued, not yet delivered | | `400` | Bad request: malformed parameter, missing field, or a send to a disconnected phone | | `401` | Unauthorized: missing, unknown, or wrong-scope key — also returned for a malformed instance id | | `404` | Not found, or a group with no connected members | | `405` | Method not allowed on this path | | `429` | Rate limited | | `500` | Server error | Errors carry a JSON body where the API produces one: ```json { "error": "phone_status only applies to group and account-wide reads" } ``` Some rejections return a plain-text body instead of JSON, so parse defensively and rely on the status code rather than the body shape.