Jjsys docs
API · Webhooks · Verifying

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 each v1= and accept if ANY matches — see the Node example below.

What to check before trusting the body

  1. The t= timestamp is within 5 minutes of now (replay-attack guard).
  2. At least one v1= in the header matches the HMAC you compute locally — using timing-safe comparison (crypto.timingSafeEqual in Node, hmac.compare_digest in Python). String === leaks the secret one byte at a time over many requests.
  3. 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 of express.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. Use timingSafeEqual (Node) or hmac.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.id for 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.

Back to webhooks overview · Configure your endpoints

Try it live

Run these endpoints against your sandbox with your test key.

Open in the playground