Verify webhook signatures before you trust a goal
Your webhook URL is public. Without a signature check, anyone who learns the URL posts fake goals into your app. Six lines of code stop them.
GOAL API· Engineering

Your webhook URL is not a secret
Your endpoint URL appears in logs, proxies, error trackers, and browser history. Treat the URL as public.
Anyone who learns the URL posts whatever they like to your app. Fake goals. Fake final scores. Settled bets on matches nobody played.
The signature proves a delivery came from us. Checking the signature takes 6 lines.
What we send
Every delivery carries a header:
X-Goal-Signature: t=1736899200,v1=5257a869e7ecebeda32affa62cdca3fa...
tis the unix timestamp when we signed the payloadv1is an HMAC SHA256 of the timestamp and the raw body, keyed with your signing secret
Verify in Node
import crypto from "crypto";
app.post("/webhooks/goal-api",
// The RAW bytes. A re-serialised body will not match the signature.
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("X-Goal-Signature");
const [t, v1] = header.split(",").map((p) => p.split("=")[1]);
const expected = crypto
.createHmac("sha256", process.env.GOAL_WEBHOOK_SECRET)
.update(t + "." + req.body)
.digest("hex");
const valid = crypto.timingSafeEqual(
Buffer.from(v1, "hex"),
Buffer.from(expected, "hex")
);
if (!valid) return res.sendStatus(401);
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(401);
res.sendStatus(200);
handle(JSON.parse(req.body));
}
);
Four mistakes to avoid
1. Comparing with ===
A plain string comparison exits at the first wrong byte. Timing differences leak the correct signature to anyone patient enough to measure.
Use crypto.timingSafeEqual. Python has hmac.compare_digest. Most languages ship an equivalent.
2. Signing a parsed body
JSON.parse then JSON.stringify changes key order and whitespace. Your HMAC will never match.
Sign the raw bytes. In Express, mount express.raw on the webhook route only, before any global express.json.
3. Skipping the timestamp check
Signature checks alone stop forgery. Replay is a different attack.
An attacker who captures one real delivery resends the exact bytes forever. The signature stays valid because the payload never changed.
Reject anything older than 5 minutes. The timestamp sits inside the signed material, so an attacker cannot edit the timestamp without breaking the signature.
4. Logging the secret
Your signing secret ends up in a log aggregator, then in a support ticket. Read the secret from the environment. Never print the secret, even at debug level.
Rotate when in doubt
curl -X POST https://api.goal-api.com/v1/auth/webhook-endpoints/ENDPOINT_ID/rotate-secret \
-H "Authorization: Bearer $GOAL_API_KEY"
Rotate after any laptop goes missing, any contractor leaves, or any secret lands in a shared channel.


