ReceivingDeep dive

Verifying a WhatsApp webhook signature

How to check that a webhook really came from your provider, what the signature covers, and the three mistakes that leave an endpoint open to anyone.

wuapiReceiving7 min read
A signed webhook request with its Wuapi-Signature header, the HMAC-SHA256 check marked verified, and the retry schedule 30s, 2m, 10m, 1h, 6h.POST /webhooks/wuapiWuapi-Signature:t=1727445731,v1=5f2c9a1e7b04...Wuapi-Event-Type: message.receivedHMAC-SHA256(secret, t + "." + body)verifiedv1 == expectedRETRIES30s2m10m1h6h[RECEIVING]TIMESTAMP AND RAW BODY, COMPARED IN CONSTANT TIME

A webhook endpoint that accepts anything anyone posts is an open door. Someone can forge a message from your customer's number, make your bot reply to it, and you will find out from the customer. The fix is small: one header, one HMAC, one comparison. This post is the whole thing, in the order you need it.

#What a signed webhook gives you

Inbound events are the only way a WhatsApp integration learns anything, and they arrive on a URL you chose. That makes the endpoint the weakest point in the whole system unless it can tell a real event from a forged one.

A signature header gives you three things:

  • Authenticity. The body arrived from your provider and was not written by anyone else.
  • Integrity. The body is the body that was signed. Nobody edited a field on the way through.
  • Freshness. A timestamp inside the signature stops a captured request being replayed at you next week.

#Why not just check a secret in the URL

A shared secret in the path, https://example.com/hooks/wuapi/SECRET, is the first thing everyone reaches for, and it is weaker than it looks.

A secret in a URL leaks in three places that a header does not. It ends up in your web server's access logs, because the request line contains it. It ends up in any error page or stack trace that prints the path. And it ends up in the referrer header if your handler ever loads anything external while processing the request, which is a redirect to a documentation page away.

None of those are exotic. Access logs in particular keep every URL your server has ever served, and a rotated secret does not help if the old one is still on disk somewhere in a log.

GET /hooks/wuapi/whsec_a91c0e4f... HTTP/1.1     // ← now in the access log forever
GET /hooks/wuapi             HTTP/1.1             // ← the secret travels in a header instead

A signature header is not in the URL, is not written to disk by a default access log, and expires in usefulness: the timestamp window means a leaked header stops verifying after a few minutes. That is a materially better posture for the same twenty minutes of work.

The thing a URL secret does buy you is that it is easy to rotate and easy to test by hand. If you want both, keep a secret in the header and put an unguessable path segment on the URL as well. You get the operational convenience without the log.

#The header

wuapi sends Wuapi-Signature: t=1727136000,v1=5f2b... on every delivery. The t is the Unix timestamp the event was signed at, v1 is the hex HMAC.

The signed string is the timestamp, a full stop, and the raw request body. Nothing else: not the timestamp on its own, not a parsed field, not the body alone.

{  "type": "message.received",  "created": "2026-09-24T14:22:31.408Z",  "data": {    "object": "message",    "id": "w82t6y1u5i9o3p7a2s6d0f4g8h2j6k1l",    "accountId": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr",    "chatId": "+584241112233",    "chatType": "direct",    "from": "+584241112233",    "direction": "inbound",    "status": "delivered",    "text": "Is my order shipped?"  }}

You have to verify the signature against the bytes you received, before any JSON parsing. If your framework parses the body first, the exact bytes are gone and the HMAC will not match.

#Verifying it, from scratch

Here is the whole check, with nothing hidden. If you read only one part of this post, read this.

import { createHmac, timingSafeEqual } from "node:crypto"

// header: "t=1727136000,v1=5f2b..."
export function verifyWuapiSignature(rawBody: string, header: string, secret: string, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]))
  const t = Number(parts.t)
  if (!t || !parts.v1) return false

  // Freshness: refuse anything outside the tolerance window.
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
  const a = Buffer.from(expected, "hex")
  const b = Buffer.from(parts.v1, "hex")

  // Constant time, so a wrong signature does not leak how wrong it was.
  return a.length === b.length && timingSafeEqual(a, b)
}

Three lines do the real work. The timestamp check rejects replays, the HMAC covers t.rawBody, and timingSafeEqual keeps the comparison from leaking.

#Or use the SDK

The SDK ships the same check, plus a typed error so a bad signature is not confused with a bug in your handler.

import { verifyWebhook, WebhookVerificationError } from "@wuapidev/sdk"

