Verify webhook signatures with HMAC in Node and Python

9 min readMetaKit

Your webhook receiver is a public URL that accepts POST requests. Anyone who finds it, or guesses it, can send you a position.closed event with a made-up profit, and if your code trusts the body, that number goes into your dashboard, your PnL report, or worse, your risk logic. Checking the HMAC signature on every delivery is the only thing standing between "an HTTP request arrived" and "MetaKit sent this".

It's about fifteen lines of code. The trouble is that four of those lines are easy to get subtly wrong, and every one of the wrong versions still works in testing.

How the HMAC webhook signature is built

Every delivery from a registered webhook carries three headers:

Content-Type: application/json
X-MetaKit-Event: trade.position.opened
X-MetaKit-Signature: t=1769594460,v1=5f2c...9ab

t is the Unix time in seconds when the event was stamped. v1 is a hex-encoded HMAC-SHA256 over the string "<t>.<raw body>", keyed with the endpoint's secret. The secret starts with whsec_ and is shown once in the dashboard (Settings, Webhooks) and returned as secret from GET /v1/webhooks.

To verify a webhook signature with HMAC you rebuild exactly that string from what arrived, compute your own digest with the same secret, and compare it to v1. If they match, the request was produced by someone holding the secret and hasn't been altered in transit.

The timestamp is inside the signed string on purpose

Notice that t isn't just a header you read; it's the prefix of the signed payload. If it were only a header, an attacker who captured one valid delivery could replay it forever and edit t to look fresh. Because t is signed, changing it breaks the signature, and because you also reject old values of t, replaying the original unchanged only works for a few minutes.

That window is the tolerance. Five minutes is the number to use. Retries reuse the original t and signature (the timestamp is stamped once per event, not per attempt), so a delivery that succeeds on the fifth attempt after network trouble can legitimately be several seconds old. Set tolerance to 10 seconds and you'll drop real retries.

Verify the raw body, not the parsed one

This is the one that bites Express users, and the failure is silent: every signature mismatches and you conclude the secret is wrong.

express.json() parses the body into an object and throws the bytes away. If you then JSON.stringify(req.body) to rebuild the signed string, you get different bytes. Key order can change. 406944.0 becomes 406944. Unicode escaping differs. HMAC doesn't care that the JSON is semantically identical; one byte off is a different digest.

Two fixes. Either mount express.raw({ type: "application/json" }) on the webhook route so req.body is a Buffer, or keep express.json() and pass a verify callback that stashes the raw bytes before parsing:

app.use(
  express.json({
    verify: (req, _res, buf) => {
      (req as any).rawBody = buf;
    },
  }),
);

The express.raw route is cleaner because the webhook path never touches the JSON parser at all. FastAPI and Flask hand you raw bytes on request (await request.body() and request.get_data() respectively), so Python mostly avoids this trap unless a framework middleware parses first.

Compare in constant time

expected === received returns as soon as it hits a differing character. An attacker measuring response times can, in principle, brute-force a valid signature one hex digit at a time. It's slow and noisy over the internet, but the fix costs nothing, so there's no reason to be the interesting case study.

Node: crypto.timingSafeEqual(a, b) on two Buffers of equal length (check the lengths first; it throws otherwise). Python: hmac.compare_digest(a, b).

Deduplicate, because retries are byte-identical

Delivery is at-least-once: up to five attempts with backoff of 0.5s, 1s, 2s, 4s, and each attempt must get a 2xx within five seconds. There's no delivery id header. A retry is the same body, same t, same v1.

That gives you a free idempotency key: the signature itself. Keep the last few minutes of seen v1 values (a Redis SET NX EX 600 is perfect; an in-memory map with a TTL is fine for a single process) and skip anything you've already handled. For trade events you can additionally key on data.ticket, which is stable across the position's lifetime. The delivery guarantees post goes into ordering, which is also not guaranteed.

Respond 2xx first, work second

The five-second timeout includes whatever your handler does. If you verify, then write to Postgres, then call Slack, then respond, you'll blow the budget under load and earn a retry you now have to dedupe. Verify, enqueue (or fire an async task), return 200. Do the real work after the response has gone.

Express and TypeScript

import crypto from "node:crypto";
import express from "express";
 
const SECRET = process.env.METAKIT_WEBHOOK_SECRET!; // whsec_...
const TOLERANCE_SEC = 300;
 
const seen = new Map<string, number>(); // signature -> expiry (ms)
 
function parseSignature(header: string | undefined): { t: string; v1: string } | null {
  if (!header) return null;
  const parts: Record<string, string> = {};
  for (const kv of header.split(",")) {
    const [k, v] = kv.split("=").map((s) => s.trim());
    if (k && v) parts[k] = v;
  }
  return parts.t && parts.v1 ? { t: parts.t, v1: parts.v1 } : null;
}
 
