Webhooks

Receive events, verify them, and handle retries and duplicates.

Event payloads are on the next page, Webhook events.

  • GET/v1/sessions/<CHANNEL_ID>/webhooks

    List this channel's webhook receivers.

  • PUT/v1/sessions/<CHANNEL_ID>/webhooks

    Replace this channel's webhook receivers.

  • POST/v1/sessions/<CHANNEL_ID>/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/<CHANNEL_ID>/webhooks" \
  -H "Authorization: Bearer <CHANNEL_TOKEN>" \
  -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/<CHANNEL_ID>/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_<EVENT_ID>
X-Wagate-Signature: 64 lowercase hex characters
json
{
  "id": "evt_<EVENT_ID>",
  "type": "message",
  "session": "<CHANNEL_ID>",
  "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/<CHANNEL_ID>/webhooks/test" \
  -H "Authorization: Bearer <CHANNEL_TOKEN>" \
  -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 = "<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.

Webhooks · WAgate docs