export async function POST(request: Request) {
  const rawBody = await request.text()
  try {
    const event = await verifyWebhook(
      rawBody,
      request.headers.get("wuapi-signature"),
      process.env.WUAPI_WEBHOOK_SECRET!,
    )
    switch (event.type) {
      case "message.received":
        console.log(event.data.object.from, event.data.object.text)
        break
      case "account.disconnected":
        console.log(event.data.object.id, event.data.object.disconnectReason)
        break
    }
    return new Response(null, { status: 204 })
  } catch (err) {
    if (err instanceof WebhookVerificationError) return new Response("invalid signature", { status: 400 })
    throw err
  }
}

Answer 204 for anything you handled. A non-2xx is a failed delivery, and wuapi will retry it, which is how one duplicate turns into six.

#What to do when verification fails

A failed verification is not an error in your integration, it is somebody posting to your URL. Answer 400 and log it, and nothing else.

The three cases look the same in the log and want different responses, so it is worth separating them by the reason your check failed.

  • No header at all. Probably a health check, a browser hitting the endpoint, or a scanner. 400 is right and nothing more is needed.
  • A header with a timestamp too old. A replay, or a queue that sat for hours. Worth an alert, because a real replay attempt is not something a scanner does by accident.
  • A header with a fresh timestamp and a bad signature. Somebody with your endpoint URL and a wrong secret. This is the one to rate limit, because a valid-looking request with a wrong signature is a deliberate attempt.
function onBadSignature(reason: "missing" | "stale" | "mismatch") {
  if (reason !== "missing") logger.warn({ reason }, "webhook signature rejected")
  return new Response("invalid signature", { status: 400 })
}

The important property is that none of the three causes your handler to run. Reject before you look at the body, and a forged event never reaches your business logic.

#The three mistakes

Most open endpoints share one of these three problems.

  1. Verifying after parsing. await request.json() then re-serialising to verify will not match. The bytes you verify must be the bytes that arrived.
  2. Comparing with `===`. String comparison on a signature returns early on the first different character, which leaks information and is slower. Use timingSafeEqual.
  3. Skipping the timestamp. Verifying the HMAC alone means a signature captured today still validates next month. Check t against a window.
function wrong(raw: unknown) { /* never verify anything you parsed yourself */ }
function alsoWrong(a: string, b: string) { return a === b } // not constant time
function right(rawBody: string, header: string, secret: string) {
  // timestamp window + HMAC over `t.rawBody` + timingSafeEqual
}

#Retries, and the endpoint that was down

A delivery that does not get a 2xx is retried, six times, on a fixed schedule. The gaps are long on purpose: a restart takes less time than the first gap.

0s

First attempt, your server is down

no 2xx
30s

Second attempt

2m

Third attempt

10m

Fourth attempt

1h

Fifth attempt, server is back

6h

Last attempt, if it failed again

6 total

One endpoint, down for an hour

Six attempts means every handler must be idempotent, because a retry re-delivers the same event. Key your processing on the event id and you can safely ignore the second copy.

#What the whole exchange looks like

Phone
Engine
Your endpoint
A contact writes
Signs the eventt=…,v1=…
Reads the raw body
Checks HMAC and timestamp
Handles it, answers 204204
end

time runs down the page · 5 steps

A delivery, from the send to your handler

The order in the last three steps is the whole post. Read raw, verify, then handle. Handle first and you have already lost.

#Events worth handling first

There are 35 event types. Four cover most integrations, and a fifth saves you a support ticket.

eventwhen it fireswhat to do with it
message.receivedsomeone wrote to a number you have linkedthe main event; route and answer
message.faileda send ended failedread error.code; it tells you which limit you hit
account.disconnecteda session droppedcheck disconnectReason before anything else
account.qr_code_issueda fresh code is waitingre-render it, do not serve a cached one
message.delivered and message.readreceipts arriveupdate your own state, do not send again

The full catalog with a payload for each one is in the events reference, and the same list is in webhooks with the endpoint settings.

#Where the secret lives, and who can see it

A signing secret is worth protecting more than a normal API key, because it does not expire and it does not get revoked by noticing unusual traffic. It is the one credential that lets somebody forge a message from your customer's number for as long as they hold it.

Three rules, and they are the whole of secret handling for a webhook.

  1. It comes back once. The API returns the secret when the endpoint is created and when it is rotated, and not afterwards. Your code has to capture it at that moment, which means the creation call and the deployment are the same event.
  2. It belongs in an environment variable. Not in the repository, not in the dashboard's URL, not in a config file that gets committed by accident. A leaked signing secret does not show up as an error; it shows up as a customer saying your bot said something it never said.
  3. The organization's owner can reveal it again. From an AES-256-GCM encrypted copy, which is a deliberate trade: fewer copies to leak, and one person who can always get it back. Nobody else can.

When you rotate, both happen at once: the old secret stops verifying immediately and the new one is delivered once. Any handler holding the old value starts failing with a signature mismatch, which is a loud failure rather than a silent one, provided you alert on it.

