Developers

How to verify webhook signatures (Node.js and Python)

Verify Standard Webhooks HMAC signatures in Node.js and Python: raw bodies in Express, Next.js and FastAPI, timestamps, replays, and why checks fail.

By Published 7 min read

On this page

To verify a webhook signature, recompute an HMAC-SHA256 over the exact bytes you received, compare it in constant time with the signature header, and reject old timestamps. With Standard Webhooks, which MobileValidate uses, the signed content is webhook-id.webhook-timestamp.body and the header is v1,<base64>. This guide has tested code for Node.js and Python, and lists the mistakes that make verification fail.

Why verify webhook signatures at all?

A webhook endpoint is a public URL that changes your data when it's called. Without a signature check, anyone who finds the URL can post a fake job.completed event and make your system fetch, trust or act on the wrong thing. The signature proves two things: the sender knows the shared secret, and the body wasn't changed in transit. The timestamp check adds a third: the message is recent, so a captured delivery can't be replayed next week.

MobileValidate sends webhooks when a lookup or bulk job finishes. Job events carry a summary and a results URL, never phone numbers, so a forged event can't inject data directly. It could still trigger work or confuse your state, so verify every delivery.

A server sends an envelope sealed with an HMAC key and a timestamp to a shield marked valid, while a replayed copy is rejected with 401.A server sends an envelope sealed with an HMAC key and a timestamp to a shield marked valid, while a replayed copy is rejected with 401.
Verify the signature and the timestamp on the raw body, and reject replays.

What exactly is signed?

Each delivery carries three headers:

HeaderContent
webhook-idThe event id, for example evt_…. Stays the same across retries
webhook-timestampUnix seconds when this attempt was sent. New for every attempt
webhook-signaturev1, plus the base64 HMAC-SHA256 of ${webhook-id}.${webhook-timestamp}.${raw body}

The HMAC key is the base64-decoded part of your endpoint secret after whsec_. You get the secret once, when you create the endpoint with POST /v1/webhook_endpoints, so store it in your secret manager straight away. The header can hold several space-separated signatures (for example during secret rotation); accept the delivery if any v1 signature matches. This follows the Standard Webhooks specification, and the HMAC construction itself is RFC 2104.

How do you verify a signature in Node.js?

No dependencies needed, only node:crypto:

JavaScript
// verify.mjs
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_S = 5 * 60;

export function verifyWebhook(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigHeader = headers["webhook-signature"];
  if (!id || !ts || !sigHeader) throw new Error("missing webhook headers");
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_S) {
    throw new Error("timestamp outside tolerance");
  }
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
  const ok = sigHeader.split(" ").some((part) => {
    const [version, sig] = part.split(",", 2);
    const got = Buffer.from(sig ?? "", "base64");
    return version === "v1" && got.length === expected.length && timingSafeEqual(got, expected);
  });
  if (!ok) throw new Error("invalid signature");
  return JSON.parse(rawBody);
}

timingSafeEqual compares in constant time, so an attacker can't learn the signature byte by byte from response times. It throws if the two buffers differ in length, which is why the length check comes first.

We tested this against deliveries signed exactly the way our webhook sender signs them. It accepted valid deliveries, and it also accepted a header with two signatures where only the second was correct. It rejected a re-serialized body (invalid signature) and a timestamp 301 seconds old (timestamp outside tolerance).

How do you get the raw body in Express and Next.js?

This is where most integrations break. Frameworks parse JSON for you, and once the body has been parsed and serialized again, its bytes no longer match what was signed.

Express 5: use express.raw() on the webhook route and make sure express.json() doesn't run on it first.

JavaScript
// server.mjs
import express from "express";
import { verifyWebhook } from "./verify.mjs";

const app = express();
const seen = new Set(); // use Redis or a DB table with a TTL in production

// express.raw keeps the body as a Buffer. Don't let express.json() run on this route first.
app.post("/webhooks/mobilevalidate", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = verifyWebhook(req.body.toString("utf8"), req.headers, process.env.MV_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send("invalid webhook");
  }
  const id = req.headers["webhook-id"];
  if (seen.has(id)) return res.sendStatus(204); // a retry of an event we already handled
  seen.add(id);
  res.sendStatus(204); // answer fast, work in the background
  queueMicrotask(() => handle(event));
});

