HookSteadBETA

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.

  1. Read the raw body, before any JSON parsing.
  2. HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64 part of your whsec_ secret.
  3. Accept if any v1,… value in webhook-signature matches, compared in constant time.
  4. Reject timestamps more than 5 minutes off, and skip a webhook-id you've already handled.

Node.js

Run
HOOKSTEAD_SECRET=whsec_… node receiver.mjs
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

Run
HOOKSTEAD_SECRET=whsec_… python receiver.py
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

Run
HOOKSTEAD_SECRET=whsec_… go run .
main.go
// 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))
}