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
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"
}
]
}'{
"receivers": [
{
"url": "https://example.com/wagate",
"events": ["message", "message.status"],
"media_policy": "reference",
"secret_set": true
}
]
}Rules:
- A receiver takes
url,eventsandsecret. Any other key answers 400field_not_allowed. urlmust be an absolute http or https address. A URL listed twice is refused.- At most 8 receivers per channel.
secretsigns every delivery to that receiver. A new receiver needs one of at least 24 characters, otherwise the call answers 400invalid_body. On a receiver you already saved, leavesecretout 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 showssecret_set: false, add a secret withPUT webhooks. Until then you have no key to verify its deliveries with.eventslists the event types the receiver wants. List every type you want. If you leaveeventsout, the receiver gets the default set:message,group.participantsandsession.status. That set does not includemessage.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.PUTreplaces the whole list.{"receivers": []}removes every receiver. A call with noreceiverskey answers 400invalid_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
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{
"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:
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:
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:
printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hexCommon 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-Deliveryvalue. 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-Deliveryand make your handler safe to run twice.
Test your endpoint
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"}'{
"status": "delivered",
"detail": "the receiver accepted a signed test event; nothing was sent to WhatsApp"
}- The
urlmust be one you already set. Any other answers 404not_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, headerX-Wagate-Event: webhook.test, anX-Wagate-Deliveryandidoftest-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 ontype. - 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
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.