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-Signature—t=<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
v1doesn't match. Optionally reject oldtvalues to limit replay. - Always compare with a constant-time function (
crypto.timingSafeEqual).