Verifying a webhook signature
Every webhook we send carries an HMAC-SHA-256 signature in the X-Jsys-Signature header. Your receiver MUST verify it before trusting the body — without verification, anyone who knows your endpoint URL can forge events.
What the signing secret is
The whsec_… string we show you exactly once when you create or rotate an endpoint. It's a shared HMAC key — we use it to sign outbound requests, your receiver uses the same key to verify them. Not encryption, not an API token — just a shared secret for proving the request came from us.
Treat it like a database password:
- Store in your receiver's environment variables or secrets manager.
- Never commit to a public repo. Never log it.
- If you suspect it leaked, rotate it from Webhooks → endpoint → Rotate secret. The old secret stays valid for 24h so receivers can update without dropped deliveries.
The signature format
X-Jsys-Signature: t=1781117608,v1=8f3a6b7c…t=— Unix timestamp (seconds) when we signed.v1=— hex-encoded HMAC-SHA-256 of{t}.{rawBody}with your endpoint's secret.- During the 24h rotation grace window, we co-sign with BOTH the new and previous secret. The header will then have TWO
v1=segments:t=…,v1=…,v1=…. Your verifier should iterate over eachv1=and accept if ANY matches — see the Node example below.
What to check before trusting the body
- The
t=timestamp is within 5 minutes of now (replay-attack guard). - At least one
v1=in the header matches the HMAC you compute locally — using timing-safe comparison (crypto.timingSafeEqualin Node,hmac.compare_digestin Python). String===leaks the secret one byte at a time over many requests. - The body is exactly the bytes we sent. Make sure your framework gives you the raw request body, not a JSON-parsed-and-re-serialized version. JSON whitespace differences will break verification.
Node.js (Express)
Critically, express.raw() — not express.json() — so the body bytes are preserved exactly.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
const SECRET = process.env.JSYS_WEBHOOK_SECRET; // whsec_...
app.post(
'/jsys-hooks',
express.raw({ type: 'application/json' }), // raw bytes — NOT json()
(req, res) => {
const header = req.header('X-Jsys-Signature');
if (!header) return res.status(400).send('missing signature header');
const raw = req.body.toString('utf8');
if (!verify(raw, header, SECRET)) {
return res.status(400).send('bad signature');
}
const event = JSON.parse(raw);
// Idempotency: event.id is unique + stable across retries.
// Cache the id; ignore on second arrival.
handleEvent(event);
return res.json({ received: true });
},
);
function verify(rawBody, header, secret, toleranceSec = 300) {
// Parse: t=…,v1=… (possibly v1=… repeated during rotation grace)
let t = null;
const sigs = [];
for (const part of header.split(',')) {
const eq = part.indexOf('=');
if (eq <= 0) continue;
const k = part.slice(0, eq).trim();
const v = part.slice(eq + 1).trim();
if (k === 't') t = Number(v);
else if (k === 'v1') sigs.push(v);
}
if (t === null || !Number.isFinite(t) || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // replay guard
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
for (const sig of sigs) {
const given = Buffer.from(sig, 'hex');
if (expected.length === given.length && timingSafeEqual(expected, given)) {
return true;
}
}
return false;
}
app.listen(3000);Python (Flask)
Flask's request.get_data() returns the raw bytes. request.get_json() would parse first, losing exactness — don't use it for verification.
import hmac, hashlib, os, time
from flask import Flask, request, abort, jsonify
app = Flask(__name__)
SECRET = os.environ['JSYS_WEBHOOK_SECRET'].encode() # whsec_... as bytes
@app.post('/jsys-hooks')
def hook():
header = request.headers.get('X-Jsys-Signature', '')
raw = request.get_data()
if not verify(raw, header, SECRET):
abort(400, 'bad signature')
import json
event = json.loads(raw)
handle_event(event)
return jsonify(received=True)
def verify(raw_body: bytes, header: str, secret: bytes, tolerance_sec: int = 300) -> bool:
t = None
sigs = []
for part in header.split(','):
if '=' not in part:
continue
k, v = part.split('=', 1)
k, v = k.strip(), v.strip()
if k == 't':
try:
t = int(v)
except ValueError:
return False
elif k == 'v1':
sigs.append(v)
if t is None or not sigs:
return False
if abs(time.time() - t) > tolerance_sec:
return False # replay guard
signed = f'{t}.'.encode() + raw_body
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
# Iterate signatures (covers the rotation-grace dual-sign window)
return any(hmac.compare_digest(expected, sig) for sig in sigs)Common mistakes
- Using
express.json()instead ofexpress.raw()— JSON parsing changes whitespace and breaks verification. Use the raw body for the HMAC, then parse it separately. - Comparing signatures with
==/===— leaks the secret over many requests via timing side-channel. UsetimingSafeEqual(Node) orhmac.compare_digest(Python). - Skipping the timestamp check — without it, an attacker who captures one signed request can replay it later forever.
- Only checking the first
v1=— during the 24h rotation grace window, the header carries two. Single-signature verifiers will fail half the deliveries until the grace ends. - Not handling idempotency — we retry up to 8 times on non-2xx. Cache
event.idfor at least 24h and ignore duplicates.
Verifying with curl (for one-off debugging)
Compute the expected signature locally and compare against what we sent:
# Pull a recent delivery's body + header from your receiver logs, then:
TS=1781117608
BODY='{"id":"evt_…","event":"order.fulfilled",…}'
SECRET='whsec_…'
echo -n "${TS}.${BODY}" | openssl dgst -sha256 -hmac "${SECRET}" -binary | xxd -p -c 256
# Compare the output with the v1= value in the X-Jsys-Signature header.