Dualhook signs every Platform API lifecycle event with X-Dualhook-Signature: sha256=<hex>, an HMAC-SHA256 of the raw request body keyed with your partner secret. Verify on the raw bytes — if your framework re-serializes JSON before passing it to your handler, the signature will mismatch. Use a constant-time comparison.
What X-Dualhook-Signature is
The signature header looks like:
X-Dualhook-Signature: sha256=8a8b5fe4c3d…
The hex value is HMAC_SHA256(secret, rawBody) where rawBody is the bytes of the request body before any JSON parsing.
Verify the signature
import { createHmac, timingSafeEqual } from "crypto";
function verify(rawBody, signatureHeader, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && timingSafeEqual(a, b);
}
timingSafeEqual prevents timing-based discovery of the expected signature.
You must use the raw request body
The signature is computed over the raw request body. If your framework re-serializes JSON before passing it to your handler (Express's bodyParser.json() after a stringify pass, certain Next.js middleware patterns, etc.), the signature will mismatch — capture and verify the raw bytes first, then parse JSON.
In Express:
app.use("/wh/dualhook", express.raw({ type: "application/json" }));
app.post("/wh/dualhook", (req, res) => {
if (!verify(req.body, req.headers["x-dualhook-signature"], SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// ...
});
In Next.js App Router (app/api/wh/dualhook/route.ts):
export async function POST(req: Request) {
const raw = await req.text(); // raw bytes as string
const ok = verify(Buffer.from(raw), req.headers.get("x-dualhook-signature") ?? "", SECRET);
if (!ok) return new Response("invalid signature", { status: 401 });
const event = JSON.parse(raw);
// ...
}
Secret rotation
Partner secrets are rotatable from the dashboard. Rotation is immediate: the moment you regenerate, all deliveries are signed with the new secret only — there is no dual-signing grace window.
To roll without dropping events:
- Regenerate in the dashboard and copy the new secret (shown once).
- Deploy your verifier to accept either the old or the new secret.
- Once deployed everywhere, remove the old secret from your verifier.
If a delivery lands on a server that only knows the old secret, return a non-2xx — the retry ladder (1m → 5m → 15m → 1h → 6h → 24h) re-attempts for ~31 hours, which comfortably covers a rollout. Anything that still goes terminal can be redelivered afterwards via POST /api/v1/events/:id/redeliver.