export function verifySignature(rawBody: Buffer, header: string | undefined): boolean {
  const sig = parseSignature(header);
  if (!sig) return false;
 
  const ageSec = Math.abs(Date.now() / 1000 - Number(sig.t));
  if (!Number.isFinite(ageSec) || ageSec > TOLERANCE_SEC) return false;
 
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${sig.t}.`)
    .update(rawBody)
    .digest("hex");
 
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(sig.v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
 
function alreadySeen(v1: string): boolean {
  const now = Date.now();
  for (const [k, exp] of seen) if (exp < now) seen.delete(k);
  if (seen.has(v1)) return true;
  seen.set(v1, now + TOLERANCE_SEC * 2 * 1000);
  return false;
}
 
const app = express();
 
app.post(
  "/hooks/metakit",
  express.raw({ type: "application/json" }), // Buffer, not express.json()
  (req, res) => {
    const header = req.get("X-MetaKit-Signature");
    if (!verifySignature(req.body as Buffer, header)) {
      return res.sendStatus(400);
    }
 
    const { v1 } = parseSignature(header)!;
    if (alreadySeen(v1)) return res.sendStatus(200); // retry of something handled
 
    res.sendStatus(200); // ack inside the 5s budget
 
    const event = req.get("X-MetaKit-Event") ?? "";
    const payload = JSON.parse((req.body as Buffer).toString("utf8"));
    void handle(event, payload).catch((err) => console.error("webhook handler failed", err));
  },
);
 
async function handle(event: string, payload: any) {
  if (event.startsWith("trade.")) {
    // header is trade.position.opened; body event is position.opened
    console.log(payload.account_id, payload.event, payload.data.ticket);
  } else if (event === "account.connected") {
    console.log("account live:", payload.account.id, payload.account.balance);
  } else if (event.startsWith("copier.")) {
    console.log(payload.copier_id, payload.action, payload.ok, payload.message);
  }
}
 
app.listen(3000);

One detail worth seeing in that code: the HMAC is fed ${t}. and then the raw Buffer separately, so the body bytes are never converted to a string and back. It's the same digest either way for valid UTF-8, but it removes a place for an encoding surprise to creep in.

Python with FastAPI

import hashlib
import hmac
import os
import time
 
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request
 
SECRET = os.environ["METAKIT_WEBHOOK_SECRET"]  # whsec_...
TOLERANCE = 300
 
app = FastAPI()
_seen: dict[str, float] = {}  # signature -> expiry
 
 
def verify_signature(raw_body: bytes, header: str | None) -> tuple[bool, str]:
    if not header:
        return False, ""
    try:
        parts = dict(p.strip().split("=", 1) for p in header.split(","))
    except ValueError:
        return False, ""
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1 or not t.isdigit():
        return False, ""
    if abs(time.time() - int(t)) > TOLERANCE:
        return False, ""
 
    expected = hmac.new(
        SECRET.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, v1), v1
 
 
def already_seen(v1: str) -> bool:
    now = time.time()
    for k in [k for k, exp in _seen.items() if exp < now]:
        del _seen[k]
    if v1 in _seen:
        return True
    _seen[v1] = now + TOLERANCE * 2
    return False
 
 
async def handle(event: str, payload: dict) -> None:
    if event.startswith("trade."):
        print(payload["account_id"], payload["event"], payload["data"]["ticket"])
    elif event == "account.connected":
        print("account live:", payload["account"]["id"], payload["account"]["balance"])
    elif event.startswith("copier."):
        print(payload["copier_id"], payload["action"], payload["ok"], payload["message"])
 
 
@app.post("/hooks/metakit")
async def metakit_hook(
    request: Request,
    background: BackgroundTasks,
    x_metakit_signature: str | None = Header(default=None),
    x_metakit_event: str = Header(default=""),
):
    raw = await request.body()  # bytes, untouched by any parser
    ok, v1 = verify_signature(raw, x_metakit_signature)
    if not ok:
        raise HTTPException(status_code=400, detail="bad signature")
    if already_seen(v1):
        return {"ok": True}
 
    payload = await request.json()
    background.add_task(handle, x_metakit_event, payload)
    return {"ok": True}

BackgroundTasks runs after the response is sent, which is exactly the ordering you want. For anything heavier than logging, push to a queue instead so a crash mid-task doesn't lose the event.

Test it before pointing MetaKit at it

You don't need a real event to prove the verifier works. You hold the secret, so you can sign a body yourself and POST it. The signed string is the timestamp, a dot, and the exact bytes you're about to send:

SECRET="whsec_..."
T=$(date +%s)
BODY='{"event":"account.connected","timestamp":"2026-08-12T09:41:00.000Z","account":{"id":2,"status":"connected"}}'
V1=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
 
curl -i http://localhost:3000/hooks/metakit \
  -H "Content-Type: application/json" \
  -H "X-MetaKit-Event: account.connected" \
  -H "X-MetaKit-Signature: t=$T,v1=$V1" \
  --data-binary "$BODY"

Expect a 200. Then run it again unchanged and expect a 200 with nothing logged (the dedupe caught it). Then change one character in BODY without re-signing and expect a 400. Then set T to an hour ago and expect a 400. If all four behave, the verifier is doing its job; if the first one fails, the usual culprit is a body parser upstream of the route rewriting the bytes.

Rotating the secret without dropping events

POST /v1/webhooks/{id}/rotate issues a new whsec_ immediately. Deliveries already in flight were signed with the old one, so for the minute or two it takes to deploy the new secret, accept either. The simplest pattern is a list of secrets in the environment; verify against each and pass if any matches. Then drop the old one.

Two things that are not signed

Equity monitor alerts sent to a webhook channel carry X-MetaKit-Event: monitor.triggered but no X-MetaKit-Signature header. They're authenticated by URL secrecy (put a long random token in the path) and by re-reading the account from the API before acting on the number. The alerts post covers those.

And nothing in this post protects you if the secret itself leaks. It's a credential. Keep it out of the repo, out of client-side code, and rotate it the moment you're not sure who has it.

The rest of the webhook contract, including the three payload shapes and which events exist, is in the docs and llms.txt.