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.
The headers
Section titled “The headers”| Header | Value |
|---|---|
X-Webhooker-Signature | v1=<hex> — HMAC-SHA256, hex-encoded. |
X-Webhooker-Timestamp | Unix seconds at signing time. |
X-Webhooker-Event-Id | The 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:
- 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.
- Compare with a constant-time function. A plain
==leaks timing information.
Verify the signature
Section titled “Verify the signature”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")));});import hashlibimport hmacimport osimport time
from flask import Flask, request
app = Flask(__name__)SIGNING_SECRET = os.environ["WEBHOOKER_SIGNING_SECRET"].encode()TOLERANCE_SECONDS = 300
@app.post("/hooks")def receive_webhook(): signature_header = request.headers.get("X-Webhooker-Signature", "") timestamp = request.headers.get("X-Webhooker-Timestamp", "") event_id = request.headers.get("X-Webhooker-Event-Id", "")
try: if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return "stale timestamp", 401 except ValueError: return "bad timestamp", 401
raw_body = request.get_data() signed_payload = timestamp.encode() + b"." + raw_body expected = hmac.new(SIGNING_SECRET, signed_payload, hashlib.sha256).hexdigest() received = signature_header.removeprefix("v1=")
if not hmac.compare_digest(expected, received): return "bad signature", 401
enqueue_event(event_id, raw_body) return "", 200package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "os" "strconv" "strings" "time")
const toleranceSeconds = 300
func handleWebhook(writer http.ResponseWriter, request *http.Request) { signingSecret := []byte(os.Getenv("WEBHOOKER_SIGNING_SECRET")) signatureHeader := request.Header.Get("X-Webhooker-Signature") timestamp := request.Header.Get("X-Webhooker-Timestamp") eventID := request.Header.Get("X-Webhooker-Event-Id")
signedAt, err := strconv.ParseInt(timestamp, 10, 64) if err != nil || abs(time.Now().Unix()-signedAt) > toleranceSeconds { http.Error(writer, "stale timestamp", http.StatusUnauthorized) return }
rawBody, err := io.ReadAll(request.Body) if err != nil { http.Error(writer, "unreadable body", http.StatusBadRequest) return }
mac := hmac.New(sha256.New, signingSecret) mac.Write([]byte(timestamp)) mac.Write([]byte(".")) mac.Write(rawBody) expected := mac.Sum(nil)
received, err := hex.DecodeString(strings.TrimPrefix(signatureHeader, "v1=")) if err != nil || !hmac.Equal(expected, received) { http.Error(writer, "bad signature", http.StatusUnauthorized) return }
writer.WriteHeader(http.StatusOK) go processEvent(eventID, rawBody)}
func abs(value int64) int64 { if value < 0 { return -value } return value}Rejecting stale deliveries
Section titled “Rejecting stale deliveries”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.
Idempotency
Section titled “Idempotency”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.
Other authentication methods
Section titled “Other authentication methods”- 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.