Jitterflow

← Blog

How to verify webhook signatures (HMAC and Standard Webhooks)

Without a signature, a webhook endpoint has to trust that a request claiming to be from a given sender actually is — anyone who finds the URL can POST to it. Signature verification closes that gap: the sender signs the request with a shared secret, and the receiver recomputes the signature and compares it before trusting the payload.

Two directions, two secrets

A webhook relay like Jitterflow sits in the middle of two independent relationships, and each one gets its own secret:

Both are opt-in per endpoint — an endpoint with neither set skips signing and verification entirely.

Signing a request you send to Jitterflow

If inboundSecret is set, every ingest call needs an X-Jitterflow-Inbound-Signature header: a hex-encoded HMAC-SHA256 of the raw JSON body, keyed by inboundSecret.

const crypto = require('crypto');

const body = JSON.stringify({ payload: { event: 'hello' } });
const signature = crypto.createHmac('sha256', inboundSecret).update(body).digest('hex');

// send `body` as the exact request body, with:
// X-Jitterflow-Inbound-Signature: <signature>

Sign the exact bytes you send. Re-serializing the object before sending — different key order, extra whitespace — produces a different signature and a 401, since the receiver hashes the raw body it actually got, not a re-parsed version of it.

Verifying a delivery from Jitterflow

If outboundSecret is set, every delivery carries two independent signature schemes at once, both keyed by the same secret — verify whichever fits your stack.

Legacy header

X-Jitterflow-Signature: hex-encoded HMAC-SHA256 of the raw request body.

function verify(secret, rawBody, signatureHeader) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  const expectedBuf = Buffer.from(expected, 'hex');
  const actualBuf = Buffer.from(signatureHeader, 'hex');
  return (
    expectedBuf.length === actualBuf.length &&
    crypto.timingSafeEqual(expectedBuf, actualBuf)
  );
}

Standard Webhooks-compatible headers

Jitterflow also sends Standard Webhooks-shaped headers alongside the legacy one — svix-id (the job's jobId), svix-timestamp (unix seconds), and svix-signature (v1,<base64 HMAC-SHA256>). A destination that already verifies webhooks from OpenAI, Twilio, or Supabase using a Standard-Webhooks-compatible library can often reuse that same verification code for Jitterflow.

One documented deviation from the spec: Standard Webhooks expects the secret in whsec_<base64> form. Jitterflow's outboundSecret is your own arbitrary text and isn't guaranteed to be valid base64 — its raw UTF-8 bytes are used as the HMAC key directly. Plugging it straight into an official SDK's secret parser will likely fail; verify manually instead:

function verifyStandardWebhook(secret, id, timestamp, rawBody, signatureHeader) {
  const signedContent = `${id}.${timestamp}.${rawBody}`;
  const expected =
    'v1,' + crypto.createHmac('sha256', secret).update(signedContent).digest('base64');
  const expectedBuf = Buffer.from(expected, 'utf8');

  // signatureHeader may contain multiple space-separated "v1,<base64>" tokens
  // (e.g. during a secret rotation window) — valid if ANY match.
  return signatureHeader
    .split(' ')
    .filter(Boolean)
    .some((candidate) => {
      const candidateBuf = Buffer.from(candidate, 'utf8');
      return (
        candidateBuf.length === expectedBuf.length &&
        crypto.timingSafeEqual(expectedBuf, candidateBuf)
      );
    });
}

Jitterflow doesn't itself enforce a freshness window on svix-timestamp — if replay protection matters for your destination, reject requests where the timestamp is too far from the current time, per standard Standard Webhooks guidance.

Full reference

Field-by-field detail, including custom headers merged into every delivery, is on Verifying signatures. Secrets are set per endpoint — see Endpoints.

Start free — takes 2 minutes