Verify our signature

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:

HeaderExampleMeaning
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)

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)

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)

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)
}