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.