#The delivery lifecycle, end to end

Everything in this post, in the order it happens, so the shape of the thing is one diagram rather than five paragraphs.

Contact
Engine
Your endpoint
Queue
Writes to your number
Builds the eventmessage.received
Signs the raw bodyt=…,v1=…
Reads bytes, checks HMAC and clock
Handles it once, answers 204keyed on event id
No 2xx, so schedules a retry30s · 2m · 10m · 1h · 6h
Your retry finds the id handled
end

time runs down the page · 7 steps

One event, signed, delivered, retried and verified

The last two steps are where most integrations get their first support ticket, and neither of them is a signature problem. The retry is correct behaviour, and an idempotent handler makes it a no-op.

#The languages, briefly

The check is the same in every language, because it is three operations and a constant-time compare. The differences that matter are the name of the HMAC constructor and how you read a raw body.

import { createHmac, timingSafeEqual } from "node:crypto"

function verify(rawBody: string, header: string, secret: string) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")))
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest()
  return timingSafeEqual(expected, Buffer.from(v1, "hex"))
}
  • Node. request.text() gives you the raw string. This is the version above.
  • Next.js route handlers. await request.text(), not request.json(). The route handler receives the exact bytes.
  • Python. hmac.new(secret.encode(), f"{t}.{raw}".encode(), hashlib.sha256).hexdigest(), then hmac.compare_digest. The raw body is await request.body().

The rule that catches people in every language: whatever your framework does by default, find the way to get the unparsed body and verify against that. A framework that parses JSON for you has already thrown away the thing you need.

#Registering an endpoint

const endpoint = await wuapi.webhookEndpoints.create({
  url: "https://example.com/webhooks/wuapi",
  events: ["message.received", "message.failed", "account.disconnected"],
})
console.log(endpoint.secret) // whsec_..., returned only on creation and rotation

Subscribe to events rather than everything. A narrower list is a smaller bill of surprises when you upgrade and something new starts arriving.

#Questions people ask

How do I verify a WhatsApp webhook signature?

Take the raw request body as bytes, read the `t` and `v1` values from the `Wuapi-Signature` header, reject anything older than about five minutes, then check that HMAC-SHA256 of `t` + "." + the raw body, keyed with your signing secret, equals `v1`. Compare with `timingSafeEqual`, not `===`.

What happens if I do not verify my webhook signature?

Anyone who finds your endpoint URL can post an event to it. For `message.received` that means your bot replies to a message your customer never sent, from a number you pay for, and the reply goes to whoever chose the payload.

How many times is a failed webhook delivery retried?

Six times in total, at 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours after the first attempt. After the sixth failure the event is kept, and the dashboard's Logs screen shows what was delivered and what was not. Make your handler idempotent and a retry costs nothing.

Does a webhook retry with the same signature?

Yes. Every attempt is a fresh delivery of the same event with a new timestamp, so the signature differs each time. That is why the timestamp window is part of the check: a retry hours later is legitimate, a captured request replayed days later is not.

Should I return 200 or 204 from my webhook endpoint?

Any 2xx counts as delivered. A `204` is the honest answer when you have handled the event and have nothing to return. Anything outside 2xx is treated as a failure and retried, so returning 500 after successfully handling an event will get you the same event six times.

#A checklist you can run against your own handler

Six questions, and a "no" to any of them is a bug rather than a style preference.

  • Can you get the raw request body, unparsed, before anything else runs?
  • Do you read both t and v1 out of the signature header?
  • Do you reject a timestamp outside about five minutes?
  • Do you compare with a constant-time function rather than ===?
  • Do you answer a non-2xx on failure and a 2xx on success, so retries mean what they say?
  • Is your handler idempotent on the event id, so a retry is a no-op?

If all six are yes, the endpoint is doing the job. Everything else on this page is detail behind them.

Header
Wuapi-Signature: t=…,v1=…
Signed string
the timestamp, a full stop, the raw body
Algorithm
HMAC-SHA256, hex digest
Tolerance
300 seconds by default
On failure
400, before your business logic runs
On success
any 2xx, so no retry is scheduled

#Where to go next

The webhooks reference has every endpoint setting and the retry table in full. Building a WhatsApp chatbot is about what you do once the event is verified, and the WhatsApp QR code covers the account.qr_code_issued case.

wuapi is an independent service. It is not affiliated with, endorsed or sponsored by WhatsApp. WhatsApp is a trademark of its respective owner.

03/What to read next

Every post

Link a number and send your first message.

One REST call, a typed SDK, and webhooks signed with HMAC-SHA256 over the raw body. No per-message fees.

The whole API is in the docs, and the docs are in one file if you are handing the work to a coding agent.

All posts · openapi.json