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:
inboundSecret— proves a call toPOST /v1/ingest/:endpointKeyreally came from you, not someone who found the ingest URL.outboundSecret— proves a delivery to your destination really came from Jitterflow, not an attacker who found your webhook receiver's URL.
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.