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.
The signature
Section titled “The signature”choppity-signature-256: t=1745800000,v1=5257a869e7ecebed...| Part | What it is |
|---|---|
t | Unix timestamp in seconds when we signed the body |
v1 | HMAC-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.
Verification recipe
Section titled “Verification recipe”The check has three steps:
- Recompute
HMAC_SHA256(secret, "<t>.<rawBody>")and timing-safe-compare againstv1. - Reject if
tis older than ~5 minutes (replay protection). - 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); });import hmac, hashlib, time
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(raw_body: bytes, header: str | None, secret: str) -> None: if not header: raise ValueError("missing signature") parts = dict(p.split("=", 1) for p in header.split(",")) t = parts.get("t") v1 = parts.get("v1") if not t or not v1: raise ValueError("malformed signature")
payload = f"{t}.{raw_body.decode('utf-8')}".encode("utf-8") expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, v1): raise ValueError("signature mismatch")
if time.time() - int(t) > TOLERANCE_SECONDS: raise ValueError("signature too old")Flask usage:
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/choppity")def receive(): try: verify_webhook( request.get_data(), # raw bytes, NOT request.json request.headers.get("choppity-signature-256"), os.environ["CHOPPITY_WEBHOOK_SECRET"], ) except ValueError as e: abort(401, str(e)) event = request.get_json() # ... handle event return "", 200package webhooks
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "strconv" "strings" "time")
const toleranceSeconds = 5 * 60
func Verify(rawBody []byte, header, secret string) error { if header == "" { return errors.New("missing signature") } var t, v1 string for _, p := range strings.Split(header, ",") { kv := strings.SplitN(p, "=", 2) if len(kv) != 2 { continue } switch kv[0] { case "t": t = kv[1] case "v1": v1 = kv[2-1] } } if t == "" || v1 == "" { return errors.New("malformed signature") }
mac := hmac.New(sha256.New, []byte(secret)) fmt.Fprintf(mac, "%s.%s", t, rawBody) expected := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), []byte(v1)) { return errors.New("signature mismatch") }
ts, err := strconv.ParseInt(t, 10, 64) if err != nil { return errors.New("malformed timestamp") } if time.Now().Unix()-ts > toleranceSeconds { return errors.New("signature too old") } return nil}Common mistakes
Section titled “Common mistakes”- Comparing against parsed JSON. The signature is over the raw body bytes
on the wire.
JSON.parse(body)thenJSON.stringify(parsed)re-encodes with different whitespace and key order — the HMAC will never match. - Ignoring the timestamp. Without the
~5 minutewindow, a captured delivery can be replayed forever. - Trusting the legacy
choppity-signatureheader. That header carries the raw secret in transit and is replayable. It’s there for backward compatibility; build new integrations againstchoppity-signature-256.
What if the signature doesn’t verify?
Section titled “What if the signature doesn’t verify?”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.