function handle(event) {
  if (event.type === "job.completed") console.log("job done:", event.data.id, event.data.results_url);
  else console.log("event:", event.type);
}

app.listen(process.env.PORT ?? 3000);

Our run with Express 5.2.1: a valid delivery got 204, the same delivery again got 204 without being processed twice, and a tampered body got 400.

Next.js App Router: read the body with req.text() and never call req.json() before verifying.

JavaScript
// app/api/webhooks/mobilevalidate/route.js
import { verifyWebhook } from "@/lib/verify";

export async function POST(req) {
  const raw = await req.text(); // the exact bytes we received, as text; never req.json() first
  let event;
  try {
    event = verifyWebhook(raw, Object.fromEntries(req.headers), process.env.MV_WEBHOOK_SECRET);
  } catch {
    return new Response("invalid webhook", { status: 400 });
  }
  // enqueue event for processing, keyed by req.headers.get("webhook-id")
  return new Response(null, { status: 204 });
}

Object.fromEntries(req.headers) gives lowercase header names, which is what the function expects. We ran the handler with standard Request objects: 204 for a valid delivery, 400 after appending one space to the body.

How do you verify a signature in Python and FastAPI?

The standard library has everything:

Python
# webhook_verify.py
import base64
import hashlib
import hmac
import json
import time

TOLERANCE_S = 5 * 60


class WebhookError(Exception):
    pass


def verify_webhook(raw_body: bytes, headers, secret: str) -> dict:
    msg_id = headers.get("webhook-id")
    ts = headers.get("webhook-timestamp")
    sig_header = headers.get("webhook-signature")
    if not msg_id or not ts or not sig_header:
        raise WebhookError("missing webhook headers")
    if not (ts.isascii() and ts.isdigit()) or abs(time.time() - int(ts)) > TOLERANCE_S:
        raise WebhookError("timestamp outside tolerance")
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{ts}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for part in sig_header.split(" "):
        version, _, sig = part.partition(",")
        if version == "v1" and hmac.compare_digest(sig.encode(), expected.encode()):
            return json.loads(raw_body)
    raise WebhookError("invalid signature")

In FastAPI, take the Request and read await request.body(). Don't declare a Pydantic model for the payload on this route, because FastAPI would parse the body before you can verify it:

Python
# webhook_app.py
import os
from fastapi import BackgroundTasks, FastAPI, Request, Response
from webhook_verify import WebhookError, verify_webhook

app = FastAPI()
seen: set[str] = set()  # use Redis or a DB table with a TTL in production


def handle(event: dict) -> None:
    if event["type"] == "job.completed":
        print("job done:", event["data"]["id"])


@app.post("/webhooks/mobilevalidate", status_code=204)
async def mobilevalidate_webhook(request: Request, background: BackgroundTasks):
    raw = await request.body()  # raw bytes: don't parse with a Pydantic model first
    try:
        event = verify_webhook(raw, request.headers, os.environ["MV_WEBHOOK_SECRET"])
    except WebhookError:
        return Response("invalid webhook", status_code=400)
    msg_id = request.headers["webhook-id"]
    if msg_id not in seen:
        seen.add(msg_id)
        background.add_task(handle, event)  # answer fast, work after the response
    return Response(status_code=204)

Tested with FastAPI 0.141: valid delivery 204, repeat 204 (handled once), tampered body 400. Starlette's headers object is case-insensitive, so headers.get("webhook-id") works whatever case the sender uses. In Django, the equivalent is request.body in a view marked @csrf_exempt; in Flask, request.get_data().

Why does webhook verification fail?

In rough order of how often we'd expect to see them:

  1. The body was re-serialized. We checked this directly: json.dumps(json.loads(body)) in Python produced ", " and ": " separators where the original had none, and verification failed. Pretty-printing, key reordering and Unicode escaping do the same. Verify first, parse second.
  2. The secret wasn't base64-decoded. The key is the bytes after whsec_, decoded from base64, not the text of the secret. (Our code also accepts the secret without the prefix, since it strips whsec_ only if present.)
  3. Clock skew. A server clock more than five minutes off rejects every delivery. Run NTP.
  4. Hex instead of base64. Many older webhook schemes use hex digests. Standard Webhooks uses base64.
  5. Only the first signature was checked. The header can carry several space-separated signatures.
  6. A proxy changed the body. Some gateways re-encode or decompress bodies. Verify at the first hop that sees the original bytes, or configure the proxy to pass the body through.
  7. Wrong secret after re-creating the endpoint. Every endpoint has its own secret, shown once.

