Receiving deliveries
Verify our signature
When a destination has a signing secret (whsec_…), Webhooker
signs every delivery it makes to your endpoint. Verifying that signature is
how your handler knows a request genuinely came from us and not from someone
who guessed your URL.
Delivery headers
Every signed delivery carries these three headers:
| Header | Example | Meaning |
|---|---|---|
X-Webhooker-Signature | v1=9f86d0… | v1= followed by the hex HMAC-SHA256 of
"{timestamp}.{body}", keyed with your
signing secret.
|
X-Webhooker-Timestamp | 1717500000 | Unix seconds at which the signature was computed. |
X-Webhooker-Event-Id | 3f9c2a10-… | The event's ID. Stable across retries — use it as an idempotency key. |
How to verify
Recompute HMAC-SHA256 over the string
timestamp + "." + rawBody using your signing secret, prefix it
with v1=, and compare it to the header with a constant-time
comparison. Two things to get right:
- Sign the raw request bytes. If you parse the JSON first and re-serialize it, the signature won't match.
- Reject deliveries whose timestamp is too old. A tolerance window blunts replay attacks where an old, valid request is sent again.
If a check that looks right keeps failing, the cause is almost always one of six recurring mistakes — body re-serialization and encoding issues lead the list.
Node.js (Express)
const crypto = require("crypto");
const express = require("express");
const app = express();
// Capture the exact bytes Webhooker signed. Letting a JSON parser run first
// re-serializes the body and the signature will no longer match.
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf; },
}));
const SIGNING_SECRET = process.env.WEBHOOKER_SIGNING_SECRET; // "whsec_..."
function isValid(req, toleranceSeconds) {
const received = req.get("X-Webhooker-Signature") || "";
const timestamp = req.get("X-Webhooker-Timestamp") || "";
// Reject stale deliveries to blunt replay attacks.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!timestamp || age > toleranceSeconds) return false;
const hmac = crypto.createHmac("sha256", SIGNING_SECRET);
hmac.update(timestamp + ".");
hmac.update(req.rawBody);
const expected = "v1=" + hmac.digest("hex");
// Constant-time compare; timingSafeEqual throws on a length mismatch.
const a = Buffer.from(received);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhooks", (req, res) => {
if (!isValid(req, 300)) return res.status(401).send("bad signature");
// X-Webhooker-Event-Id is stable across retries: use it as an idempotency key.
const eventId = req.get("X-Webhooker-Event-Id");
// ... process the event, then acknowledge ...
res.sendStatus(200);
});
app.listen(3000); Python (Flask)
import hmac
import hashlib
import time
from flask import Flask, request
app = Flask(__name__)
SIGNING_SECRET = "whsec_..." # load this from an environment variable
def is_valid(req, tolerance_seconds=300):
received = req.headers.get("X-Webhooker-Signature", "")
timestamp = req.headers.get("X-Webhooker-Timestamp", "")
raw_body = req.get_data() # exact bytes, before any parsing
if not timestamp or abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
signed = timestamp.encode() + b"." + raw_body
digest = hmac.new(SIGNING_SECRET.encode(), signed, hashlib.sha256).hexdigest()
expected = "v1=" + digest
return hmac.compare_digest(received, expected)
@app.post("/webhooks")
def webhooks():
if not is_valid(request):
return "bad signature", 401
# X-Webhooker-Event-Id is stable across retries: use it as an idempotency key.
event_id = request.headers.get("X-Webhooker-Event-Id")
# ... process the event, then acknowledge ...
return "", 200 Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"time"
)
const signingSecret = "whsec_..." // load this from the environment
func isValid(r *http.Request, body []byte, tolerance time.Duration) bool {
received := r.Header.Get("X-Webhooker-Signature")
tsHeader := r.Header.Get("X-Webhooker-Timestamp")
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return false
}
// Reject stale deliveries to blunt replay attacks.
if time.Since(time.Unix(ts, 0)).Abs() > tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(tsHeader + "."))
mac.Write(body)
expected := "v1=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(received), []byte(expected))
}
func handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if !isValid(r, body, 5*time.Minute) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// r.Header.Get("X-Webhooker-Event-Id") is a stable idempotency key.
w.WriteHeader(http.StatusOK)
}