Each delivery carries x-mellow-signature: the HMAC-SHA256 of the raw request body, keyed with the endpoint's secret, written as lowercase hex. The secret starts with whsec_ and is shown once when the endpoint is made.
Compute the same over the raw bytes you received, before parsing, and compare in constant time.
The time of the post
The body carries sentAt: the time this post was sent, new on each try. It is inside what the signature covers, so nobody can change it and keep the signature. After the signature checks out, refuse a post whose sentAt is more than five minutes from your own clock. Then a post that somebody copied cannot be sent to you again later.
at is the time of the event, and a retry keeps it. Do not use at for this check: a retry after an hour is a real post.
Node
Code
import { createHmac, timingSafeEqual } from "node:crypto";export function verify(rawBody, header, secret) { const expected = createHmac("sha256", secret).update(rawBody).digest("hex"); const given = Buffer.from(header ?? "", "hex"); return ( given.length === expected.length / 2 && timingSafeEqual(given, Buffer.from(expected, "hex")) );}// After verify, on the parsed body.export function fresh(body, maxMs = 5 * 60 * 1000) { return Math.abs(Date.now() - Date.parse(body.sentAt)) <= maxMs;}
Python
Code
import hashlib, hmacfrom datetime import datetime, timezonedef verify(raw_body: bytes, header: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header or "")# After verify, on the parsed body.def fresh(body: dict, max_seconds: int = 300) -> bool: sent = datetime.fromisoformat(body["sentAt"].replace("Z", "+00:00")) return abs((datetime.now(timezone.utc) - sent).total_seconds()) <= max_seconds
PHP
Code
function verify(string $rawBody, string $header, string $secret): bool { return hash_equals(hash_hmac('sha256', $rawBody, $secret), $header);}// After verify, on the parsed body.function fresh(array $body, int $maxSeconds = 300): bool { return abs(time() - strtotime($body['sentAt'])) <= $maxSeconds;}
Parse the JSON only after the signature checks out. A body that fails the check is not from us: answer 401 and do nothing with it. Answer a body whose sentAt is too old the same way.