WhatsApp events on your server

Register a URL and receive signed events from the WhatsApp chats you choose to save.

Add your endpoint

Open Developers, add a public HTTPS URL, choose events and copy the signing secret. The dashboard queues a test event and displays its delivery result. Your receiver should verify the signature, durably accept the event, and return 2xx quickly. Do longer work in your own queue. API keys with whatsapp.webhooks:manage can also register and manage endpoints.

curl -X POST https://whatsync.app/api/v1/webhooks \
  -H "Authorization: Bearer $WHATSYNC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://your-server.example/whatsync","event_types":["message.received"]}'

Only saved chats produce message events

The same number filters and selected groups that control saving also control webhook delivery. Tick a group in What's saved before expecting its events. Your own replies produce message.sent, never message.received. QR delivery ticks are not available in version one; message.status is Business only. Short QR reconnects do not produce disconnection alerts; states requiring human action do.

Events and payloads

message.received
{
  "id": "evt_01J9XK2Q8V7Y3M4N5P6R7S8T9U",
  "type": "message.received",
  "created_at": "2026-10-08T09:41:12.512Z",
  "api_version": "2026-10-01",
  "account_id": "conn_example",
  "data": {
    "message": {
      "id": "example-message",
      "stored_message_id": "conn_example_example-message",
      "conversation_id": "120363012345678901@g.us",
      "channel": "qr",
      "chat_type": "group",
      "group": {
        "id": "120363012345678901@g.us",
        "name": "Site requests"
      },
      "from": {
        "phone": "15550000001",
        "name": "Ravi"
      },
      "from_me": false,
      "type": "text",
      "text": "Need 20 bags of cement at block C",
      "quoted_message_id": null,
      "has_attachment": false,
      "attachment": null,
      "timestamp": "2026-10-08T09:41:11.000Z"
    }
  }
}
message.sent
{
  "id": "evt_01J9XK2Q8V7Y3M4N5P6R7S8T9U",
  "type": "message.sent",
  "created_at": "2026-10-08T09:41:12.512Z",
  "api_version": "2026-10-01",
  "account_id": "conn_example",
  "data": {
    "message": {
      "id": "example-message",
      "stored_message_id": "conn_example_example-message",
      "conversation_id": "120363012345678901@g.us",
      "channel": "qr",
      "chat_type": "group",
      "group": {
        "id": "120363012345678901@g.us",
        "name": "Site requests"
      },
      "from": {
        "phone": "15550000001",
        "name": "Ravi"
      },
      "from_me": true,
      "type": "text",
      "text": "Need 20 bags of cement at block C",
      "quoted_message_id": null,
      "has_attachment": false,
      "attachment": null,
      "timestamp": "2026-10-08T09:41:11.000Z"
    }
  }
}
message.status
{
  "id": "evt_01J9XK2Q8V7Y3M4N5P6R7S8T9U",
  "type": "message.status",
  "created_at": "2026-10-08T09:41:12.512Z",
  "api_version": "2026-10-01",
  "account_id": "conn_example",
  "data": {
    "message_id": "wamid.example",
    "conversation_id": "15550000001",
    "channel": "business",
    "status": "delivered",
    "errors": [],
    "timestamp": "2026-10-08T09:41:12.000Z"
  }
}
connection.connected
{
  "id": "evt_01J9XK2Q8V7Y3M4N5P6R7S8T9U",
  "type": "connection.connected",
  "created_at": "2026-10-08T09:41:12.512Z",
  "api_version": "2026-10-01",
  "account_id": "conn_example",
  "data": {
    "channel": "qr",
    "phone": "15550000001"
  }
}
connection.disconnected
{
  "id": "evt_01J9XK2Q8V7Y3M4N5P6R7S8T9U",
  "type": "connection.disconnected",
  "created_at": "2026-10-08T09:41:12.512Z",
  "api_version": "2026-10-01",
  "account_id": "conn_example",
  "data": {
    "channel": "qr",
    "phone": "15550000001",
    "reason": "logged_out",
    "action_needed": "scan_qr",
    "dashboard_url": "https://whatsync.app/dashboard"
  }
}
webhook.test
{
  "id": "evt_01J9XK2Q8V7Y3M4N5P6R7S8T9U",
  "type": "webhook.test",
  "created_at": "2026-10-08T09:41:12.512Z",
  "api_version": "2026-10-01",
  "account_id": "conn_example",
  "data": {
    "message": "This is a WhatSync webhook test."
  }
}

