02 / Docs
Verify HookStead signatures
Complete receivers with no dependencies. Each one is started for real in our test suite, receives a delivery from HookStead, and must reject tampered, forged, stale and unsigned requests.
- Read the raw body, before any JSON parsing.
- HMAC-SHA256 of
{webhook-id}.{webhook-timestamp}.{body}, keyed with the base64 part of yourwhsec_secret. - Accept if any
v1,…value inwebhook-signaturematches, compared in constant time. - Reject timestamps more than 5 minutes off, and skip a webhook-id you've already handled.
Node.js
HOOKSTEAD_SECRET=whsec_… node receiver.mjs// A webhook receiver that verifies Hookstead signatures (Standard Webhooks).
// Node.js 18+, no dependencies.
//
// HOOKSTEAD_SECRET=whsec_... PORT=4000 node receiver.mjs
//
// Hookstead signs every delivery with three headers:
// webhook-id the delivery id (the same on every retry: use it to skip duplicates)
// webhook-timestamp unix seconds when this attempt was signed
// webhook-signature "v1,<base64 HMAC-SHA256>"; several, space-separated, while a secret rotates
// The signed content is "{webhook-id}.{webhook-timestamp}.{raw body}", keyed with the base64
// part of your destination's whsec_ secret. Always verify against the raw bytes, before parsing.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const TOLERANCE_SECONDS = 5 * 60;
const secret = process.env.HOOKSTEAD_SECRET ?? "";
if (!secret.startsWith("whsec_")) {
console.error("Set HOOKSTEAD_SECRET to your destination's signing secret (whsec_...)");
process.exit(1);
}
const key = Buffer.from(secret.slice("whsec_".length), "base64");
/** Returns true when the request carries a valid, fresh Hookstead signature. */
export function verify(headers, body, nowSeconds = Math.floor(Date.now() / 1000)) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
const ts = Number(timestamp);
if (!Number.isInteger(ts) || Math.abs(nowSeconds - ts) > TOLERANCE_SECONDS) return false; // replay protection
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(body).digest();
return signatures.split(" ").some((entry) => {
const [version, sig] = entry.split(",");
if (version !== "v1" || !sig) return false;
const given = Buffer.from(sig, "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
const seen = new Set(); // use a database or cache in production
const server = createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const body = Buffer.concat(chunks);
if (req.method !== "POST" || !verify(req.headers, body)) {
res.writeHead(401).end("invalid signature\n");
return;
}
const id = req.headers["webhook-id"];
if (seen.has(id)) {
res.writeHead(200).end("duplicate\n"); // already handled: acknowledge, do nothing
return;
}
seen.add(id);
console.log(JSON.stringify({ id, body: body.toString("utf8") }));
res.writeHead(204).end(); // any 2xx tells Hookstead the delivery succeeded
});
});
server.listen(Number(process.env.PORT ?? 4000), "127.0.0.1", () => {
console.log(`listening on http://127.0.0.1:${server.address().port}`);
});Python
HOOKSTEAD_SECRET=whsec_… python receiver.py"""A webhook receiver that verifies Hookstead signatures (Standard Webhooks).
Python 3.9+, standard library only.
HOOKSTEAD_SECRET=whsec_... PORT=4000 python receiver.py
Hookstead signs every delivery with three headers:
webhook-id the delivery id (the same on every retry: use it to skip duplicates)
webhook-timestamp unix seconds when this attempt was signed
webhook-signature "v1,<base64 HMAC-SHA256>"; several, space-separated, while a secret rotates
The signed content is "{webhook-id}.{webhook-timestamp}.{raw body}", keyed with the base64 part
of your destination's whsec_ secret. Always verify against the raw bytes, before parsing.
"""
import base64
import hashlib
import hmac
import json
import os
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
TOLERANCE_SECONDS = 5 * 60
SECRET = os.environ.get("HOOKSTEAD_SECRET", "")
if not SECRET.startswith("whsec_"):
sys.exit("Set HOOKSTEAD_SECRET to your destination's signing secret (whsec_...)")
KEY = base64.b64decode(SECRET[len("whsec_"):])
def verify(headers, body: bytes, now=None) -> bool:
"""True when the request carries a valid, fresh Hookstead signature."""
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures:
return False
try:
ts = int(timestamp)
except ValueError:
return False
if abs(int(now if now is not None else time.time()) - ts) > TOLERANCE_SECONDS:
return False # replay protection
signed = msg_id.encode() + b"." + timestamp.encode() + b"." + body
expected = hmac.new(KEY, signed, hashlib.sha256).digest()
for entry in signatures.split(" "):
version, _, sig = entry.partition(",")
if version != "v1" or not sig:
continue
try:
given = base64.b64decode(sig, validate=True)
except ValueError:
continue
if hmac.compare_digest(given, expected):
return True
return False
SEEN = set() # use a database or cache in production
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("content-length") or 0))
if not verify(self.headers, body):
self.send_response(401)
self.end_headers()
self.wfile.write(b"invalid signature\n")
return
msg_id = self.headers["webhook-id"]
if msg_id in SEEN:
self.send_response(200) # already handled: acknowledge, do nothing
self.end_headers()
return
SEEN.add(msg_id)
print(json.dumps({"id": msg_id, "body": body.decode("utf-8", "replace")}), flush=True)
self.send_response(204) # any 2xx tells Hookstead the delivery succeeded
self.end_headers()
def log_message(self, *args):
pass
if __name__ == "__main__":
server = ThreadingHTTPServer(("127.0.0.1", int(os.environ.get("PORT", "4000"))), Handler)
print(f"listening on http://127.0.0.1:{server.server_port}", flush=True)
server.serve_forever()Go
HOOKSTEAD_SECRET=whsec_… go run .// A webhook receiver that verifies Hookstead signatures (Standard Webhooks).
// Go 1.21+, standard library only.
//
// HOOKSTEAD_SECRET=whsec_... PORT=4000 go run .
//
// Hookstead signs every delivery with three headers:
//
// webhook-id the delivery id (the same on every retry: use it to skip duplicates)
// webhook-timestamp unix seconds when this attempt was signed
// webhook-signature "v1,<base64 HMAC-SHA256>"; several, space-separated, while a secret rotates
//
// The signed content is "{webhook-id}.{webhook-timestamp}.{raw body}", keyed with the base64 part
// of your destination's whsec_ secret. Always verify against the raw bytes, before parsing.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
const toleranceSeconds = 5 * 60
// Verify reports whether the request carries a valid, fresh Hookstead signature.
func Verify(key []byte, h http.Header, body []byte, now time.Time) bool {
id, timestamp, signatures := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
if id == "" || timestamp == "" || signatures == "" {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if d := now.Unix() - ts; d > toleranceSeconds || d < -toleranceSeconds {
return false // replay protection
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + timestamp + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, entry := range strings.Split(signatures, " ") {
version, sig, ok := strings.Cut(entry, ",")
if !ok || version != "v1" {
continue
}
given, err := base64.StdEncoding.DecodeString(sig)
if err == nil && hmac.Equal(given, expected) {
return true
}
}
return false
}
func main() {
secret := os.Getenv("HOOKSTEAD_SECRET")
if !strings.HasPrefix(secret, "whsec_") {
log.Fatal("Set HOOKSTEAD_SECRET to your destination's signing secret (whsec_...)")
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
log.Fatal("HOOKSTEAD_SECRET is not valid base64 after whsec_")
}
var mu sync.Mutex
seen := map[string]bool{} // use a database or cache in production
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20))
if err != nil || r.Method != http.MethodPost || !Verify(key, r.Header, body, time.Now()) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
id := r.Header.Get("webhook-id")
mu.Lock()
dup := seen[id]
seen[id] = true
mu.Unlock()
if dup {
w.WriteHeader(http.StatusOK) // already handled: acknowledge, do nothing
return
}
out, _ := json.Marshal(map[string]string{"id": id, "body": string(body)})
fmt.Println(string(out))
w.WriteHeader(http.StatusNoContent) // any 2xx tells Hookstead the delivery succeeded
})
port := os.Getenv("PORT")
if port == "" {
port = "4000"
}
fmt.Printf("listening on http://127.0.0.1:%s\n", port)
log.Fatal(http.ListenAndServe("127.0.0.1:"+port, nil))
}