Register a webhook
Pass it in POST /start when pairing, or change it any time — no restart, persisted:
curl -X POST "https://api.meowsapp.com/set-webhook" \
-H "Authorization: Bearer $SN_TOKEN" -H "Content-Type: application/json" \
-d '{
"clientId": "acmemain",
"webhookUrl": "https://example.com/whatsapp/incoming",
"webhookToken": "9f3b…a-long-random-secret",
"pollLookupUrl": "https://example.com/whatsapp/poll-lookup"
}'webhookUrl— absolutehttps://URL. Without one, inbound messages are dropped (logged, not stored). Set""to pause delivery.webhookToken— sent back to you on every call. Choose something long and random.pollLookupUrl— optional; lets us resolve poll votes to option names (below).
The receiver contract
We send one event per HTTP call:
POST /whatsapp/incoming HTTP/1.1
Content-Type: application/json
Authorization: Bearer <webhookToken>
User-Agent: Meowsapp-WhatsApp/1
X-SN-Event: text| You return | We do |
|---|---|
| 2xx | Delivered. Return this once you have durably stored (or deliberately dropped) the event. |
| 4xx (except 429) | Permanently rejected — no retry. Use for unauthenticated or malformed calls. |
| 429 / 5xx / timeout | Retried with exponential backoff — 4 attempts (~0.5 s, 1 s, 2 s apart) — then dropped. |
Ack in under 15 seconds; expect duplicates; do not assume order
Respond immediately and process asynchronously. Retries mean the same event can arrive twice — de-duplicate on(clientId, chatRoomId, messageId). Events are delivered concurrently; order by timestamp if you need it. There is no durable queue: if your receiver is down beyond the retry window, those events are lost (use thehosted inbox as a safety net if that matters).
The event envelope
Every event shares these fields. At most one of the per-type objects at the bottom is present, matching type:
{
"event": "message.received",
"type": "text",
"clientId": "acmemain",
"orgId": "acme",
"timestamp": "2026-08-28T05:22:55Z",
"chatRoomId": "acmemain-60111111111111-123456789012345",
"myLid": "60111111111111",
"clientLid": "123456789012345",
"remoteJid": "123456789012345@lid",
"remoteName": "Jane Lim",
"remotePhone": "60123456789",
"remoteUsername": "janelim",
"avatarUrl": "https://files.meowsapp.com/…/avatars/123456789012345/1965117713.jpg",
"pushName": "Jane",
"fromMe": false,
"isGroup": false,
"messageId": "3EB0A1B2C3D4E5F6",
"body": "Hello there",
"quoted": { "messageId": "3EB0…", "participant": "6011…", "body": "Earlier text", "type": "text" },
"mentions": ["60111111111111"],
"isForwarded": false,
"meta": { "fromAI": true, "userId": "u_42", "sender": "Ops bot", "raw": { "campaign": "aug" } },
"media": { "…": "type = image | video | audio | document | sticker" },
"poll": { "…": "type = poll_creation" },
"pollVote": { "…": "type = vote_update" },
"interactive": { "…": "type = interactive_reply" },
"contact": { "…": "type = contact" },
"location": { "…": "type = location" },
"reaction": { "…": "type = reaction" },
"edit": { "…": "type = edit" },
"revoke": { "…": "type = revoke" },
"group": { "…": "only when isGroup = true" }
}| Field | Meaning |
|---|---|
type | Event type — see the catalogue below. Also sent as the X-SN-Event header. |
clientId / orgId | Which of your sessions received it, and its workspace id ("" if none). |
chatRoomId, myLid, clientLid, remoteJid | Conversation key and the two parties. Reply with to: clientLid. |
remoteName, remotePhone, remoteUsername, pushName, avatarUrl | Contact display name, phone (when WhatsApp reveals it), @username handle, the sender's self-set name, and a permanent profile-picture link. Any may be empty. |
fromMe | true for messages sent by this session (from the phone or via the API) — see below. |
isGroup, group | Group messages only (opt-in per session). |
messageId, body, timestamp | WhatsApp id, text / caption / rendered text, send time (RFC 3339, UTC). |
quoted, mentions, isForwarded | Reply target {messageId, participant?, body, type}, mentioned user-parts, forwarded flag. |
meta | The metaData you attached when sending, echoed back on the fromMe event as {fromAI, userId, userName, sender, name, raw}. |
Event types
The full catalogue with payload samples is on the Webhook events reference page. In short:
type | Extra object | What happened |
|---|---|---|
text | — | Text message; body is the text. |
image video audio document sticker | media | A file; descriptors to download it, or a stored link with the hosted inbox. |
poll_creation | poll | Someone sent a poll. |
vote_update | pollVote | Someone voted. |
interactive_reply | interactive | A button you sent was tapped. |
contact | contact | A contact card was shared. |
location | location | A pinned or live location. |
reaction | reaction | Emoji added to / removed from an earlier message. |
edit | edit | An earlier message was edited. |
revoke | revoke | An earlier message was deleted for everyone. |
Media events
The bytes are not in the webhook. Every media event carries media.download — the WhatsApp CDN path, media key and hashes needed to fetch and decrypt the file (the standard WhatsApp Web media descriptors). Treat mediaKey as a secret. If you would rather not implement decryption, use thehosted inbox: it downloads every file, stores it on a permanent public link and exposes downloadUrl. See the Media guide.
Poll votes & the lookup endpoint
WhatsApp sends votes as hashes of the chosen option names, not the names. When a vote_update arrives we decrypt it and try to resolve the names: from hosted history if the poll was stored there, otherwise by calling your pollLookupUrl:
POST <pollLookupUrl>
Authorization: Bearer <webhookToken>
Content-Type: application/json
{ "clientId": "acmemain", "chatRoomId": "acmemain-6011…-1234…", "pollMessageId": "3EB0…" }{ "options": ["Pizza", "Sushi", "Salad"] }Return the exact original option strings; order does not matter. If the lookup fails or you did not configure one, the vote is still delivered with resolved: false and selectedHashes (hex SHA-256 of each chosen option) so you can match it yourself.
Reactions, edits, deletes
reaction, edit and revoke are updates to an earlier message, identified by targetId. Do not insert them as new messages: update the reaction set, replace the body, or mark the row deleted. The messageId on these events is the id of the update itself.
Group messages
Off by default. Enable with POST /set-enable-group-ai {"clientId", "enabled": true}. Group events have isGroup: true,remoteJid is the group (…@g.us), chatRoomId is <clientId>-<myLid>-<groupId>, andgroup = {jid, subject, participant, participantPhone?, participantUsername?} identifies the sender. Broadcast lists, status updates and channels/newsletters are never delivered.
Your own sends (fromMe)
Messages sent from the linked phone and messages sent through the API are echoed to your webhook with fromMe: true, so a single feed reconstructs the whole conversation. API sends carry your metaData back as meta — attach{"fromAI": true, "userId": "…"} when sending and you can attribute every outbound message without a second lookup.
Security checklist
- Verify
Authorization: Bearer <webhookToken>on every call to both endpoints; reject anything else with401. - HTTPS only. Respond fast (webhook ≤ 15 s, poll lookup ≤ 8 s); do the real work on a queue.
- De-duplicate on
(clientId, chatRoomId, messageId). - Never log
media.download.mediaKey. - Keep the receiver highly available — there is no replay of dropped events.