Verify signatures

Every outbound delivery is signed with the destination's secret (shown once when you create the destination, prefixed whsec_). Verify it before trusting the payload.

The headers

Each delivery carries:

Hookie-Signature: t=1718000000,v1=5f2b8c…e11a
Hookie-Event-Id: 9d3f2a10-…
Hookie-Delivery-Id: 4c7b…
  • Hookie-Signaturet=<unix>,v1=<hex>, an HMAC-SHA256 (Stripe-style).
  • Hookie-Event-Id — the event id; dedupe on this, since delivery is at-least-once and unordered.
  • Hookie-Delivery-Id — the specific delivery attempt (useful in logs).

What's signed

The signed string is <t>.<raw body> — the timestamp, a dot, then the exact request body bytes. Compute HMAC-SHA256(secret, "<t>.<body>") as lowercase hex and compare it to v1 in constant time.

Node.js

const crypto = require("crypto");

// Verify a Hookie outbound webhook signature.
// header: the raw "Hookie-Signature" value ("t=<unix>,v1=<hex>")
// rawBody: the exact request body bytes (do NOT re-serialize the JSON)
function verifyHookie(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  if (!parts.t || !parts.v1) return false;
  const signed = `${parts.t}.${rawBody}`;
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
  const a = Buffer.from(parts.v1);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Express handler

const express = require("express");
const app = express();

// Capture the RAW body — signature is over the exact bytes.
app.post("/hook", express.raw({ type: "*/*" }), (req, res) => {
  const raw = req.body.toString("utf8");
  const ok = verifyHookie(process.env.HOOKIE_SECRET, req.get("Hookie-Signature"), raw);
  if (!ok) return res.status(401).end();

  const eventId = req.get("Hookie-Event-Id"); // dedupe on this — delivery is at-least-once
  const event = JSON.parse(raw);
  // … handle event …
  res.status(200).end();
});

Notes

  • Verify against the raw bytes — re-serializing the JSON will change the signature.
  • Reject if the header is missing or v1 doesn't match. Optionally reject old t values to limit replay.
  • Always compare with a constant-time function (crypto.timingSafeEqual).