conversation_id can be passed to the matching send or read tool. Attachments contain metadata; call get_attachment_link with stored_message_id once the file has been saved to obtain a current download link. Business file downloads are not available through this tool. Payloads are capped at 256 KB; oversized text includes text_truncated=true. All event times are UTC. Each endpoint stays on its payload API version; ignore unknown fields.

Verify the original request body

Signing follows Standard Webhooks. Verify webhook-id, webhook-timestamp and webhook-signature using your endpoint secret. Reject timestamps more than five minutes from your clock. Do this before parsing JSON. Rotating a secret sends both signatures for 24 hours.

Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(body, headers, secret) {
  const id = headers['webhook-id'];
  const ts = headers['webhook-timestamp'];
  if (!id || !/^\d+$/.test(ts || '') || Math.abs(Date.now()/1000 - Number(ts)) > 300) throw new Error('Stale webhook');
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(id + '.' + ts + '.').update(body).digest();
  const valid = (headers['webhook-signature'] || '').split(' ').some(value => {
    const [version, encoded] = value.split(',');
    const actual = Buffer.from(encoded || '', 'base64');
    return version === 'v1' && actual.length === expected.length && timingSafeEqual(actual, expected);
  });
  if (!valid) throw new Error('Invalid signature');
  return JSON.parse(body.toString('utf8'));
}
// body must be the original raw bytes, before JSON parsing.
Python
import base64, hashlib, hmac, json, time
def verify(body, headers, secret):
    event_id = headers.get('webhook-id', '')
    ts = headers.get('webhook-timestamp', '')
    if not event_id or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        raise ValueError('Stale webhook')
    key = base64.b64decode(secret.removeprefix('whsec_'))
    expected = base64.b64encode(hmac.new(key, (event_id + '.' + ts + '.').encode() + body, hashlib.sha256).digest()).decode()
    valid = any(s.startswith('v1,') and hmac.compare_digest(s[3:], expected) for s in headers.get('webhook-signature', '').split(' '))
    if not valid:
        raise ValueError('Invalid signature')
    return json.loads(body)
# body is bytes from the request; normalize header names to lowercase.
PHP
function verify_webhook($body, $headers, $secret) {
    $id = $headers['webhook-id'] ?? '';
    $ts = $headers['webhook-timestamp'] ?? '';
    if (!$id || !ctype_digit($ts) || abs(time() - (int)$ts) > 300) throw new Exception('Stale webhook');
    $key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
    if ($key === false) throw new Exception('Invalid secret');
    $expected = base64_encode(hash_hmac('sha256', $id.'.'.$ts.'.'.$body, $key, true));
    $valid = false;
    foreach (explode(' ', $headers['webhook-signature'] ?? '') as $signature) {
        if (str_starts_with($signature, 'v1,') && hash_equals($expected, substr($signature, 3))) $valid = true;
    }
    if (!$valid) throw new Exception('Invalid signature');
    return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
}
// $body = file_get_contents('php://input'); lowercase the header names.

Delivery, retries and retention

Queued events are delivered at least once; duplicates are possible and order is not guaranteed. Deduplicate the stable event ID and use event/message timestamps to interpret ordering. Failures retry after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours and 12 hours, with ±20% jitter. After nine attempts the delivery fails. HTTP 410 disables an endpoint immediately. An endpoint with 100 consecutive failures and no success for three days is disabled. No redirects are followed and requests time out after ten seconds.

Payloads and response excerpts are retained for seven days; delivery metadata lasts thirty days. Resend is available while the payload exists. Paused or deleted endpoints stop future requests; a request already in flight may finish. Saving a message and queueing its event are separate operations: a failure between them can miss an event, and a later duplicate message can repair that gap. This service does not promise every WhatsApp event will be captured.