How do you prevent replays and duplicate processing?

Two defences, and you need both:

  • Timestamp tolerance. Reject deliveries whose webhook-timestamp is more than five minutes from your clock. A captured request becomes useless quickly. Each retry is re-signed with a fresh timestamp, so legitimate retries still pass.
  • Idempotency on webhook-id. The id stays the same across retries, so store processed ids (a table with a unique index, or Redis with a TTL of a few days) and skip repeats. This also protects you from your own retries: if your handler times out after doing the work, we'll deliver again.

Answer with a 2xx quickly and do the work in the background. We treat anything other than 2xx within 10 seconds as a failure and retry after about 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and then every 10 hours, for up to 24 hours.

How do you test your endpoint?

  • Endpoint verification. New endpoints start as pending_verification. We send a signed webhook.verification event, and any 2xx answer activates the endpoint. Until then, other events are held, not dropped.
  • Test deliveries. POST /v1/webhook_endpoints/{id}/test sends a signed webhook.test event, even before verification. It's the quickest way to check your signature code end to end.
  • Unit tests. Sign a fixture yourself with the same HMAC and assert that a tampered body, an old timestamp and a wrong secret all fail.
  • Test mode. With a test key, a lookup of +447700900004 that names your verified webhook_endpoint_id stays pending for about five seconds and then sends lookup.completed, so you can exercise the whole flow for free. See test mode.

Managing endpoints needs a key with the webhooks:manage scope. The webhooks docs cover registration, events and payloads.

Should you use a library instead?

If you prefer, yes. Standard Webhooks has official libraries in many languages. We verified real MobileValidate-signed deliveries with standardwebhooks 1.1.1 on npm (new Webhook(secret).verify(body, headers)) and 1.1.0 on PyPI. Both accepted valid deliveries. The mobilevalidate TypeScript SDK exports verifyWebhook from mobilevalidate/webhooks with the same checks and runs on Node, Bun, Deno and edge runtimes. The hand-written versions above are short enough to audit, which is why we show them.

What are the key takeaways?

  • Sign-check the exact raw bytes: express.raw(), req.text() in Next.js, await request.body() in FastAPI. Verify first, parse second.
  • The key is the base64-decoded secret after whsec_; the signature is base64, prefixed v1,, and may be one of several.
  • Compare in constant time, and reject timestamps more than five minutes off.
  • Deduplicate on webhook-id; it stays the same across retries.
  • Answer 2xx within 10 seconds and work in the background.
  • Test with the endpoint test event and free test-mode lookups before going live.

Sources

  1. Standard Webhooks specification — Standard Webhooks, 2026
  2. Standard Webhooks — standardwebhooks.com, 2026
  3. RFC 2104: HMAC: Keyed-Hashing for Message Authentication — IETF, 1997
  4. Express 5 API reference (express.raw) — Express, 2026
  5. Using the Request directly — FastAPI, 2026

Frequently asked questions

What is Standard Webhooks?

An open specification for signing and delivering webhooks. The sender puts webhook-id, webhook-timestamp and webhook-signature headers on each delivery. The signature is an HMAC-SHA256 of the id, timestamp and raw body, base64-encoded and prefixed with v1.

Why does my webhook signature verification fail?

Most often because the body was parsed and re-serialized before verification, so the bytes differ from what was signed. Other causes: using the secret string without base64-decoding the part after whsec_, a server clock that is more than five minutes off, or comparing against the wrong header.

How do I stop replayed webhooks?

Reject timestamps outside a short window (five minutes) and remember the webhook-id of events you've processed. Retries of the same event keep the same id, so the id also makes your handler idempotent.

Can I use an off-the-shelf Standard Webhooks library?

Yes. We verified our deliveries with the standardwebhooks packages on npm (1.1.1) and PyPI (1.1.0). The mobilevalidate SDK also includes verifyWebhook once it's published.

All articles

Know before you send.

Tell us about your use case. We review every request and set you up with test and live keys.