Check the HMAC before you trust a payload, and use the raw body when you do it.
Your endpoint URL is public. Anyone who learns it can POST whatever they like to it, so verify the signature before you act on a delivery.
How it works
Each endpoint has a signing secret. We compute HMAC-SHA256(raw_body, secret) and send it as:
X-HeySupport-Signature: sha256=<hex>You recompute it with your own copy of the secret and compare. If they differ, the request did not come from us.
Node
import crypto from 'node:crypto';
export function verify(rawBody, signatureHeader, secret) {
const expected =
'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(signatureHeader ?? '');
const b = Buffer.from(expected);
// Lengths must match before timingSafeEqual, which throws otherwise.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Express
import express from 'express';
const app = express();
// express.raw, NOT express.json — see below.
app.post(
'/hey-support',
express.raw({ type: 'application/json' }),
(req, res) => {
if (!verify(req.body, req.get('X-HeySupport-Signature'), process.env.HS_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Acknowledge fast, work afterwards.
res.sendStatus(200);
void handle(event);
},
);Next.js route handler
export async function POST(req: Request) {
const raw = await req.text();
if (!verify(raw, req.headers.get('x-heysupport-signature'), process.env.HS_WEBHOOK_SECRET!)) {
return new Response('bad signature', { status: 401 });
}
const event = JSON.parse(raw);
return Response.json({ ok: true });
}Use the raw body
Sign against the exact bytes we sent, never a re-serialized object. JSON.parse followed by JSON.stringify can reorder keys, change number formatting and drop whitespace — all of which produce a different HMAC and a signature that never matches.
This is by far the commonest reason a working secret appears to fail.
Most frameworks parse JSON for you by default, which destroys the raw body before your handler sees it. Turn that off for the webhook route specifically, as above.
Rotating the secret
You can rotate a signing secret from the same settings page. There is a 24-hour overlap during which we sign with both the new and the previous secret, so deliveries already in flight keep verifying while you deploy the new value.
Verify against either during that window, then drop the old one.
Compare in constant time
Use timingSafeEqual or your language's equivalent rather than ===. A plain string comparison returns early on the first differing byte, which leaks the correct signature a byte at a time to anyone willing to measure.