Webhooks
Signature verification
Every delivery is signed with your webhook secret. Verify before you parse — an unsigned endpoint is an open door.
The scheme
algorithm
signature = HMAC_SHA256(secret, timestamp + "." + rawBody)
header = X-JourneyStack-Signature: sha256=<hex>- secret is the whsec_… value shown when you save a webhook URL. Rotate it any time in the portal.
- timestamp is the X-JourneyStack-Timestamp header, seconds since epoch.
- rawBody is the exact bytes we sent. Verify before JSON parsing — re-serialising changes the bytes and breaks the signature.
Node example
js
import { createHmac, timingSafeEqual } from "crypto";
export function verify(rawBody, headers, secret) {
const ts = headers["x-journeystack-timestamp"];
const given = String(headers["x-journeystack-signature"] || "").replace("sha256=", "");
if (!ts || !given) return false;
// Reject anything older than five minutes to stop replays.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const a = Buffer.from(given, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Getting the raw body
js
// Express: mount a raw parser on the webhook path only
app.post("/hooks/journeystack", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body.toString("utf8"), req.headers, process.env.JS_WEBHOOK_SECRET)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
enqueue(event); // do the work off the request
res.status(200).send("ok");
});Respond fast, work later
Return 2xx within 10 seconds. Anything slower is treated as a failure and retried. Queue the payload and process it outside the request.
Rotating the secret
Rotating in the portal issues a new secret immediately; deliveries in flight were already signed with the old one. Accept both secrets for a few minutes during a rotation, then drop the old value.