Guides
Verify a WhatsApp webhook signature (HMAC-SHA256) in Node and Python
Every WaGate webhook carries X-Wagate-Signature, 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 parsing the JSON, compare in constant time, and answer 401 when it does not match.
Facts (as of October 2026, API v1)
- Header: X-Wagate-Signature. Value: 64 lowercase hex characters, no prefix.
- Algorithm: HMAC-SHA256 over the raw body bytes, keyed with the receiver secret.
- A receiver secret is at least 24 characters. It is set per receiver.
- A retry keeps the same event id in the signed body.
Steps
- Set a receiver with a secret. PUT the channel webhooks with your URL, the events you want and a secret of at least 24 characters. PUT replaces the whole receiver list, so include the receivers you keep.
- Read the raw body. Capture the request body as bytes. Do not parse and re-serialize it before hashing.
- Compute and compare. Compute the HMAC-SHA256 of the raw body with the secret, hex encode it, and compare it to X-Wagate-Signature in constant time.
- Deduplicate by event id. Store the id from each verified body you handle and skip repeats.
What a delivery looks like
POST /webhook HTTP/1.1
Content-Type: application/json
X-Wagate-Event: message.status
X-Wagate-Delivery: evt_XXXXXXXX
X-Wagate-Signature: 64 lowercase hex charactersThe body is JSON with id, type, session, timestamp and payload. X-Wagate-Delivery repeats the body id, but only the body is signed.
Set a receiver and a secret
PUT replaces the whole receiver list for the channel. Include every receiver you want to keep, or the ones you leave out are removed. Sending an empty receivers list removes all of them.
curl -X PUT "https://api.wagate.dev/v1/sessions/$CHANNEL_ID/webhooks" \
-H "Authorization: Bearer $CHANNEL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"receivers":[{"url":"https://example.com/webhook","events":["message","message.status"],"secret":"REPLACE_WITH_24_PLUS_CHARACTERS"}]}'Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifySignature(rawBody, signatureHeader, secret) {
// A JSON body parser that ran first leaves a parsed object, not bytes: refuse it rather than throw.
if (!Buffer.isBuffer(rawBody) || !secret || !signatureHeader) return false; // an empty key would accept a forgery signed with an empty key
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 hashlib
import hmac
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
# A JSON body parser that ran first leaves a parsed object, not bytes: refuse it rather than raise.
if not isinstance(raw_body, bytes) or not secret or not signature_header:
return False # an empty key would accept a forgery signed with an empty key
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)A receiver that verifies and ignores duplicates
import express from 'express';
import { verifySignature } from './verify.js';
import { enqueueDurably } from './queue.js'; // your durable queue or database write
const app = express();
const { WEBHOOK_SECRET: SECRET } = process.env;
if (!SECRET) throw new Error('Set WEBHOOK_SECRET before you start');
const seen = new Set(); // illustration only: production needs a durable dedupe store
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifySignature(req.body, req.get('x-wagate-signature'), SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// The delivery header is NOT signed. Dedupe on the id inside the signed body.
if (seen.has(event.id)) return res.sendStatus(200); // a retry of an event you already accepted
try {
await enqueueDurably(event); // persist first; process from the queue later
} catch {
return res.sendStatus(500); // not accepted: non-2xx so the sender retries
}
seen.add(event.id); // mark seen only after the event is safely stored
res.sendStatus(200);
});
app.listen(3000);Check one by hand
# Prints the signature you should have received. Compare it to the header.
printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hexSend yourself a signed test event
curl -X POST "https://api.wagate.dev/v1/sessions/$CHANNEL_ID/webhooks/test" \
-H "Authorization: Bearer $CHANNEL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/webhook"}'The test event is signed like a real one, has type webhook.test, and never reaches WhatsApp. The URL must be one you already set.
Replays and duplicates
- The signature covers the body only. A captured valid request still verifies if someone sends it again, so deduplicate on the id inside the verified body. X-Wagate-Delivery is not signed and can be forged.
- The body timestamp is the time of the event, and it stays fixed across every retry. WaGate retries a failed delivery for about two days at the default (50 attempts, at most one hour apart), so a freshness window shorter than that rejects legitimate retries. Dedupe on event.id, and do not reject a delivery for its age.
- A failed delivery is retried a limited number of times with a growing wait and keeps the same body id. Make your handler safe to run twice.
- Answer 2xx only after the event is durably stored, and keep that step quick. Do the slow work from your queue. The default timeout for an attempt is 10 seconds.
- The Set in the sample is for illustration. In production keep seen ids in a durable store, for example a unique key on event.id in your database. Make the check and the store one atomic step, or make the enqueue idempotent on event.id, so two copies arriving at once cannot both get through. Otherwise a restart forgets every id and a retry runs twice.
- If storing fails, answer non-2xx. The sender then retries the same event instead of treating it as delivered.
Common mistakes
- Parsing the JSON and hashing the re-serialized text. Hash the bytes you received.
- Using the wrong secret when you run several receivers. Each receiver has its own.
- Comparing with ==. Use a constant-time compare.
Questions
- Which header carries the signature?
- X-Wagate-Signature. It is 64 lowercase hex characters with no prefix such as sha256=.
- What exactly is signed?
- The raw request body bytes, hashed with HMAC-SHA256 and the secret you set on that receiver.
- How do I ignore duplicate deliveries?
- Store the event.id from the verified body of every event you accept. A retry keeps the same body id, so a repeat is a duplicate. The X-Wagate-Delivery header is not signed, so treat it as informational only and never dedupe on it. Answer 200 for duplicates so the retries stop.
- Can I test my endpoint without sending a WhatsApp message?
- Yes. POST /webhooks/test sends one signed webhook.test event to a receiver you already set. Nothing is sent to WhatsApp.
Free sandbox, forever. Add a real number when you're ready.
Start in the free sandboxKeep going
WaGate is an independent WhatsApp API for developers: send and receive messages, automate groups and communities, and get signed webhooks with a free sandbox. Not affiliated with WhatsApp or Meta. You link your own number.