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

  1. 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.
  2. Read the raw body. Capture the request body as bytes. Do not parse and re-serialize it before hashing.
  3. 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.
  4. Deduplicate by event id. Store the id from each verified body you handle and skip repeats.

What a delivery looks like

HTTP
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 characters

The 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
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

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

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

Node.js (Express)
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

Shell
# Prints the signature you should have received. Compare it to the header.
printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex

Send yourself a signed test event

curl
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

Common mistakes

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 sandbox

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.