Skip to content

Verifying signatures

Every webhook delivery includes a choppity-signature-256 header. Use it to prove the request came from Choppity and hasn’t been replayed.

choppity-signature-256: t=1745800000,v1=5257a869e7ecebed...
PartWhat it is
tUnix timestamp in seconds when we signed the body
v1HMAC-SHA256 of <t>.<rawBody>, hex-encoded, keyed by your signing secret

<rawBody> is the exact request body bytes we POSTed. If your framework parses JSON before you can see the raw bytes, configure it to expose the raw body as a buffer or string — re-stringifying parsed JSON won’t match because of whitespace and key-order differences.

The check has three steps:

  1. Recompute HMAC_SHA256(secret, "<t>.<rawBody>") and timing-safe-compare against v1.
  2. Reject if t is older than ~5 minutes (replay protection).
  3. Reject if either fails.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
export function verifyWebhook(rawBody, header, secret) {
if (!header) throw new Error('missing signature');
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('='))
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) throw new Error('malformed signature');
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
if (
expected.length !== v1.length ||
!timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
) {
throw new Error('signature mismatch');
}
const ageSeconds = Math.floor(Date.now() / 1000) - Number(t);
if (ageSeconds > TOLERANCE_SECONDS) {
throw new Error('signature too old');
}
}

Express usage — make sure you read the raw body, not the parsed JSON:

import express from 'express';
const app = express();
app.post(
'/webhooks/choppity',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
verifyWebhook(
req.body.toString('utf8'),
req.header('choppity-signature-256'),
process.env.CHOPPITY_WEBHOOK_SECRET
);
} catch (e) {
return res.status(401).send(e.message);
}
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event
res.sendStatus(200);
}
);
  • Comparing against parsed JSON. The signature is over the raw body bytes on the wire. JSON.parse(body) then JSON.stringify(parsed) re-encodes with different whitespace and key order — the HMAC will never match.
  • Ignoring the timestamp. Without the ~5 minute window, a captured delivery can be replayed forever.
  • Trusting the legacy choppity-signature header. That header carries the raw secret in transit and is replayable. It’s there for backward compatibility; build new integrations against choppity-signature-256.

Return 401. We’ll retry up to 5 times with exponential backoff. If the mismatch is real (wrong secret, body re-encoded, etc.) you’ll see retries pile up in your logs — fix the verification and the next delivery will go through.