# WAgate API > Put a WhatsApp number behind an API from $25 a month, flat. Send and receive, run groups and communities, and get a signed webhook with a stable delivery id for everything that happens on the number. Free sandbox, forever, no card. API: https://api-wagate.socialground.co/v1 Pricing: 1 to 4 channels: $25 per channel per month, or $240 per year; 5 to 9 channels: $20 per channel per month, or $192 per year; 10 or more channels: $11 per channel per month, or $106 per year. Yearly saves about 20%. Docs: public, no sign-in needed. Start at https://wagate.dev/docs. Docs index: Send and receive WhatsApp messages from your own code, with one channel per number. Every request goes to the address below with your channel token. OpenAPI spec: https://wagate.dev/docs/openapi.json - [Docs](https://wagate.dev/docs) - [Quickstart](https://wagate.dev/docs/quickstart): Send your first message and receive your first webhook. - [Authentication and conventions](https://wagate.dev/docs/authentication): Your channel token, what it opens, and the conventions every route shares. - [Sending messages](https://wagate.dev/docs/messages): Send text, polls and contact cards, reply, mention, react, delete, mark read, show presence. - [Media](https://wagate.dev/docs/media): Send images, video, audio, voice notes and documents from a URL, and download media you received. - [Webhooks](https://wagate.dev/docs/webhooks): Receive events, verify them, and handle retries and duplicates. - [Webhook events](https://wagate.dev/docs/webhook-events): The four event types WAgate sends, with the payload of each. - [Limits](https://wagate.dev/docs/limits): What limits a channel has, and what each 429 means. - [Errors](https://wagate.dev/docs/errors): Every error code the API returns, what causes it, and what to do. - [Groups](https://wagate.dev/docs/groups): List, create and manage groups: participants, subject, description, settings, invite link. - [Communities](https://wagate.dev/docs/communities): Create a community, add subgroups, link groups, list participants, get the invite link. - [Contacts](https://wagate.dev/docs/contacts): Check which numbers are on WhatsApp, and save, read and remove contacts. - [Chats and history](https://wagate.dev/docs/chats): List chats, read a conversation page by page, and ask the phone for older history. - [Your channel and its health](https://wagate.dev/docs/numbers-and-safety): Read and rename your channel, read its health, and pace sending. - [Recipes](https://wagate.dev/docs/recipes): Retry safely, handle sends of unknown outcome, mark reads, and pace volume. - [Warming](https://wagate.dev/docs/warming): Switch warming on or off for a channel, and read its progress. - [API reference](https://wagate.dev/docs/reference): Every route in the public API, generated from the OpenAPI document. - [Agent skill](https://wagate.dev/docs/agent-skill): install the WAgate skill in an AI agent - [MCP server](https://wagate.dev/docs/mcp): use WAgate from an MCP client # Quickstart Source: https://wagate.dev/docs/quickstart Send your first message and receive your first webhook. You need a WAgate channel with a linked WhatsApp number. This takes about five minutes. Every example uses these shapes. Replace them with your own values. | Shape | Meaning | | --- | --- | | `` | your channel ID | | `` | your channel token: 43 characters of letters, digits, `-` and `_`, with no prefix | | `66XXXXXXXXX` | a phone number in full international form, digits only | | `https://example.com/wagate` | your webhook receiver | ## 1. Get your channel token In the console, add a number and scan its QR code. Then create an API token for the channel and copy it. The console shows it once. Your channel ID is the name on the channel's row in the console. It is a string of letters, digits, hyphens and underscores. ## 2. Check that the API answers `GET /health` needs no token. ```bash curl https://api-wagate.socialground.co/health ``` ```json { "status": "ok" } ``` The address has no `/v1` here. Every other route is under `/v1`. ## 3. Send a text ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//messages/text" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"to": "66XXXXXXXXX", "text": "Hello from WAgate"}' ``` `to` is the recipient's phone number in international form with the country code, or a WhatsApp JID. ```json { "id": "3EB0C767D26A1D8E2A41", "chat_jid": "66XXXXXXXXX@s.whatsapp.net", "timestamp": "2026-09-17T03:00:00Z", "status": "server_ack" } ``` `status: "server_ack"` means WhatsApp's servers took the message. It does not mean the recipient's phone has it. Delivery and read receipts arrive later as `message.status` webhooks. If this fails, read the `code` in the JSON body and look it up in Errors. The three you are most likely to see on a new channel are `unauthorized` (401, wrong token), `not_linked` (409, the number is not linked yet) and `sandbox_allowance_exceeded` (429, the free sandbox channel used an allowance). ## 4. Receive a webhook Every new receiver needs a secret of at least 24 characters. Pick one and keep it on your server. WAgate signs every delivery with it, and you check that signature in step 6. A request that adds a receiver without a secret is refused with 400 `invalid_body`. ```bash curl -X PUT "https://api-wagate.socialground.co/v1/sessions//webhooks" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"receivers": [{"url": "https://example.com/wagate", "events": ["message", "message.status"], "secret": "REPLACE_WITH_24_PLUS_CHARACTERS"}]}' ``` ```json { "receivers": [ { "url": "https://example.com/wagate", "events": ["message", "message.status"], "media_policy": "reference", "secret_set": true } ] } ``` The response never repeats the secret. It only says whether one is set. Your URL must be a public address on http or https. A URL that resolves to a loopback, private, link-local or cloud-metadata address is refused at delivery time. ## 5. Fire a test event ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//webhooks/test" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/wagate"}' ``` ```json { "status": "delivered", "detail": "the receiver accepted a signed test event; nothing was sent to WhatsApp" } ``` The `url` must match a receiver you already set. Any other URL answers 404 `not_found`. If your receiver does not accept the test event, the call answers 400. ## 6. Check the signature Every delivery carries `X-Wagate-Signature`: the lowercase hex HMAC-SHA256 of the raw request body, keyed with your secret. Check it before you parse the JSON. Full code is in Webhooks. ## Next - Messages: texts with replies and mentions, polls, reactions, deletes, read receipts. - Webhooks and Webhook events: what arrives, in what shape. - Limits and Errors: what a 429 means, and which errors mean you should not blindly retry. # Authentication and conventions Source: https://wagate.dev/docs/authentication Your channel token, what it opens, and the conventions every route shares. ## The token Every request under `/v1` sends your channel token as a bearer token. ```Example Authorization: Bearer ``` A channel token is a random string of 43 characters (letters, digits, `-` and `_`) with no prefix. Examples on these pages write it as ``. A token opens one channel: the one it was created for. A request for another channel's path is refused with 401 `unauthorized`. WAgate stores only a hash of the token. The console shows it once, when you create it. Creating a new token replaces the old one. Keep it on your server. Never put it in a browser page or in an app you ship. ## Base URL All routes are under `/v1`, for example `/v1/sessions//messages/text`. The one exception is the liveness check, `/health`, with no `/v1` and no token. The console and the OpenAPI document at `/docs/openapi.json` show the exact origin for your deployment. ## Channel ID, and "session" in paths Paths say `sessions/{name}`. `{name}` is your channel ID, the name on the channel's row in the console. It matches `^[A-Za-z0-9_-]+$`. ## Addresses and IDs | Value | Shape | Where it appears | | --- | --- | --- | | Phone number | digits with country code, such as `66XXXXXXXXX` | `to`, `mentions` | | User JID | `66XXXXXXXXX@s.whatsapp.net` | `chat_jid`, `sender_jid`, `jid` | | LID | `@lid` | `sender_lid`, `chat_lid`, `lid` | | Group or community JID | `120363000000000001@g.us` | `group`, `community` paths | | Message ID | starts with a letter or digit, then letters, digits, `.`, `_` or `-`, up to 128 characters in all, such as `3EB0C767D26A1D8E2A41` | `id`, `quoted`, `{message}` paths | | Media ID | 64 hex characters, a dot, an extension | `media_id`, `GET media/{id}` | People are identified by phone number as `@s.whatsapp.net`. WhatsApp sometimes addresses a person by a LID instead. WAgate publishes the phone JID whenever it knows it and gives the LID alongside; see Webhook events for the rules. ## Requests - Bodies are JSON with `Content-Type: application/json`. - A body may be at most 1 MiB. A body with a field the route does not take is refused with 400. The code is `invalid_body` or `field_not_allowed`. - Do not send an account header. The public API refuses it with 400 `header_not_allowed`. - Times in responses use RFC 3339 with a timezone offset. Many timestamps use UTC (`Z`); allowance `resets_at` values use Bangkok time (`+07:00`). Parse the offset rather than assuming UTC. ## Responses and errors A success is 200, 201 (created) or 202 (accepted, as for a history request). An error is JSON with a stable `code` and a human `error`: ```json { "code": "unauthorized", "error": "invalid bearer token" } ``` Branch on `code`, never on `error`. Treat a code you do not know as a generic failure. Errors lists every code. ## When a token is refused - A missing, short or unknown token, or a channel ID that does not exist, answers 401 `unauthorized`. - A token that stops applying while a request is in flight answers 401 `credential_stale`. That happens when the token is replaced or the channel is removed. - A channel whose paid slot ended answers 401 `slot_ended`. ## Replacing a token The API cannot create, replace or revoke a token. To replace the token of a channel you already have, email hello@wagate.dev. A replaced token stops working at once. ## Webhook secrets Every new webhook receiver needs its own `secret` of at least 24 characters. WAgate signs each delivery with it so you can check the delivery came from WAgate. A request that adds a receiver without a secret is refused with 400 `invalid_body`. When you send the receiver list again, omit `secret` on a receiver you already saved to keep its stored secret. Send a new value to replace it. A secret cannot be removed: delete the receiver and add it again with a new one. A receiver saved before this rule and still without a secret (`secret_set: false` in `GET webhooks`) keeps working. Add a secret to it with `PUT webhooks`. See Webhooks. ## Health check ```bash curl https://api-wagate.socialground.co/health ``` ```json { "status": "ok" } ``` # Sending messages Source: https://wagate.dev/docs/messages Send text, polls and contact cards, reply, mention, react, delete, mark read, show presence. Media is on its own page, Media. Placeholders in examples: channel ``, token ``, number `66XXXXXXXXX`, message `3EB0C767D26A1D8E2A41`, base `https://API_ORIGIN/v1`. - POST sessions//messages/text: Send a text, with optional mentions and a quoted reply. - POST sessions//messages/contact: Send a contact card. - POST sessions//messages/poll: Send a poll. - PUT sessions//messages//reaction: React to a message. - DELETE sessions//messages//reaction: Remove your reaction. - DELETE sessions//messages/: Delete a message you sent, for everyone. - POST sessions//messages/read: Send read receipts. - POST sessions//presence: Set the channel's presence. - POST sessions//typing: Show or clear the typing indicator. ## What every send returns Sends answer 200 with: ```json { "id": "3EB0C767D26A1D8E2A41", "chat_jid": "66XXXXXXXXX@s.whatsapp.net", "timestamp": "2026-09-17T03:00:00Z", "status": "server_ack" } ``` - `id` identifies the message. Keep it: you need it to quote, react, delete, and to match `message.status` webhooks. - `server_ack` means WhatsApp's servers accepted it. Whether it reached the phone, and whether it was read, arrives as `message.status` webhooks (`delivered`, `read`, `played`, `undecryptable`). - The API has no idempotency key. If a send returns 504 `send_unconfirmed`, the message may have gone out. Treat it as possibly delivered and do not resend blindly. The `id` in that error names a log row marked ambiguous. It is not a WhatsApp message ID. See Recipes. - An API send is not echoed back to you as a `message` webhook. - Every send, poll, reaction and delete counts against your daily send cap, and against the sandbox allowance on a sandbox channel. ## Send a text ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//messages/text" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"to": "66XXXXXXXXX", "text": "Thanks, see you tomorrow"}' ``` Fields: - `to` (required): phone number with country code, or a JID. - `text`: the message. - `mentions` (optional): phone numbers or user JIDs of the people to mention. Group and other non-person addresses are refused with 400. - `quoted` (optional): the ID of a message this channel has in the same chat. The text is sent as a reply to it. Letters, digits, dot, underscore and hyphen, 1 to 128 characters. Reply and mention in one call: ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//messages/text" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"to": "120363000000000001@g.us", "text": "@66XXXXXXXXX see above", "mentions": ["66XXXXXXXXX"], "quoted": "3EB0C767D26A1D8E2A41"}' ``` Put `@` and the number in the text where the tag should appear. The `mentions` list notifies the person. Without the `@number` in the text, they are notified but nothing is highlighted. Errors specific to this route: 400 `quote_not_found` (the quoted message is not in this channel's log), 400 `quote_wrong_chat` (it belongs to another chat). ## Send a poll ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//messages/poll" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"to": "66XXXXXXXXX", "question": "Which day suits you?", "options": ["Monday", "Tuesday", "Wednesday"], "multiple": false}' ``` - `question` up to 255 characters. - `options`: 2 to 12 different options, each up to 100 characters. - `multiple`: `true` lets a person choose more than one option. The default is `false`. Votes arrive as `message` webhooks of type `poll_vote`, with the chosen options by name. See Webhook events. ## Send a contact card ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//messages/contact" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"to": "66XXXXXXXXX", "display_name": "Customer A", "vcard": "BEGIN:VCARD\nVERSION:3.0\nFN:Customer A\nTEL;type=CELL;waid=66XXXXXXXXX:+66XXXXXXXXX\nEND:VCARD"}' ``` `to`, `display_name` and `vcard` are all required. ## React to a message ```bash curl -X PUT "https://api-wagate.socialground.co/v1/sessions//messages/3EB0C767D26A1D8E2A41/reaction" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"emoji": "👍"}' ``` - Send exactly one emoji. The value must be at most 32 bytes of valid UTF-8, include at least one non-ASCII character, and contain no spaces, control characters, ASCII letters or digits. `ok` is refused with 400. The gateway does not check that the value is a single emoji, so send exactly one. - A new reaction replaces this channel's earlier one on that message. - A message this channel has no record of answers 404 `message_not_found`. - Remove your reaction with `DELETE` on the same path, no body. ## Delete a message for everyone ```bash curl -X DELETE "https://api-wagate.socialground.co/v1/sessions//messages/3EB0C767D26A1D8E2A41" \ -H "Authorization: Bearer " ``` ```json { "message_id": "3EB0C767D26A1D8E2A41", "chat_jid": "66XXXXXXXXX@s.whatsapp.net", "id": "3EB0C767D26A1D8E2A42", "timestamp": "2026-09-17T03:01:00Z", "status": "server_ack" } ``` - Only your own messages, sent through the API or on the phone. Someone else's answers 403 `not_own_message`. Deleting other people's messages as a group admin is not offered. - Within 48 hours of sending. An older message answers 409 `revoke_window_passed`. - A message not in the log answers 404 `message_not_found`. - Deleting again is safe: it answers 200 with `status: "already_revoked"` and sends nothing. In that case `id` and `timestamp` are absent. A second delete while the first is running answers 409 `revoke_in_progress`. - The log keeps the message and marks it with `revoked_at`. A delete made on the phone, or by the sender of a message you received, arrives as a `message` webhook of type `protocol` with `revoke.message_id`. ## Mark messages read Mark a whole chat read: ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//messages/read" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"chat": "66XXXXXXXXX@s.whatsapp.net"}' ``` ```json { "status": "read", "receipted": 3 } ``` Or mark named messages: ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//messages/read" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"chat": "120363000000000001@g.us", "message_ids": ["3EB0C767D26A1D8E2A41"], "up_to": true}' ``` - Send `message_ids` (at most 500), or send only `chat` to mark the whole chat. `sender` is also accepted. - A whole-chat read that finds nothing unread answers 200 with `{"status":"nothing_to_acknowledge","receipted":0}`. - In a group, only the ids you pass are marked, and each sender sees only their own. `up_to: true` also marks every unread message in the group at or before the newest id you pass, each to its own sender. A request that would mark 500 or more messages this way, or send receipts to more than 5 senders, answers 400 `up_to_range_too_large` and sends nothing. `up_to` works only in a group chat and only with `message_ids`. - `skipped` lists ids you sent that were not marked: ids this channel has no record of, when the request does not settle which single chat they belong to. - If a read fails after some receipts went out, the answer is 502 `receipt_partial` with `receipted`. Do not resend the request as is. ## Presence and typing ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//presence" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"presence": "available"}' curl -X POST "https://api-wagate.socialground.co/v1/sessions//typing" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"chat": "66XXXXXXXXX@s.whatsapp.net", "state": "typing"}' ``` Both answer `{"status":"ok"}`. - `presence`: `available` or `unavailable`. `online` and `offline` are accepted as other names. - `state`: `typing` (or `composing`), `recording`, or `paused` (or `stop`, which clears it). ## What can go wrong on a send | Status and code | Meaning | What to do | | --- | --- | --- | | 409 `not_linked` | no phone is linked | link it in the console | | 409 `stood_down` | WAgate paused the number after WhatsApp pushed back | reconnect in the console once the restriction ends | | 403 `channel_unentitled` | no paid slot and not the sandbox channel | buy a channel | | 429 `sandbox_allowance_exceeded` | sandbox allowance used | wait until `resets_at` | | 429 `gateway_busy`, `rate_limited` | back-pressure | wait `Retry-After`, retry | | 429 `whatsapp_rate_limited` | WhatsApp throttled the number | back off, retry later | | 504 `send_unconfirmed` | outcome unknown; treat as possibly delivered | do not resend blindly; see Recipes | # Media Source: https://wagate.dev/docs/media Send images, video, audio, voice notes and documents from a URL, and download media you received. - POST sessions//messages/image: Send an image from a URL. - POST sessions//messages/video: Send a video from a URL. - POST sessions//messages/audio: Send an audio file from a URL. - POST sessions//messages/voice: Send a voice note from a URL. - POST sessions//messages/document: Send a document from a URL. - GET sessions//media/: Download media you received. ## Send media The gateway fetches the file from a URL you give it, uploads it to WhatsApp and sends it. You do not upload bytes to WAgate. ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//messages/document" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"to": "66XXXXXXXXX", "url": "https://example.com/files/menu.pdf", "caption": "This week'"'"'s menu", "filename": "menu.pdf"}' ``` The response is the same as for a text: `id`, `chat_jid`, `timestamp` and `status: "server_ack"`. Fields: | Field | Applies to | Meaning | | --- | --- | --- | | `to` | all, required | recipient, as for a text | | `url` | all | where the file is | | `caption` | image, video, document | up to 4096 UTF-8 bytes. Audio and voice notes carry no caption | | `filename` | document | up to 255 UTF-8 bytes, the name the recipient sees. Defaults to the last part of the URL path, then `document` plus an extension for its type | | `mime_type` | document | for example `application/pdf`. Otherwise the type comes from the URL's Content-Type, else from the bytes | ## Accepted types and sizes | Route | Accepted | Largest file | | --- | --- | --- | | `image` | JPEG, PNG | 5 MiB | | `video` | MP4 (H.264), 3GPP | 16 MiB | | `audio` | AAC, MP4 audio, MP3, AMR, Ogg. Sent as a file to play, not as a voice note | 16 MiB | | `voice` | Ogg Opus only. Shown as a voice note with its duration | 16 MiB | | `document` | any file | 32 MiB | The type is read from the file's bytes, so a wrong file extension or Content-Type does not matter. The one exception is `document`, where `mime_type` or the URL's Content-Type is used first. ## What the URL must be - http or https, with no username or password in it. - It must resolve to a public internet address. Every redirect hop is held to the same rule, and it may redirect at most 3 times. - It must answer 2xx within 30 seconds. ## Errors | Status and code | Cause | Fix | | --- | --- | --- | | 400 `media_type_unsupported` | the file is not a type this kind can carry, for example a WebP sent as an image | convert it, or send it as a `document` | | 400 `media_too_large` | over the size limit for the kind | shrink it, or use `document` (32 MiB) | | 400 `media_fetch_failed` | the URL did not answer, answered with an error, or is not a public address | check the URL from outside your network | | 502 `media_upload_failed` | the upload to WhatsApp failed and nothing was sent | safe to retry | | 429 `gateway_busy` with `bound: "media"` | the gateway is holding as many media sends as it can | retry after `Retry-After`, which is 10 seconds | | 504 `send_unconfirmed` | outcome unknown; treat as possibly delivered | do not resend blindly; see Recipes | A media message counts as one message against your daily send cap, and against the sandbox allowance on a sandbox channel, whatever its size. ## Download media you received A `message` webhook, or a message read from a chat, carries `media_id` when the message has media. Download it with `GET`: ```bash curl -o file.bin "https://api-wagate.socialground.co/v1/sessions//media/" \ -H "Authorization: Bearer " ``` - The media ID is 64 hex characters, a dot and an extension. - The answer is the file's bytes, not JSON, always served as `application/octet-stream` as an attachment. Decide the real type from the message's `type` and the extension. - The response carries `Cache-Control: private, no-store`. - A well-formed ID that is not yours always answers 403 `media_forbidden`, whether it belongs to another channel, does not exist, or was removed by retention. An ID that is not in that shape answers 404 `not_found`. # Webhooks Source: https://wagate.dev/docs/webhooks Receive events, verify them, and handle retries and duplicates. Event payloads are on the next page, Webhook events. - GET sessions//webhooks: List this channel's webhook receivers. - PUT sessions//webhooks: Replace this channel's webhook receivers. - POST sessions//webhooks/test: Send one signed test event to a receiver you already set. ## How delivery works - WhatsApp tells WAgate something happened: a message, a receipt, a group change, a connection change. - WAgate stores the event, then delivers it with one HTTP POST to each receiver that wants that event type. - Your endpoint answers with any 2xx status. Anything else, or no answer in time, counts as a failure and the event is retried. Answer fast and do the work after you answer. The default timeout for a delivery attempt is 10 seconds; the deployment can configure it. WAgate reads and discards at most 4096 bytes of your response body. ## Set your receivers ```bash curl -X PUT "https://api-wagate.socialground.co/v1/sessions//webhooks" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "receivers": [ { "url": "https://example.com/wagate", "events": ["message", "message.status"], "secret": "REPLACE_WITH_24_PLUS_CHARACTERS" } ] }' ``` ```json { "receivers": [ { "url": "https://example.com/wagate", "events": ["message", "message.status"], "media_policy": "reference", "secret_set": true } ] } ``` Rules: - A receiver takes `url`, `events` and `secret`. Any other key answers 400 `field_not_allowed`. - `url` must be an absolute http or https address. A URL listed twice is refused. - At most 8 receivers per channel. - `secret` signs every delivery to that receiver. A new receiver needs one of at least 24 characters, otherwise the call answers 400 `invalid_body`. On a receiver you already saved, leave `secret` out to keep the stored one, or send a new value to replace it. A secret cannot be removed: delete the receiver and add it again. If a receiver saved earlier shows `secret_set: false`, add a secret with `PUT webhooks`. Until then you have no key to verify its deliveries with. - `events` lists the event types the receiver wants. List every type you want. If you leave `events` out, the receiver gets the default set: `message`, `group.participants` and `session.status`. That set does not include `message.status`. An empty string in the list is refused. A name that is not an event type is accepted and matches nothing, so check the spelling. - `PUT` replaces the whole list. `{"receivers": []}` removes every receiver. A call with no `receivers` key answers 400 `invalid_body`. - Some receivers carry a media setting that only the console can set. A channel token cannot remove such a receiver: that call answers 403 `admin_only_field`. Remove it in the console. - Receivers you set through the API can only be delivered to public internet addresses. A loopback, private, link-local or metadata address is refused when WAgate tries to connect. Redirects are never followed, so give the final address. `GET sessions//webhooks` returns the same `receivers` list plus `"persistent": true` and a `persistence_note`: every event is stored before delivery and retried for about two days, then kept aside, not dropped. It never returns a secret, only `secret_set`. Each receiver can also show `sessions`, the channels it is scoped to. ## A delivery ```Example POST /wagate HTTP/1.1 Content-Type: application/json X-Wagate-Event: message X-Wagate-Delivery: evt_ X-Wagate-Signature: 64 lowercase hex characters ``` ```json { "id": "evt_", "type": "message", "session": "", "timestamp": "2026-09-17T03:00:00Z", "payload": { "...": "see Webhook events" } } ``` `X-Wagate-Delivery` equals the body's `id`. ## Verify the signature `X-Wagate-Signature` is the lowercase hex HMAC-SHA256 of the raw request body, keyed with your receiver's secret. There is no prefix. Compute it over the bytes you received, before you parse the JSON, and compare in constant time. Node.js: ```js import { createHmac, timingSafeEqual } from 'node:crypto'; export function isFromWAgate(rawBody, signatureHeader, secret) { const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); const given = Buffer.from(signatureHeader ?? ''); const want = Buffer.from(expected); return given.length === want.length && timingSafeEqual(given, want); } ``` Python: ```python import hmac, hashlib def is_from_wagate(raw_body: bytes, signature_header: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature_header or "") ``` Shell, to check by hand: ```bash printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex ``` Common mistakes: parsing and re-serializing the JSON before hashing, using the wrong secret when you have several receivers, and comparing with `==`. ## Retries and duplicates - A failed delivery retries with a growing wait. The wait starts at 1 second, doubles each time and stops growing at 1 hour, plus up to half a second of random jitter. - WAgate tries at least 50 times. At the one-hour cap that rides out about two days of downtime. - After the last attempt the event is kept aside as failed, not dropped. There is no public API to list or replay failed events. - WAgate does not follow redirects. - A retry keeps the same `X-Wagate-Delivery` value. Use it to ignore duplicates: store the IDs you have processed and skip repeats. - Delivery attempts can repeat. A pending or retrying delivery blocks later events for the same receiver URL and channel until it succeeds or is marked failed. Other URLs and channels can keep receiving events. This is not a global ordering guarantee: recovery can still repeat or reorder deliveries. Deduplicate by `X-Wagate-Delivery` and make your handler safe to run twice. ## Test your endpoint ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//webhooks/test" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/wagate"}' ``` ```json { "status": "delivered", "detail": "the receiver accepted a signed test event; nothing was sent to WhatsApp" } ``` - The `url` must be one you already set. Any other answers 404 `not_found`. - It is one attempt, delivered right away, and never reaches WhatsApp. If your endpoint does not answer 2xx, the call answers 400 `invalid_request`. - The test works even when the receiver is subscribed only to other event types. - The test event has type `webhook.test`, header `X-Wagate-Event: webhook.test`, an `X-Wagate-Delivery` and `id` of `test-evt_` plus 32 hex characters, and payload `{"note": "this is a delivery test fired by an operator; no message was sent and nothing changed"}`. Allow for it in a handler that switches on `type`. - Test sends have no cooldown of their own. They count against the general limits like any other request, so a burst of them can answer 429 `rate_limited`. See Limits. ## A minimal receiver ```js import express from 'express'; import { createHmac, timingSafeEqual } from 'node:crypto'; const app = express(); const WEBHOOK_SECRET = ""; // the secret you set on this receiver const seen = new Set(); // use a database in production app.post('/wagate', express.raw({ type: 'application/json' }), (req, res) => { const want = createHmac('sha256', WEBHOOK_SECRET).update(req.body).digest('hex'); const got = req.get('x-wagate-signature') ?? ''; if (got.length !== want.length || !timingSafeEqual(Buffer.from(got), Buffer.from(want))) { return res.sendStatus(401); } const id = req.get('x-wagate-delivery'); if (seen.has(id)) return res.sendStatus(200); seen.add(id); res.sendStatus(200); // answer first const event = JSON.parse(req.body.toString('utf8')); // handle event.type here }); app.listen(3000); ``` A non-2xx answer such as 401 makes WAgate retry. Answer 200 for duplicates. # Webhook events Source: https://wagate.dev/docs/webhook-events The four event types WAgate sends, with the payload of each. Every delivery has the same envelope. The `payload` depends on `type`. See Webhooks for headers, signing and retries. ```json { "id": "evt_", "type": "message", "session": "", "timestamp": "2026-09-17T03:00:00Z", "payload": {} } ``` | `type` | When it fires | | --- | --- | | `message` | a message arrives, or your own number sends from the paired phone | | `message.status` | WhatsApp reports what happened to a message you sent | | `group.participants` | someone joins, leaves, is promoted or is demoted in a group | | `session.status` | the channel's connection state changes | A receiver gets only the types it lists in `events`. A receiver with no `events` gets the default set: `message`, `group.participants` and `session.status`, not `message.status`. To get `message.status`, list it. Ignore fields you do not know. Handle a `type` you do not recognize the way you handle an unknown message `type`: log it and answer 200. The test event `webhook.test` is the one extra type you will meet today. Changes within /v1 are additive only: we add routes, optional fields, event types and error codes, and we never change or remove what is already there. We give 90 days' notice by email before any removal or rename. ## `message` A text received in a direct chat: ```json { "id": "evt_", "type": "message", "session": "", "timestamp": "2026-09-17T03:00:00Z", "payload": { "id": "3EB0C767D26A1D8E2A41", "chat_jid": "66XXXXXXXXX@s.whatsapp.net", "sender_jid": "66XXXXXXXXX@s.whatsapp.net", "push_name": "Customer A", "type": "text", "text": "hello", "from_me": false, "is_group": false, "timestamp": "2026-09-17T03:00:00Z" } } ``` Fields : | Field | Meaning | | --- | --- | | `id` | the message ID | | `chat_jid` | the conversation: a person's JID or a group JID | | `sender_jid` | who sent it | | `push_name` | the sender's WhatsApp display name, when known | | `type` | the message kind, see below | | `text` | the text, or the caption of media, or the poll question | | `media_id` | present for messages with downloadable media; see Media | | `from_me` | true when your number sent it, for example from the paired phone | | `is_group` | true for a group chat | | `timestamp` | when WhatsApp says it was sent | | `mentions` | JIDs the message tags | | `quoted_id`, `quoted_author` | the message this one replies to, and its author | | `sender_lid`, `sender_pn_resolved`, `chat_lid`, `chat_pn_resolved` | see "People and LIDs" | | `poll`, `reaction`, `revoke` | extra objects for poll, reaction and delete messages | Optional fields are absent, not empty. `mentions` absent means the message carried no context to read; `[]` means it tagged nobody. ## Message `type` values `text`, `image`, `video`, `document`, `audio`, `sticker`, `contact`, `location`, `poll`, `poll_vote`, `reaction`, `protocol`, `short` (a video note), `group_invite`, `live_location`, `contact_list`, `unknown`. A message that cannot be described arrives as `unknown`. New types can be added, so treat a type you do not know the way you treat `unknown`. ## Polls and votes A poll arrives as type `poll`: `text` is the question and `poll` holds `options`. `poll.multiple` is present as `true` for a multiple-choice poll and omitted for a single-choice poll; absence means `false`. ```json { "type": "poll", "text": "Which day suits you?", "poll": { "options": ["Monday", "Tuesday"] } } ``` A vote arrives as type `poll_vote`: `poll.poll_id` and the chosen options by name in `poll.selected`. ```json { "type": "poll_vote", "poll": { "poll_id": "3EB0C767D26A1D8E2A41", "selected": ["Tuesday"] } } ``` - `poll.unresolved` counts chosen options that could not be named, because the poll is not in this channel's log. A vote with `unresolved` above 0 is a vote you could not fully read, not a vote for fewer options. - `poll.unreadable` true means the vote could not be decrypted, and `selected` means nothing. - A readable vote with no `selected` and no `unresolved` means the person withdrew their vote. ## Reactions A reaction arrives as type `reaction`. `text` is the emoji and `reaction.message_id` is the message reacted to. A removed reaction has an empty `text` and `reaction.removed` set to true. ```json { "type": "reaction", "text": "👍", "reaction": { "message_id": "3EB0C767D26A1D8E2A41" } } ``` ## Deleted messages A message deleted for everyone arrives as type `protocol` with `revoke.message_id`, the message deleted. `from_me` true means your number deleted it on the phone. `from_me` false means its sender did, or in a group possibly an admin. A delete you make through the API is not echoed here. ```json { "type": "protocol", "from_me": false, "revoke": { "message_id": "3EB0C767D26A1D8E2A41" } } ``` ## People and LIDs People are identified by phone number, as `@s.whatsapp.net` with no device suffix, so the same person always has the same address. WhatsApp sometimes addresses a person by a LID (`@lid` or `@hosted.lid`) instead. When it does: - `sender_jid`, the `chat_jid` of a chat that is not a group, and each entry of `participants` still carry the phone JID whenever WAgate knows it. - The LID the address arrived as is in `sender_lid`, `chat_lid` or `participant_lids`. - When no phone number is known for a LID, the field keeps the LID and a flag says so: `sender_pn_resolved` or `chat_pn_resolved` is present and `false`. These flags appear only in that case. Treat such a value as unresolved, not as a phone number. - A group `chat_jid`, `mentions` and `quoted_author` are delivered as WhatsApp sent them. ## `message.status` What happened to a message you sent. A `server_ack` from the send call only means WhatsApp's servers took it; this event is the other side. ```json { "id": "evt_", "type": "message.status", "session": "", "timestamp": "2026-09-17T03:00:05Z", "payload": { "ids": ["3EB0C767D26A1D8E2A41"], "chat_jid": "66XXXXXXXXX@s.whatsapp.net", "recipient_jid": "66XXXXXXXXX@s.whatsapp.net", "status": "delivered", "timestamp": "2026-09-17T03:00:04Z" } } ``` - `ids`: the messages this receipt covers. WhatsApp batches, so one receipt often covers several sends. - `status`: `delivered`, `read`, `played` or `undecryptable`. - Receipts from your own other devices are not sent. ## `group.participants` ```json { "id": "evt_", "type": "group.participants", "session": "", "timestamp": "2026-09-17T03:00:00Z", "payload": { "group_jid": "120363000000000001@g.us", "action": "join", "participants": ["66XXXXXXXXX@s.whatsapp.net"], "actor_jid": "66XXXXXXXXX@s.whatsapp.net", "timestamp": "2026-09-17T03:00:00Z" } } ``` - `action` is `join`, `leave`, `promote` or `demote`. These differ from the `add` and `remove` you send to the participants route. - `participant_lids` and `participants_pn_resolved` line up with `participants` by index. A participant that arrived as a phone number has `""` in `participant_lids`. `participant_lids` is present only when at least one participant arrived as a LID, and `participants_pn_resolved` only when at least one could not be resolved. - `actor_jid` is who made the change, when known. ## `session.status` The channel's connection state changed. ```json { "id": "evt_", "type": "session.status", "session": "", "timestamp": "2026-09-17T03:00:00Z", "payload": { "state": "DISCONNECTED", "jid": "66XXXXXXXXX@s.whatsapp.net", "reason": "..." } } ``` `state` is one of `NEW`, `PAIRING`, `CONNECTED`, `DISCONNECTED`, `BLOCKED`. `jid` and `reason` appear when known. Use it to alert yourself when a channel stops being `CONNECTED`. # Limits Source: https://wagate.dev/docs/limits What limits a channel has, and what each 429 means. ## Paid channels Paid channels have no gateway send cap by default and no sandbox message allowance. An operator override can impose a positive daily ceiling or a zero stop. Public ingress and in-flight limits still apply. Pacing is up to you. WhatsApp can still restrict a number that behaves badly, so watch Health before you push volume. Public customer request limits are below. Ingress and concurrency values are code defaults that the deployment can configure; use the returned `window`, `bound` and `Retry-After` rather than treating these numbers as fixed API guarantees: - A request body can be at most 1 MiB. - One client address can make at most 3,000 requests a minute across all channels and tokens, with a burst of 600. The public edge also has a shared ceiling of 12,000 requests a minute with a burst of 2,400. A 429 `rate_limited` with a `window` starting `ingress.` comes from these. - One token may have at most 16 requests in flight at once. Over that, 429 `rate_limited` with `window: "concurrency.token"` and a 1 second wait. When the whole API is carrying as much as it will, you get the same 429 with `window: "concurrency"`. - The gateway holds at most 8 in-flight requests per channel, and also bounds all of one account's channels together and a shared pool. Over a bound, 429 `gateway_busy` with `bound` set to `channel`, `account` or `pool`. - Media sends have their own bound: 429 `gateway_busy` with `bound: "media"` and a 10 second wait. Design for back-pressure: keep a small queue on your side and send a few at a time. Branch on the 429 code: a busy gateway needs a short wait, an exhausted daily allowance can mean waiting until midnight, and a zero send cap needs support to change it. ## The sandbox channel Every account comes with one free sandbox channel for testing. Its allowances are counted in Bangkok time (UTC+7). | Allowance | `window` value | Limit | Resets | | --- | --- | --- | --- | | Messages sent | `sandbox_messages_daily` | 180 a day | midnight | | New chats | `sandbox_chats_monthly` | 10 a month | the first of the month | | Number checks | `sandbox_number_checks_daily` | 20 a day | midnight | | Group adds | `sandbox_group_adds_daily` | 5 a day | midnight | | API calls | `sandbox_api_calls_monthly` | 1,500 a month | the first of the month | What counts: - Every send counts as a message: texts, contact cards, polls, media, reactions, removed reactions and deletes. - A chat you already messaged this month does not count against new chats again. - Group adds count each person added. Creating a group, creating a community and linking a group into a community count as group adds too. - Every request made with the sandbox channel's token counts as one API call, reads included, whether or not it succeeds. - Requests WAgate refuses before they run (a 429 from a busy gateway, or from any allowance), requests the console makes for you, messages you receive and webhooks WAgate delivers do not count. - A request that would go over an allowance is refused whole, before anything is sent. ## Reading a 429 Branch on `code`, never on the status alone. | `code` | Cause | Wait | | --- | --- | --- | | `rate_limited` | a flood guard refused a burst | `Retry-After` header, then retry | | `gateway_busy` | too many requests in flight | `Retry-After` header (1 second, 10 for media), then retry; nothing is queued | | `sandbox_allowance_exceeded` | a sandbox allowance ran out | `Retry-After` header, or until `resets_at` | | `daily_send_cap_exceeded` | a daily send ceiling is exhausted, or a zero cap stops sends | for a positive ceiling, until `resets_at`; for `cap: 0`, contact support | | `daily_cap_group_add`, `daily_cap_contact_write`, `daily_cap_contact_check` | a warm-up protection allowance is used | `retry_after` in the body, or until `resets_at` | | `history_request_too_soon` | history requested too recently | `Retry-After` header (also `retry_after` in the body) | | `whatsapp_rate_limited` | WhatsApp throttled the number | no header; back off, retry later | Test sends to your webhook receivers (`POST webhooks/test`) have no cooldown of their own. They count against these limits like any other request. Example bodies: ```json { "code": "rate_limited", "error": "...", "window": "ingress.source", "retry_after": 2 } ``` ```json { "code": "gateway_busy", "error": "...", "bound": "account", "retry_after": 1 } ``` ```json { "code": "sandbox_allowance_exceeded", "error": "...", "window": "sandbox_messages_daily", "cap": 180, "used": 180, "reserved": 0, "resets_at": "2026-09-30T00:00:00+07:00", "retry_after": 3600 } ``` ```json { "code": "daily_cap_group_add", "error": "...", "class": "group_add", "window": "daily_group_add", "cap": 5, "sent": 5, "tier": "...", "resets_at": "2026-09-30T00:00:00+07:00", "retry_after": 3600 } ``` `daily_send_cap_exceeded` carries `window: "daily_send"`, `cap`, `sent`, `resets_at` and `retry_after`. An exhausted positive ceiling resets at Bangkok midnight (`resets_at`). A cap of `0` stops sends and remains zero after midnight: email hello@wagate.dev to have it changed. The same code can also mean send counting became unreliable for the rest of the day; inspect the error before retrying. Check the channel's `daily_send_cap` and `daily_send_cap_source` if the ceiling is unexpected. ## Warm-up protection An optional switch in the console. With it on, group adds, group and community creation, contact checks and contact saves have their own daily allowances, and a call over the allowance answers 429 `daily_cap_`. With it off, a paid channel has none of these allowances. ## Pace yourself WAgate imposes no send timing floor on paid channels; the request limits and any send cap described above still apply. A loop that sends as fast as it can is the kind of traffic WhatsApp restricts. Space sends out with random gaps rather than a fixed interval. See Recipes for a paced sender. # Errors Source: https://wagate.dev/docs/errors Every error code the API returns, what causes it, and what to do. An error is JSON with a `code` you can branch on and an `error` message for people. Branch on `code`. The message can change. Treat a code you do not know as a generic failure. ```json { "code": "unauthorized", "error": "invalid bearer token" } ``` Some codes add fields to the body, shown below. A failure on the connection to WhatsApp can also add `reason`, one of `proxy_unreachable`, `proxy_auth_failed`, `proxy_tls_failed`, `proxy_timeout`, `proxy_dns_failed` or `connect_failed`. ## How to read the status | Status | Meaning | Retry? | | --- | --- | --- | | 400 | your request is wrong | no, fix it | | 401 | token problem | no, fix the token | | 403 | the token is valid but may not do this | no | | 404 | the route or object does not exist | no | | 409 | the channel's state does not allow this | after the state changes | | 429 | back-pressure or an allowance | after the wait | | 500 | the request failed; a write may already have applied | idempotent reads: backoff; writes: inspect before retrying; report if it persists | | 502, 503 | the request or response failed; a write may already have applied | idempotent reads: backoff; writes: inspect before retrying | | 504 | outcome of a send unknown | not blindly, see `send_unconfirmed` | For writes, a 500, 502 or 503 does not prove nothing happened. The edge can reject a response after the operation succeeded. Inspect before retrying: list or read the state you changed, and never re-create a group without reading/listing first. A send may be delivered even when you cannot confirm it; see Recipes. Only retry a write directly when the specific error proves it was refused before being sent. `POST messages/read` sends receipts and is a write, despite its name. ## 400 Bad request | Code | Cause | Fix | | --- | --- | --- | | `invalid_body` | the body is not valid JSON, is empty, is over 1 MiB, carries a field the route does not take, lists a webhook receiver without a secret, or has malformed or invalid `mentions` | send a JSON object with only the documented fields | | `invalid_request` | well-formed but cannot be done as sent, for example more than 50 participants, a duplicate participant, a name that is too long, an `X-Wagate-Groups-Fresh` value other than `required`, or a mention whose phone identity cannot be resolved | read the `error` text, fix the value | | `field_not_allowed` | a key this route does not accept, on routes that name it (channel `PATCH`, `history-sync`) | remove the key | | `header_not_allowed` | the request carried an account header | send only the bearer token | | `up_to_range_too_large` | a read with `up_to` would mark too many messages or send receipts to too many senders | mark an older message with `up_to` first, or read without `up_to`; nothing was sent | | `quote_not_found` | the quoted message is not in this channel's log | quote a message this channel has | | `quote_wrong_chat` | the quoted message belongs to another chat | quote a message from the same chat | | `media_too_large` | the media is over the limit for its kind | shrink it, or use `document`; limits are on Media | | `media_type_unsupported` | not a type this kind can carry | convert it, or send as `document` | | `media_fetch_failed` | the URL did not answer, answered with an error, or is not a public address | check the URL from outside your network | ## 401 Unauthorized | Code | Cause | Fix | | --- | --- | --- | | `unauthorized` | the token is missing, too short, unknown, or does not open this channel; or the channel does not exist | check the token and the channel ID | | `credential_stale` | the token stopped applying during the request: replaced, or the channel removed | use the current token | | `slot_ended` | this channel's paid slot has ended | renew it in the console | A 401 carries `WWW-Authenticate: Bearer`. ## 403 Forbidden | Code | Cause | Fix | | --- | --- | --- | | `admin_only_field` | your webhook list would drop a receiver whose media setting was set in the console | keep that receiver, or remove it in the console | | `account_suspended` | the account that owns this channel is on hold | contact support | | `number_forbidden` | a supplied phone number could not be read as a valid number | check the number format; invalid mentions answer 400 `invalid_body` or `invalid_request` | | `blocked` | this channel is blocked | contact support | | `channel_unentitled` | no paid slot, and not your sandbox channel | buy a channel in the console | | `media_forbidden` | the media belongs to another channel, to nobody, does not exist, or was removed by retention. One answer for all four, on purpose | check the `media_id` came from this channel | | `not_own_message` | you tried to delete a message this channel did not send | only your own messages can be deleted for everyone | | `lid_lookup_not_granted` | LID lookup is not enabled for this channel; only an operator can enable it | do not retry; contact support if you need it | ## 404 Not found | Code | Cause | Fix | | --- | --- | --- | | `not_found` | the route does not exist (a malformed media ID counts), or `webhooks/test` named a URL this channel has not set | check the path and the URL | | `message_not_found` | the message is not in this channel's log | use an ID from a send result or a webhook | | `no_history_anchor` | the channel holds no message in that chat to ask for older history from | wait for a message, or send one | ## 409 Conflict | Code | Cause | Fix | | --- | --- | --- | | `not_linked` | the channel has no linked phone | link it in the console | | `stood_down` | WAgate paused this number after WhatsApp pushed back | reconnect it in the console after the restriction ends | | `verification_failed` | the subgroup WAS created, and checking it afterward failed. `group_jid` names it | do NOT create it again; see Communities | | `revoke_window_passed` | the message is too old to delete for everyone | it can no longer be deleted for everyone | | `revoke_in_progress` | a delete of that message is already running | wait for it to return | | `history_sync_off` | synced history is off for this channel | turn on `store_history_sync` | `verification_failed` body: ```json { "code": "verification_failed", "error": "...", "verification": "...", "definitive": true, "group_jid": "120363000000000009@g.us", "retry_as_ambiguous": false } ``` ## 429 Too many requests | Code | Cause | Wait | | --- | --- | --- | | `rate_limited` | a flood guard refused a burst; the body has `window` and `retry_after` | `Retry-After` seconds | | `gateway_busy` | the gateway is working on too many requests; `bound` says which limit; nothing is queued | `Retry-After` seconds | | `sandbox_allowance_exceeded` | the sandbox channel used up an allowance; `window` says which | until `resets_at` | | `daily_send_cap_exceeded` | this channel exhausted a positive daily ceiling, has a zero stop, or its send counting became unreliable; the body has `window`, `cap`, `sent`, `resets_at` and `retry_after` | positive ceiling: until `resets_at`; zero cap: contact support; inspect counting errors | | `whatsapp_rate_limited` | WhatsApp throttled the number; no `Retry-After` is sent | back off, retry later | | `daily_cap_group_add` | today's group-add allowance is used (warm-up protection is on); the body has `class`, `cap`, `sent`, `tier` and `resets_at` | until `resets_at` | | `daily_cap_contact_write` | today's contact-save allowance is used (warm-up protection is on); the body has `class`, `cap`, `sent`, `tier` and `resets_at` | until `resets_at` | | `daily_cap_contact_check` | today's number-check allowance is used (warm-up protection is on); the body has `class`, `cap`, `sent`, `tier` and `resets_at` | until `resets_at` | | `history_request_too_soon` | a history request for this channel or chat came too recently | `Retry-After` seconds (also `retry_after` in the body) | Details and example bodies: Limits. ## 500, 502, 503, 504 | Code | Status | Cause | What to do | | --- | --- | --- | --- | | `internal_error` | 500 | WAgate could not complete the request | idempotent reads: backoff; writes: inspect before retrying; contact support if it persists | | `upstream_unreachable` | 502 | the gateway could not reach the service behind it | idempotent reads: backoff; writes: inspect before retrying | | `upstream_redirect` | 502 | the service behind it answered with a redirect | idempotent reads: backoff; writes: inspect before retrying | | `upstream_not_json` | 502 | the service answered with something unreadable | idempotent reads: backoff; writes: inspect before retrying | | `upstream_not_relayable` | 502 | the answer could not be passed back safely | idempotent reads: backoff; writes: inspect before retrying | | `upstream_too_large` | 502 | the media object is larger than the public API relays | use a smaller object | | `media_upload_failed` | 502 | the upload to WhatsApp failed and nothing was sent | safe to retry | | `receipt_partial` | 502 | some read receipts went out before the read failed; `receipted` says how many | do not resend the same request | | `upstream_unavailable` | 503 | the token could not be checked right now | retry | | `wagate_not_configured` | 503 | this deployment has no upstream configured | contact support | | `operator_unresolved` | 503 | the gateway could not confirm its own account setup for a moment; nothing is wrong with your channel | retry after `Retry-After` (5 seconds) | | `whatsapp_disconnected` | 503 | the WhatsApp connection dropped before the request completed; the response cannot tell whether the write was sent | treat any write as possibly applied; list or read the state of what you changed before retrying a write; never re-create (e.g. a group) without reading/listing first; reads can be retried after a short back-off | | `whatsapp_timeout` | 503 | WhatsApp did not answer an info query in time; the response cannot tell whether the write was sent | treat any write as possibly applied; list or read the state of what you changed before retrying a write; never re-create (e.g. a group) without reading/listing first; reads can be retried after a short back-off | | `read_unresolvable` | 503 | a read receipt could not find the other side's address | inspect read/receipt state before retrying this write | | `history_unresolvable` | 503 | a history read was refused rather than answered short, because the other side's second address could not be resolved | retry | | `history_unavailable` | 503 | synced history is unavailable right now | retry later | | `audit_unavailable` | 503 | the read could not be recorded first, so it was not served | retry | | `lane_reclaimed` | 503 | a call reached its deadline before it finished; the body has `ambiguous` | see below; `Retry-After`: 1 | | `send_unconfirmed` | 504 | the outcome of a send is UNKNOWN; it may have reached WhatsApp | see below | ## The three "outcome unknown" errors ```json { "code": "send_unconfirmed", "error": "...", "ambiguous": true, "id": "ambiguous:evt_" } ``` ```json { "code": "lane_reclaimed", "error": "...", "retry_after": 1, "ambiguous": false } ``` ```json { "code": "receipt_partial", "error": "...", "receipted": 12 } ``` - `send_unconfirmed` (504): the message may have been delivered. The body has `ambiguous: true` and, when WAgate could log the attempt, an `id`. That `id` names a row in this channel's chat log marked `ambiguous: true`. It is not a WhatsApp message ID, so no `message.status` webhook will carry it, and neither the row nor its absence tells you whether the message went out. Do not resend blindly: check with the recipient where you can, and resend only if you accept a possible duplicate. See Recipes. - `lane_reclaimed` (503): `ambiguous` is always present. `false` means the call was refused before it was sent and nothing reached WhatsApp, so a plain retry is safe. `true` means it was sent and the outcome is unknown: inspect before retrying a write. For a group add, read membership first; if the participant is already present, treat the add as success. - `receipt_partial` (502): some receipts went out. Do not send the same request again, or those messages are marked twice. Mark only the rest. Full handling code for all three is in Recipes. ## Warming codes | Code | Status | Meaning | | --- | --- | --- | | `warming_peer_refused` | 403 | Warming refused a partner number. Contact support. | | `warming_cooldown` | 409 | Stopped for safety. `restart_after` says when warming can be switched on again, or null when an operator clears it. | | `warming_ack_required` | 409 | The account has not acknowledged the current warming text. Send `acknowledge`. | | `warming_restart_ack_required` | 409 | The cooldown is over. Send `restart_ack: true` to switch warming back on. | | `warming_state_unreadable` | 503 | Warming state could not be read right now. Retry. | | `warming_ack_version_unknown` | 400 | The `text_version` is not the current one. | # Groups Source: https://wagate.dev/docs/groups List, create and manage groups: participants, subject, description, settings, invite link. **These actions have their own daily allowance when warm-up protection is on** - With optional warm-up protection on, group adds, contact saves and contact checks count against a daily allowance for your number’s age: on a new number a few a day, rising week by week. Over the allowance you get a 429 with the class and your tier. Adding people to groups and writing contacts on a new number is what gets numbers restricted, so keep it low and let Health guide you. One call carries at most 50 people. - The sandbox channel also counts group adds and number checks against its own allowances (5 group adds and 20 number checks a day), whether or not warm-up protection is on. - Checking a long list of numbers you have never talked to is a pattern WhatsApp acts on. Check the people you are about to message, not a bought list. - GET sessions//groups: List this channel's groups. - POST sessions//groups: Create a group. - GET sessions//groups/: Get one group. - POST sessions//groups//participants: Add, remove, promote or demote people. - GET sessions//groups//invite-link: Get the invite link. - PUT sessions//groups//subject: Set the subject. - PUT sessions//groups//description: Set or clear the description. - PUT sessions//groups//settings: Change settings. A group is addressed by its group JID, for example `120363000000000001@g.us`. ## The group object ```json { "jid": "120363000000000001@g.us", "name": "Team", "description": "Team updates", "owner_jid": "66XXXXXXXXX@s.whatsapp.net", "created": "2026-09-01T03:00:00Z", "participants": [ { "jid": "66XXXXXXXXX@s.whatsapp.net", "phone_number": "66XXXXXXXXX@s.whatsapp.net", "lid": "100000000000000@lid", "phone_resolved": true, "is_admin": true, "is_super_admin": true } ], "participant_count": 1, "announce": false, "locked": false, "member_add_mode": "admin_add", "is_community": false, "is_default_subgroup": false, "is_incognito": false, "owner_resolved": true } ``` - `announce` true means only admins can send. - `locked` true means only admins can edit group info. - `member_add_mode` is `admin_add` or `all_member_add`. - `linked_parent_jid` is present for a group linked to a community. - `description`, `owner_jid`, `participants`, `phone_number` and `lid` are left out when empty. - `phone_number`, when present, is a full phone JID such as `66XXXXXXXXX@s.whatsapp.net`, not bare digits. - `phone_resolved` is false when WAgate could not work out the person's phone number. `owner_resolved` says the same for the owner. ## List groups ```bash curl "https://api-wagate.socialground.co/v1/sessions//groups" \ -H "Authorization: Bearer " ``` ```json { "groups": [ { "jid": "120363000000000001@g.us", "name": "Team", "participant_count": 12 } ], "fetched_at": "2026-09-17T03:00:00.123456789Z", "stale": false, "cache": "hit" } ``` The list may be served from a recent copy. Every answer says how fresh it is: - `fetched_at`: when WhatsApp produced this list. - `cache`: `miss` (WhatsApp was asked for this answer), `hit` (served from a recent copy), or `stale` (WhatsApp is throttling this number, so the last good list is served). - `stale`: `true` exactly when `cache` is `stale`. To force a list WhatsApp produced for this request, send the request header `X-Wagate-Groups-Fresh: required`. Any other value answers 400 `invalid_request`. A fresh read asks WhatsApp every time, so it can answer 429 `whatsapp_rate_limited` instead of a cached list. Use it only when you are about to act on the list. Creating a group, renaming it, changing its description or participants, and leaving a group each drop the cached list for the channel. ## Create a group ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//groups" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"subject": "Team", "participants": ["66XXXXXXXXX"]}' ``` Answers 201 with the group object. - `subject`: required, 1 to 25 characters. - `participants`: optional, at most 50. Phone numbers or JIDs. - `parent_jid`: optional. Creates the group inside that community. A create counts as one group add for the group, one for each participant, and one more with `parent_jid`. On the sandbox channel these come out of the sandbox group-add allowance. Where warm-up protection is on, they come out of today's group-add allowance and can answer 429 `daily_cap_group_add`. ## Change participants ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//groups/120363000000000001@g.us/participants" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"action": "add", "participants": ["66XXXXXXXXX@s.whatsapp.net"]}' ``` ```json { "results": [ { "jid": "66XXXXXXXXX@s.whatsapp.net", "status": "ok", "lid": "100000000000000@lid", "matched_by": "a" } ] } ``` - `action` is `add`, `remove`, `promote` or `demote`. - `participants` is required, 1 to 50 per call for every action. Naming the same person twice answers 400 `invalid_request`. Send each person once. - The answer has one entry per person, so one person failing does not fail the others. Each entry's `jid` is the string you sent. - `status` is `ok`, `error_` or `unknown`. An `error_` entry also has an `error` sentence. The codes WhatsApp returns per person: 403 not allowed or the person's privacy blocks the add; 408 left recently; 409 already in the group; 401 the person blocked this number; 404 not on WhatsApp. Any other code reads "unspecified participant error". - `matched_by` says how WAgate attached WhatsApp's answer to that person: `a`, `c`, `single` or `unknown`. - If WAgate cannot tell which person an answer belongs to, that result is `status: "unknown"`. The change may have applied. Read the group before you retry. - Each person added counts as one group add. Remove, promote and demote do not. - An add can answer 503 `lane_reclaimed` with `ambiguous`. See Errors. ## Subject, description, invite link ```bash curl -X PUT "https://api-wagate.socialground.co/v1/sessions//groups/120363000000000001@g.us/subject" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"subject": "New name"}' # {"status":"renamed"} curl -X PUT "https://api-wagate.socialground.co/v1/sessions//groups/120363000000000001@g.us/description" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"description": "Updates, one a day"}' # the updated group object curl "https://api-wagate.socialground.co/v1/sessions//groups/120363000000000001@g.us/invite-link" \ -H "Authorization: Bearer " # {"invite_link":"https://chat.whatsapp.com/XXXXXXXX"} ``` - Subject: 1 to 25 characters. The answer is `{"status": "renamed"}`. - Description: up to 2048 characters. An empty string removes it. WAgate reads the group back after the change, and the answer is the group object. If the read-back does not match, the call fails instead of answering 200. - Invite link: `{"invite_link": "..."}`. ## Change settings ```bash curl -X PUT "https://api-wagate.socialground.co/v1/sessions//groups/120363000000000001@g.us/settings" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"announce": true, "locked": false, "member_add_mode": "admin_add"}' ``` ```json { "jid": "120363000000000001@g.us", "applied": ["announce", "locked", "member_add_mode"], "observed": { "announce": true, "locked": false, "member_add_mode": "admin_add" } } ``` A 200 always has exactly these three keys. Send at least one setting. Settings you leave out stay as they are. The settings are applied one at a time, then the group is read back. A 200 means every setting you sent was applied and the read-back matches. Any failure is an error, never a 200 with a note. The error body is the usual `code` and `error` plus three keys that say how far it got: ```json { "code": "invalid_request", "error": "...", "applied": ["announce", "locked"], "observed": { "announce": false, "locked": true, "member_add_mode": "admin_add" }, "mismatch": ["announce"] } ``` - `applied`: the settings WhatsApp accepted before the failure, in the order `announce`, `locked`, `member_add_mode`. A setting not in the list was not applied, and the ones after a failed setting were not attempted. - `observed`: the group as read back. It is only meaningful when every setting was applied and the read-back ran. Before that, it holds `false`, `false` and `""`, which are placeholders, not the group's state. - `mismatch`: the settings WhatsApp accepted but that did not take on the read-back. It is `null` unless that read-back ran and found a difference. - `applied` and `mismatch` are both `null` when WAgate could not report progress: when the request failed before any setting was tried (for example 409 `not_linked`), and also when the channel's state changed between applying the settings and reading them back (for example it was stood down). In that second case settings may have applied, so read the group before you retry. If `mismatch` is a non-empty list, WhatsApp accepted those settings and did not act on them, so read the group before you retry. Otherwise, retry only the settings missing from `applied`. The status and `code` are those of the underlying failure: a WhatsApp refusal or a read-back mismatch is 400 `invalid_request`, a WhatsApp throttle is 429 `whatsapp_rate_limited`, and channel states are the usual codes such as 409 `not_linked`. ## Errors on these routes 409 `not_linked`, 409 `stood_down`, 403 `blocked`, 429 `daily_cap_group_add` (where warm-up protection is on), 429 `sandbox_allowance_exceeded` (sandbox group adds), 429 `whatsapp_rate_limited`, 503 `lane_reclaimed`. See Errors. # Communities Source: https://wagate.dev/docs/communities Create a community, add subgroups, link groups, list participants, get the invite link. - POST sessions//communities: Create a community. - GET sessions//communities/: Get one community. - GET sessions//communities//invite-link: Get its invite link. - GET sessions//communities//participants: List its participants. - GET sessions//communities//subgroups: List its subgroups. - POST sessions//communities//subgroups: Create a subgroup. - POST sessions//communities//links: Link an existing group. - DELETE sessions//communities//links/: Unlink a group. A community is a group with `is_community: true`. Group adds are counted. Creating a community is one add, linking a group is one add, and creating a subgroup is one add for the subgroup, one for its link and one for each participant you create it with. The count only matters on a sandbox channel (5 group adds a day) and on a channel with warm-up protection on. See Limits. ## Create a community ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//communities" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"subject": "My community"}' ``` Answers 201 with a group object where `is_community` is true. `subject` is 1 to 25 characters. WhatsApp also creates the community's announcement group, so a new community already has one subgroup. ## Get a community `GET communities/` returns the same fields as a group. See Groups. The invite link comes from `GET communities//invite-link` and answers `{"invite_link": "..."}`. If the JID is a group and not a community, it answers 400. Use the group invite link route for groups. ## Subgroups ```bash curl "https://api-wagate.socialground.co/v1/sessions//communities/120363000000000002@g.us/subgroups" \ -H "Authorization: Bearer " ``` ```json { "subgroups": [ { "jid": "120363000000000003@g.us", "name": "Announcements", "is_default_subgroup": true } ] } ``` `is_incognito` is not returned by this API. Create one: ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//communities/120363000000000002@g.us/subgroups" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"subject": "Support", "participants": ["66XXXXXXXXX"], "hidden": true}' ``` - `subject` is required, 1 to 25 characters. `participants` is optional, at most 50. `hidden` is optional. - Answers 201 with the group. With `hidden: true`, a 201 does not prove the subgroup is hidden, because WhatsApp gives no readable signal for that. Check on a phone if it matters. - With `hidden: true`, the new group is read back to check it is linked to the community. If it is not, you get 409 `verification_failed` with `group_jid`, `verification: "failed"`, `definitive: true` and `retry_as_ambiguous: false`. The subgroup WAS created. Do not create it again. Link the `group_jid` from the error with the links route instead. ## Link and unlink a group ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//communities/120363000000000002@g.us/links" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"group_jid": "120363000000000001@g.us", "hidden": false}' curl -X DELETE "https://api-wagate.socialground.co/v1/sessions//communities/120363000000000002@g.us/links/120363000000000001@g.us" \ -H "Authorization: Bearer " ``` Both answer 200 with the group object, after reading the group back to confirm the change took. WhatsApp fixes whether a linked group is hidden at the moment it is linked. To change it, unlink the group and link it again with the new `hidden` value. ## Participants ```bash curl "https://api-wagate.socialground.co/v1/sessions//communities/120363000000000002@g.us/participants" \ -H "Authorization: Bearer " ``` ```json { "participants": [ { "jid": "66XXXXXXXXX@s.whatsapp.net", "phone_number": "66XXXXXXXXX@s.whatsapp.net", "phone_resolved": true } ], "count": 1 } ``` This is everyone across the community's linked groups. WhatsApp may identify a person by LID. Then `jid` and `lid` hold the LID, and `phone_number` is present only when the phone number is known. `phone_resolved` says which case you have. ## Errors on these routes - 409 `not_linked`: the number is not linked. 409 `stood_down`: WhatsApp restricted the number and WAgate stopped using it. - 409 `verification_failed`: subgroup create only, see above. - 403 `blocked`: the channel is blocked. - 429 `daily_cap_group_add`: warm-up protection is on and today's group-add allowance is used up. - 429 `sandbox_allowance_exceeded`: a sandbox channel used its 5 group adds for the day. See Errors. # Contacts Source: https://wagate.dev/docs/contacts Check which numbers are on WhatsApp, and save, read and remove contacts. - POST sessions//contacts/check: Check which numbers are on WhatsApp. - GET sessions//contacts/: Get a contact. - PUT sessions//contacts/: Save or update a contact. - DELETE sessions//contacts/: Remove a contact. - GET sessions//lids/pn/: Look up the LID for a phone number. - GET sessions//lids/: Look up the phone number for a LID. `` is a user JID (`66XXXXXXXXX@s.whatsapp.net`) or a LID (`@lid`). A bare phone number also works. Checking a long list of numbers you have never talked to is a pattern WhatsApp acts on. Check the people you are about to message, not a bought list. Saving a contact before you message them helps your address book reading (see Your channel and its health). Saving people in bulk that you never message does not. ## Check numbers ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//contacts/check" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"phones": ["66XXXXXXXXX", "66YYYYYYYYY"]}' ``` ```json { "results": [ { "query": "+66XXXXXXXXX", "registered": true, "jid": "66XXXXXXXXX@s.whatsapp.net", "is_business": true, "verified_name": "Example Shop" }, { "query": "+66YYYYYYYYY", "registered": false } ] } ``` - 1 to 50 numbers per call. An empty list, more than 50, or a value with no digits is refused with 400. - `jid` appears when the number is on WhatsApp. `is_business` appears only when it is `true`. `verified_name` appears only when WhatsApp has one. - Each number counts as one number check. A sandbox channel gets 20 a day (429 `sandbox_allowance_exceeded`). With warm-up protection on, a daily allowance by number age applies (429 `daily_cap_contact_check`). See Limits. ## Get a contact ```bash curl "https://api-wagate.socialground.co/v1/sessions//contacts/66XXXXXXXXX@s.whatsapp.net" \ -H "Authorization: Bearer " ``` ```json { "jid": "66XXXXXXXXX@s.whatsapp.net", "push_name": "Customer A", "full_name": "Customer A", "first_name": "Customer A", "found": true, "handset": "saved" } ``` `business_name` also appears for a business contact. This reads WAgate's own store and makes no call to WhatsApp. ## Save a contact ```bash curl -X PUT "https://api-wagate.socialground.co/v1/sessions//contacts/66XXXXXXXXX@s.whatsapp.net" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"full_name": "Customer A", "first_name": "Customer A"}' ``` - `full_name` is required. `first_name` is optional and defaults to `full_name`. Both are trimmed. - The target must be a person: `@s.whatsapp.net` or `@lid`. Anything else is a 400. - Saving again with the same names changes nothing. - The answer is the saved contact read back from the store. A save that did not land is an error, not a 200 with `found: false`. - Each save counts as one contact write. With warm-up protection on, a daily allowance applies (429 `daily_cap_contact_write`). ## The handset field Saving, reading and removing a contact answer `handset`. It says whether the linked phone holds the contact under those names. Writing to the phone is switched on per channel by the gateway operator. Where it is off, every save is kept in WAgate only. | `handset` | Meaning | | --- | --- | | `saved` | The phone confirmed the names. | | `pending` | The write to the phone is queued. | | `unknown` | A write may or may not have reached the phone. | | `failed` | This request did not make it. No phone number is known for the contact yet, the queue is full, or the channel's phone writes are halted. | | `off` | Phone writes are not enabled on the channel, so the contact is saved in WAgate only. | | `removed` | The phone confirmed the contact is not in its contact list. | If phone writes are halted, a write that was already queued waits until an operator clears the halt. One that could not be queued is not made, so send it again once the halt is cleared. ## Remove a contact ```bash curl -X DELETE "https://api-wagate.socialground.co/v1/sessions//contacts/66XXXXXXXXX@s.whatsapp.net" \ -H "Authorization: Bearer " ``` - No body. It clears the stored names and, where phone writes are enabled, removes the contact from the linked phone through the same queue as a save. - Removing a contact that is not stored, or already removed, answers 200. - Without phone writes enabled it answers 200 with `handset: "off"`, and only the stored names are cleared. - It counts as one contact write. ## LID lookup Two read routes map a phone number to its LID and back. They read WAgate's own LID map and make no call to WhatsApp. They answer only on a channel the gateway operator has enabled LID lookup for. Your channel's `lid_lookup` field says whether yours is. If it is not, both answer 403 `lid_lookup_not_granted`. Do not retry that error, because it does not change on its own. Email support if you need LID lookup. `` is 6 to 20 digits with an optional leading `+`. `` is digits followed by a literal `@lid`. ```bash curl "https://api-wagate.socialground.co/v1/sessions//lids/pn/66XXXXXXXXX" -H "Authorization: Bearer " ``` ```json { "pn": "66XXXXXXXXX@s.whatsapp.net", "lid": "100000000000000@lid" } ``` A mapping WAgate does not know is still a 200, with the unknown side left out. ## Errors on these routes 409 `not_linked`, 409 `stood_down`, 403 `blocked`, 403 `lid_lookup_not_granted` (LID routes), 429 `daily_cap_contact_check`, 429 `daily_cap_contact_write`, 429 `sandbox_allowance_exceeded`. See Errors. # Chats and history Source: https://wagate.dev/docs/chats List chats, read a conversation page by page, and ask the phone for older history. - GET sessions//chats: List chats, up to 100. - GET sessions//chats//messages: Read one conversation, newest first. - GET sessions//history-sync: See whether synced history is stored. - PUT sessions//history-sync: Turn storing synced history on or off. - POST sessions//chats//history-requests: Ask the phone for older messages of one chat. Chats and messages come from WAgate's own log of this channel. The log starts when the number was linked, unless synced history is on. ## List chats ```bash curl "https://api-wagate.socialground.co/v1/sessions//chats" -H "Authorization: Bearer " ``` ```json { "chats": [ { "chat_jid": "66XXXXXXXXX@s.whatsapp.net", "last_at": "2026-09-17T03:00:00Z", "message_count": 42, "unread": 2 } ] } ``` Up to 100 chats. `unread` can be `null`. ## Read a conversation ```bash curl "https://api-wagate.socialground.co/v1/sessions//chats/66XXXXXXXXX@s.whatsapp.net/messages?limit=50" \ -H "Authorization: Bearer " ``` ```json { "messages": [ { "id": "3EB0C767D26A1D8E2A41", "chat_jid": "66XXXXXXXXX@s.whatsapp.net", "sender_jid": "66XXXXXXXXX@s.whatsapp.net", "direction": "in", "type": "text", "text": "hello", "from_me": false, "timestamp": "2026-09-17T03:00:00Z", "source": "live", "source_chat": "66XXXXXXXXX@s.whatsapp.net" } ], "chats": ["66XXXXXXXXX@s.whatsapp.net"], "merge": "none_known", "next_before": "AbC123" } ``` - Messages come newest first, 50 to a page by default. `limit` is 1 to 500. - When older messages exist, the response has `next_before`. Pass it back as `before=`, exactly as returned. Stop when it is absent. Any other `before` value is a 400. - `limit` and `before` are the only query parameters this route reads. - `direction` is `in` or `out`. `from_me` is the more reliable field: a message sent from the paired phone is stored as `in` with `from_me: true`. - `source` is `live` for a message captured while the channel was connected, or `history_sync` for one the phone sent as history. - `type` includes text, image, video, document, audio, sticker, contact, contact_list, location, live_location, poll, poll_vote, reaction, protocol, short, group_invite and unknown. New values can be added, so treat one you do not recognize as unknown. - Other fields on a message when they apply: `media_id`, `push_name`, `mentions`, `quoted_id`, `quoted_author`, `poll`, `reaction`, `revoke`, `revoked_at`, `ambiguous`, `chat_lid`, `sender_lid`, `sender_pn_resolved`, `quoted_author_lid`. - `ambiguous: true` marks a send that answered 504 `send_unconfirmed`. Its `id` starts with `ambiguous:` and is not a WhatsApp message ID. The message may or may not have been delivered. The row records the attempt, not the outcome. - `revoked_at` is set when a message was deleted for everyone. The log keeps the message's content. A paging loop: ```js let before; do { const url = new URL(`${API}/sessions//chats/${chat}/messages`); url.searchParams.set('limit', '200'); if (before) url.searchParams.set('before', before); const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } }); const page = await res.json(); handle(page.messages); before = page.next_before; } while (before); ``` ## One person, two addresses WhatsApp may file a person's messages under their phone number and under a LID. On a channel that holds the `lid_lookup` grant, both are read and merged into one timeline. `merge` says what happened: - `merged`: both addresses were read. - `none_known`: no other address is known for this person. - `not_granted`: your channel does not hold the grant, so only the address you named was read. - `not_a_person`: the chat is a group. Each message's `source_chat` says which address it is filed under. In a group, each sender is shown once. `sender_jid` is their phone number where known, and `sender_lid` is the LID they were stored under. ## Reads are recorded Each page you read is recorded in your API access records: which token read it, which chats, and how many messages. The content is never recorded there. ## History from the phone Storing history the phone sends is off by default. ```bash curl -X PUT "https://api-wagate.socialground.co/v1/sessions//history-sync" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"store_history_sync": true}' # {"store_history_sync":true} curl "https://api-wagate.socialground.co/v1/sessions//history-sync" -H "Authorization: Bearer " # {"store_history_sync":true} ``` - The body must be exactly that one field, true or false. Any other body is refused with 400 `field_not_allowed`. - With it on, history your phone sends is kept with this channel's messages, under the same retention, and returned by the conversation read with `source: history_sync`. Turning it off stops storing. What was stored stays until retention removes it. The gateway does not read or use this content. It only stores it for your channel. - A phone sends history when you link it and when you ask. Turn the setting on before linking to receive up to 200 recent messages per chat, from within the message retention period. Ask for older messages of one chat: ```bash curl -X POST "https://api-wagate.socialground.co/v1/sessions//chats/66XXXXXXXXX@s.whatsapp.net/history-requests" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"count": 50}' ``` ```json { "status": "requested", "chat_jid": "66XXXXXXXXX@s.whatsapp.net", "before_message_id": "3EB0C767D26A1D8E2A41", "before": "2026-09-01T03:00:00Z", "count": 50 } ``` - The body is optional. `count` is 1 to 50 and defaults to 50. The request asks for messages older than the oldest one this channel holds for the chat. - The answer is 202. The phone replies later, in its own time, and the messages then appear in the conversation read with `source: history_sync`. Whether the phone answers, and how much, is up to the phone. - If `store_history_sync` is off, it answers 409 `history_sync_off`. If the channel holds no message of that chat, it answers 404 `no_history_anchor`. - Limits: one request per channel every 30 seconds, and one per chat every 5 minutes. Over that, 429 `history_request_too_soon` with a `Retry-After` header and the same number as `retry_after` in the body. Wait that long. - Each request is recorded in your API access records. ## Errors on these routes 403 `blocked`, 404 `not_found`, 400 `invalid_request` or `field_not_allowed`, 429 `history_request_too_soon`. See Errors. # Your channel and its health Source: https://wagate.dev/docs/numbers-and-safety Read and rename your channel, read its health, and pace sending. - GET sessions/: Read this channel's state. - PATCH sessions/: Change the label or the registration date. - GET sessions//safety: Read the readings, by key, and the overall verdict. ## Your number is a real WhatsApp account WAgate links your number the way WhatsApp Web does, so WhatsApp judges it like any other account. A number that sends like a robot can get restricted. ## Warming Warming builds a normal history on a new number before you use it for volume. It is optional, and it does not cap what you send yourself. The sandbox channel can warm too; its warming sends count toward its allowances. ## Read the channel ```bash curl "https://api-wagate.socialground.co/v1/sessions/" -H "Authorization: Bearer " ``` ```json { "name": "", "phone": "66XXXXXXXXX", "label": "Support line", "state": "CONNECTED", "plan": "paid", "daily_send_cap": null, "daily_send_cap_source": "none", "warmup_protection": false, "lid_lookup": false, "token_set": true, "jid": "66XXXXXXXXX@s.whatsapp.net", "push_name": "Support", "qr_pending": false, "connected": true, "logged_in": true, "created_at": "2026-09-17T03:00:00Z", "updated_at": "2026-09-17T03:05:00Z", "last_message_at": "2026-09-17T03:05:00Z", "last_event_at": "2026-09-17T03:05:00Z", "connected_observed_at": "2026-09-17T03:05:10Z" } ``` `name`, `phone`, `label`, `daily_send_cap`, `warmup_protection`, `lid_lookup`, `state`, `token_set`, `qr_pending`, `created_at`, `updated_at`, `connected` and `logged_in` are always present, including empty `phone` or `label`, false booleans and a null `daily_send_cap`. The API also supplies `plan` and `daily_send_cap_source`. Optional fields such as `jid`, `push_name`, `last_error` and the activity timestamps are omitted when they have no value. - `state` is `NEW`, `PAIRING`, `CONNECTED`, `DISCONNECTED` or `BLOCKED`. - `plan` is `paid`, `sandbox`, `comp` or `internal`. `sandbox` is the account's free channel, which has daily allowances (see Limits). `paid` has no gateway allowances. `comp` is a channel covered by the account's channel grant rather than a subscription. `internal` is a channel owned by the gateway operator's own account. - `daily_send_cap` is a number, or `null` when no cap applies. A positive number is a daily ceiling that resets at Bangkok midnight. `0` stops sends until the cap is changed; midnight does not lift a zero cap. An operator override can set either a positive ceiling or zero. `daily_send_cap_source` is `operator`, `config`, `default` or `none`. - `warmup_protection` is true when the optional age-based daily allowances are on for this channel. It is off by default. - `lid_lookup` says whether LID lookup is enabled for this channel. See Contacts. - `connected_observed_at` is when `connected` was observed. `connected_stale: true` means this read could not observe the connection, so `connected` is the last known value. The field is absent when the read is fresh. - `last_error` appears when the last connection attempt or state change failed. It is one of a fixed set of sentences and never quotes the underlying error. - `registration_date` appears when you have set one. ## Rename the channel ```bash curl -X PATCH "https://api-wagate.socialground.co/v1/sessions/" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"label": "Support line", "registration_date": "2024-03-01"}' ``` The answer is the channel object. - `label`: up to 40 characters. An empty string resets it. - `registration_date`: `YYYY-MM-DD`, not in the future. An empty string resets it. It tells the health reading how old the number really is. - Leave a field out to keep it as it is. - Any other key is refused with 400 `field_not_allowed`. ## Health ```bash curl "https://api-wagate.socialground.co/v1/sessions//safety" -H "Authorization: Bearer " ``` ```json { "status": "current", "session": "", "label": "Support line", "measured_at": "2026-09-17T03:05:00Z", "overall": { "key": "overall", "label": "Overall", "level": "good", "reason": "every indicator is good", "measured_at": "2026-09-17T03:05:00Z", "value": "good", "basis": "", "evidence_at": null, "live": false }, "indicators": [ { "key": "stood_down", "label": "Allowed to send", "level": "good", "value": "Yes", "reason": "Nothing is stopping this number from sending.", "basis": "wagate's own dial policy", "measured_at": "2026-09-17T03:05:00Z", "evidence_at": "2026-09-17T03:05:00Z", "live": true, "data": { "stood_down": false, "blocked": false, "quarantined": false, "refused": false, "refusal_kind": "", "cap_source": "none" } }, { "key": "lifetime", "label": "Number age", "level": "good", "value": "", "reason": "Old enough to carry normal volume.", "basis": "paired", "measured_at": "2026-09-17T03:05:00Z", "evidence_at": "2026-03-01T03:05:00Z", "live": false, "data": { "days": 200, "source": "paired", "is_lower_bound": false, "linked_at": "2026-03-01T03:05:00Z" } }, { "key": "address_book_coverage", "label": "Saved contacts", "level": "good", "value": "1 of 1 people saved", "reason": "Most of the people you message are saved in this number's contacts.", "basis": "this number's contact store, against the peers it has messaged", "measured_at": "2026-09-17T03:05:00Z", "evidence_at": "2026-09-17T03:05:00Z", "live": true, "data": { "saved": 1, "peers": 1, "unresolved_lid": 0, "faults": 0, "ratio": 1 } }, { "key": "response_rate", "label": "Replies", "level": "too_early", "value": "0 of 0 people replied in the last 7 days (0 messages sent)", "reason": "Not enough messages yet to judge. This needs messages to at least 5 people.", "basis": "wagate's message log, both directions", "measured_at": "2026-09-17T03:05:00Z", "evidence_at": "2026-09-17T03:05:00Z", "live": false, "data": { "peers_answered": 0, "peers_messaged": 0, "sends": 0, "window_days": 7, "min_people": 5, "min_messages": 5 } } ] } ``` API shape: report `{status, measured_at, overall, indicators[]}`; indicator `{key, label, level, value, reason, basis, measured_at, evidence_at, live, data}`. The readings, by key: | `key` | Label | | --- | --- | | `stood_down` | Allowed to send | | `lifetime` | Number age | | `address_book_coverage` | Saved contacts | | `response_rate` | Replies | | `proxy` | Proxy (operator and owning account only) | Channel-token view returns 4 rows (no proxy); operator and owning-account view returns 5. Levels are `good`, `watch`, `bad` and `too_early`. `overall.level` is always exactly `good`, `watch` or `bad`, never `none`. It is `bad` if any reading is bad, else `watch` if any reading is `watch` or could not be measured or the report is `waiting` or `stale`, else `good`. It is the worst of the readings, never an average, and it carries the losing reading's reason. `too_early` is neutral: `good` plus `too_early` reads `good` when at least one reading is measured `good`; all `too_early` reads `watch` with "Not enough data yet"; a reading that could not be measured reads `watch`. A reading that could not be measured is `watch` with `data.unreadable: true` and value "Could not read", not `good`. An unrecognized level is treated as `watch`. `status` is `current` when the channel is connected and the report is fresh. It is `waiting` when the number is not linked yet, with no retained measurements and `overall.level: "watch"` (reason "Not linked yet"). It is `stale` when a paired channel is disconnected or its connection cannot be observed fresh. A stale report keeps the last known measurements when available, and `overall.level` is `watch` (reason "Not connected right now. Showing the last known readings."), or `bad` when a measured reading, `stood_down` included, is bad. Both waiting and stale reports include a current `stood_down` indicator. Check `status` before treating the overall verdict as a current measurement. Each row shows its source time. The footer shows "Refreshed HH:MM" from the report time. Each row has a collapsed "Show evidence" expander with raw counts, window and source time. Proxy = real test time; age = link date; live state = "Right now". Replayed rows in a stale/waiting report show "As of (not connected)". Allowed to send is recomputed on every read and still shows "Right now". Number age shows one count: calendar days elapsed from `linked_at` in the viewer zone, "Linked 1 Oct, 19 days ago"; lower-bound sources render "At least N days". The level uses server `days`. The lifetime reading is good or watch, never bad: a young number is something to watch, not something wrong. Only a reading about the number's state right now can be bad: stood down, or a failing proxy. Age, contacts, replies and a missing or shared proxy are things to watch. For `lifetime`, a computed age has an empty `value`. Use `data.source`, `data.linked_at`, `data.days` and `data.is_lower_bound` to render it. A `registration_date` source is a calendar date encoded as UTC midnight; preserve that date rather than converting it to a local instant. Pairing and message timestamps are instants and use the viewer's zone. Count calendar days to `measured_at` in the viewer's zone; lower-bound ages use `data.days`. For `stood_down`, `data.refused` is the policy-only refusal used by the warmer, independent of the Health `level`. Cap refusals and recent restriction history can change the Health level without changing this field. The `stood_down`, `blocked`, `quarantined` and optional `since` fields keep their previous meanings. The warmer still treats an allowed verdict beside one of those conditions as unreadable. ## Changelog Sending pace is no longer a health reading. A refusal from the daily cap now shows under Allowed to send. Readings are matched by key; new level too_early means not enough data yet and does not affect the overall result. ## Pacing Paid channels have no daily send cap by default; an operator override can impose a positive ceiling or a zero stop. Pacing is up to you. Space sends out with some randomness rather than firing bursts. ## Warm-up protection Optional warm-up protection is a switch in the console. With it on, group adds, group and community creation, contact checks and contact saves have their own daily allowances by number age, and a call over the allowance gets 429. With it off, a paid channel has none of these allowances. The `warmup_protection` field on the channel shows the switch. See Limits for the 429 codes. ## When WhatsApp pushes back Health reports what the gateway can measure. It adds no restrictions. You can unlink on your phone and link again by code or QR, through the console or API. Automatic reconnect only resumes a linked device. Changed 2026-10-20: `overall.level` no longer returns `none`; `waiting` and `stale` reports read `watch` (or `bad` when a measured reading is bad). # Recipes Source: https://wagate.dev/docs/recipes Retry safely, handle sends of unknown outcome, mark reads, and pace volume. Short patterns for the situations that cause the most support questions. Each one points to the page that has the full reference. ## 1. Know which errors are safe to retry | Response | Safe to retry as is? | Why | | --- | --- | --- | | 429 `rate_limited`, `gateway_busy` | Yes, after `Retry-After` | Nothing was sent. The request was never queued. | | 429 `sandbox_allowance_exceeded` | After `resets_at` | The sandbox channel reached an allowance. Nothing was sent. | | 502 `media_upload_failed` | Yes | The upload to WhatsApp failed and nothing was sent. | | 503 `lane_reclaimed` with `ambiguous: false` | Yes, after `Retry-After` | Refused before it was sent. | | 503 `lane_reclaimed` with `ambiguous: true` | Only after checking | It was sent and the outcome is unknown. A group add may already have applied. | | 504 `send_unconfirmed` | No | The message may have been delivered. See recipe 3. | | 502 `receipt_partial` | No | Some read receipts already went out. See recipe 4. | | Other 500, 502, 503 | Idempotent reads: with backoff. Writes: inspect before retrying. | A write may have succeeded before its response failed. List or read the changed state first; never re-create a group without reading/listing first. | The API has no idempotency key. A 500, 502 or 503, or a lost response, can leave a write possibly applied. Inspect before retrying writes; if a send cannot be confirmed, handle it as possibly delivered. `POST messages/read` sends receipts and is a write. See Errors for every code and Limits for the allowances. ## 2. A sender that handles 429 and unknown outcomes ```js const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function sendText(to, text) { for (let attempt = 0; attempt < 5; attempt++) { const res = await fetch(`${API}/sessions/${CHANNEL}/messages/text`, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ to, text }), }); if (res.ok) return res.json(); const err = await res.json().catch(() => ({})); if (res.status === 429 && (err.code === 'rate_limited' || err.code === 'gateway_busy')) { const wait = Number(res.headers.get('retry-after') ?? err.retry_after ?? 2); await sleep((wait + Math.random()) * 1000); continue; } if (err.code === 'send_unconfirmed' || res.status >= 500) { return { possiblyDelivered: true, logId: err.id }; // never resend automatically; see recipe 3 } throw Object.assign(new Error(err.code ?? `http_${res.status}`), { err }); } throw new Error('gave up'); } ``` Only `rate_limited` and `gateway_busy` are retried here. Every other 429 is thrown to the caller on purpose: - `daily_send_cap_exceeded` can mean an exhausted positive daily ceiling: wait until `resets_at` at Bangkok midnight. `cap: 0` is a stop that waiting does not lift; contact support. If the error reports unreliable counting, inspect it before retrying. - `whatsapp_rate_limited` means WhatsApp throttled the number, and it sends no `Retry-After`. Back off for minutes, not seconds. - `sandbox_allowance_exceeded` carries `resets_at`, which can be hours away. ## 3. After `send_unconfirmed` Treat the message as possibly delivered. Nothing in the API can settle it either way. - The `id` in the error (it starts with `ambiguous:`) names a row WAgate wrote to the chat log to record the attempt. The row is marked `ambiguous: true` whether or not the message arrived. Finding it does not mean the message was delivered. - Not finding it does not mean the message was not sent. If WAgate could not write the row, the error has no `id` at all, and the message may still have gone out. - The `id` is not a WhatsApp message ID. No `message.status` event will name it. What to do: - Do not resend automatically. Mark the send as "possibly delivered" in your own records. - Where it matters, confirm with the recipient another way, such as their reply, before you send again. - If you must resend without confirmation, accept that the recipient may get the message twice. ## 4. Mark a group read without double-receipting If `POST messages/read` answers `receipt_partial`, the body has `receipted`, the number of messages already marked before the failure. It is a count, not a list of ids, so do not send the same request again. Resending can repeat receipts that already went out. If you need the rest marked, send a new request for the newest message you still need marked and accept that a receipt may be repeated. ## 5. Deduplicate webhooks Store the `X-Wagate-Delivery` header for every event you process. A retry of the same event reuses it. ```js if (await alreadyProcessed(deliveryId)) return res.sendStatus(200); await markProcessed(deliveryId); // before the work, or in the same transaction res.sendStatus(200); ``` See Webhooks for signing and retries. ## 6. Add people to a group safely A group add takes up to 50 people per request and answers one result per person. ```js async function addOne(group, jid) { const res = await post(`/groups/${group}/participants`, { action: 'add', participants: [jid] }); const r = res.results[0]; if (r.status === 'unknown') return readGroupAndCheck(group, jid); // may have applied return r; } ``` A person with status `error_409` is already in the group, which for an add is a success. Add a few people at a time, with random gaps. If you get `lane_reclaimed` with `ambiguous: true`, inspect membership before retrying: the add may already have applied. A participant already present means success. See Groups. ## 7. A sender with random gaps ```js async function paced(jobs, minMs = 2000, maxMs = 9000) { for (const job of jobs) { await job(); await sleep(minMs + Math.random() * (maxMs - minMs)); } } ``` The numbers are an example, not a limit. WAgate imposes no send timing floor on paid channels; request limits and any send cap still apply (see Limits). WhatsApp reacts to regular, robotic timing, so vary your gaps. See Numbers and safety. ## 8. Alert when a channel disconnects Subscribe a receiver to `session.status`, and list it in `events`. When `payload.state` is anything other than `CONNECTED`, alert yourself and stop sending. The possible states are `NEW`, `PAIRING`, `CONNECTED`, `DISCONNECTED` and `BLOCKED`. See Webhook events. # Warming Source: https://wagate.dev/docs/warming Switch warming on or off for a channel, and read its progress. Warming is free and included with every channel. Your channels chat with each other so they look like ordinary, active WhatsApp accounts. These routes switch it on or off for one channel, read its progress, and choose whether your webhook receivers also get its traffic. All three routes use the channel token. While warming is not available for a channel, each answers 404 `not_found`. - GET sessions//warming: Read this channel's warming state. - PUT sessions//warming: Switch warming on or off. - PUT sessions//warming/webhooks: Choose whether this channel's webhook receivers also get its warming traffic. ## Read the state ```bash curl "https://api-wagate.socialground.co/v1/sessions//warming" \ -H "Authorization: Bearer " ``` ```json { "enabled": true, "since": "2026-10-11T03:00:00Z", "state": "warming", "reason_code": "warming", "restart_after": null, "stage": 2, "stage_count": 5, "clean_days_at_stage": 1, "clean_days_needed": 3, "conversations_today": 4, "target_today": 9, "partners": 2, "voice": "Example persona", "last_activity_at": "2026-10-11T04:12:00Z", "next_window": { "from": "2026-10-11T07:00:00Z", "to": "2026-10-11T09:30:00Z" }, "stale": false, "webhook_include_warming": false } ``` Fields: | Field | Meaning | | --- | --- | | `enabled` | whether warming is switched on for this channel. | | `since` | when it was switched on, or null. | | `state` | one of `off`, `stopped`, `paused`, `waiting`, `warming`. | | `reason_code` | why it is in that state. See the list below. | | `restart_after` | after a safety stop, the earliest time you can switch it back on. Null otherwise. | | `stage`, `stage_count`, `clean_days_at_stage`, `clean_days_needed` | progress through the warming stages. `stage`, `clean_days_at_stage` and `clean_days_needed` can be null before progress is known. | | `conversations_today`, `target_today` | today's activity against today's target, or null. | | `partners` | how many of your channels this one warms with. | | `voice` | the display name of the persona in use, or null. | | `last_activity_at`, `next_window` | the last activity, and the next time window when activity is due. Either can be null. | | `stale` | true when the progress figures could not be read fresh, so they are the last known values. | | `webhook_include_warming` | whether your receivers get warming traffic. See the last section. | The `reason_code` values: | Code | Meaning | | --- | --- | | `off` | Warming is switched off. | | `warming` | Warming is running. | | `waiting_for_partner` | Waiting for another of your channels to be ready. | | `waiting_for_voice` | Waiting for a free persona. | | `waiting_for_capacity` | Waiting for capacity. | | `paused_by_you` | Warming is paused for the account. | | `paused_disconnected` | Paused because the channel is not connected. | | `paused_allowance` | Paused because the channel's daily allowance is used. | | `stopped_safety` | Stopped for safety. See the cooldown below. | | `pool_halted` | Warming is halted for all your channels. | | `stopped_operator` | Stopped by WAgate. | ## Switch warming on or off `PUT sessions//warming` takes `enabled` (required, boolean), `restart_ack` (boolean) and `acknowledge` (an object with `text_version`). Any other field answers 400 `field_not_allowed`. The answer is the channel object, and its `warming` field holds the same object `GET` returns. Switching off always works, including while your account is suspended. ```bash curl -X PUT "https://api-wagate.socialground.co/v1/sessions//warming" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"enabled": false}' ``` ## Before the first switch-on The account owner acknowledges the warming terms once before any channel on the account can warm. In the console that is a checkbox. On the API you send it with the first switch-on, as the `acknowledge` field. Sending it records the acknowledgment for the account. ```bash curl -X PUT "https://api-wagate.socialground.co/v1/sessions//warming" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"enabled": true, "acknowledge": {"text_version": "2026-10-13"}}' ``` `text_version` must be the current version of the warming terms, shown in the console where you switch warming on. It is checked only while the account has not yet acknowledged the current version. Once it has, you can send `{"enabled": true}` alone, and a `text_version` you send is ignored. If the terms change, the version changes, and the account is asked again at its next switch-on. ## What the account owner agrees to The account acknowledges the following text once before any of its channels can warm. In the console it is a checkbox, with the heading "Turn on warming". The last item is the checkbox label. - Warming sends short, pre-written chat messages between your own channels, automatically, from your numbers. You do not write these messages. WAgate does, and sends them on your behalf. They are ordinary WhatsApp messages from your numbers: you will see the chats on your phones, and they count toward your sandbox allowances. - WhatsApp decides what it allows. Warming is meant to lower the risk of a restriction, but WhatsApp can still restrict or ban any number, including one that is warming, for reasons we cannot see and cannot reverse. A restriction or ban alone does not entitle you to a refund or compensation. Refund rights required by law and the liability exceptions in Terms section 13 still apply. - You can turn warming off for any number at any time. It stops before the next message. - I understand that WAgate will send pre-written messages from my numbers on my behalf, and that WhatsApp may still restrict or ban a number that is warming. I own these numbers or am authorized to operate them, and I accept that risk. ## After a safety stop If WAgate stops a number for safety, `state` is `stopped` and `reason_code` is `stopped_safety`. The number cools down first. Until `restart_after`, a switch-on answers 409 `warming_cooldown`. After that, switching it back on needs `restart_ack`: ```bash curl -X PUT "https://api-wagate.socialground.co/v1/sessions//warming" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"enabled": true, "restart_ack": true}' ``` If `restart_after` is null, no end time was recorded and only WAgate can clear the stop. Contact support at hello@wagate.dev. ## Refusals | Status | Code | Meaning | | --- | --- | --- | | 409 | `warming_ack_required` | The account has not acknowledged the current terms. Send `acknowledge`. | | 400 | `warming_ack_version_unknown` | The `text_version` is not the current one. | | 409 | `warming_cooldown` | Stopped for safety. `restart_after` says when it can be switched on again, or is null. | | 409 | `warming_restart_ack_required` | The cooldown is over. Send `restart_ack: true`. | | 503 | `warming_state_unreadable` | The warming record could not be read, so the switch-on was refused. Retry later. | | 403 | `warming_peer_refused` | Warming refused an operation between channels. It should not reach you. Contact support. | | 403 | `account_suspended` | The account is on hold. You can still switch warming off. | | 400 | `field_not_allowed` | The body has a field this route does not accept. | | 404 | `not_found` | Warming is not available for this channel. | When several apply, they are checked in this order: suspended account, cooldown, acknowledgment, restart acknowledgment. See Errors for the full code list. ## Warming traffic on your webhooks By default your webhook receivers get nothing from warming. To receive it, switch it on for the channel: ```bash curl -X PUT "https://api-wagate.socialground.co/v1/sessions//warming/webhooks" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"include_warming": true}' ``` The body is exactly `{"include_warming": true}` or `{"include_warming": false}`. Any other body answers 400. The answer is the warming object shown above. With it on, your receivers also get the `message` and `message.status` events that warming traffic produces, and each of those events carries `"warming": true` at the top level, next to `type`. Filter on that field if you do not want to process them. See Webhook events for the envelope. Support: hello@wagate.dev