Skip to content

When a gateway has Sign outbound requests set to HMAC, every delivery carries a signature over the exact bytes being sent. Verify it before you trust the body: your endpoint is public, so the signature is what separates a real delivery from anyone who guessed the URL.

HeaderValue
X-Webhooker-Signaturev1=<hex> — HMAC-SHA256, hex-encoded.
X-Webhooker-TimestampUnix seconds at signing time.
X-Webhooker-Event-IdThe event id. Stable across attempts and replays — use it for idempotency.

The signed string is the timestamp, a literal dot, then the raw request body:

HMAC_SHA256(secret, "{timestamp}.{raw body}")

Two rules decide whether your check works:

  1. Use the raw body bytes. A body parsed into an object and re-serialised is not the same bytes, and the signature will not match.
  2. Compare with a constant-time function. A plain == leaks timing information.
import crypto from "node:crypto";
import express from "express";
const app = express();
const signingSecret = process.env.WEBHOOKER_SIGNING_SECRET;
const toleranceSeconds = 300;
// The raw body is required: express.json() would destroy the exact bytes.
app.post("/hooks", express.raw({ type: "*/*" }), (request, response) => {
const signatureHeader = request.get("X-Webhooker-Signature") ?? "";
const timestamp = request.get("X-Webhooker-Timestamp") ?? "";
const eventId = request.get("X-Webhooker-Event-Id") ?? "";
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
return response.status(401).send("stale timestamp");
}
const expected = crypto
.createHmac("sha256", signingSecret)
.update(`${timestamp}.`)
.update(request.body)
.digest("hex");
const received = signatureHeader.replace(/^v1=/, "");
const expectedBuffer = Buffer.from(expected, "hex");
const receivedBuffer = Buffer.from(received, "hex");
const signatureValid =
expectedBuffer.length === receivedBuffer.length &&
crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
if (!signatureValid) {
return response.status(401).send("bad signature");
}
// Answer first, process afterwards.
response.status(200).end();
handleEvent(eventId, JSON.parse(request.body.toString("utf8")));
});

The timestamp header lets you refuse a request that was captured and replayed later. A 5-minute window is a sensible default, as used above. Remember that a genuine retry can arrive hours after the event was received — but it is re-signed at send time, so its timestamp is always fresh. A stale timestamp means the request was recorded and replayed, not that it was retried.

X-Webhooker-Event-Id is the same for every attempt and every manual replay of one event. Store it, and drop a delivery you have already processed. Delivery is at-least-once: a response that gets lost on the way back is retried even though your handler already did the work.

  • API key — the configured header carries the secret verbatim. Compare it in constant time too.
  • Basic auth — a standard Authorization: Basic … header.
  • None — nothing is added, and any caller can imitate a delivery. Use it only when the endpoint is protected by other means, such as mutual TLS or an allow-list.