Platform API: Webhook signatures

Verify X-Dualhook-Signature on incoming lifecycle event webhooks using HMAC-SHA256 over the raw request body.

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:

  1. Regenerate in the dashboard and copy the new secret (shown once).
  2. Deploy your verifier to accept either the old or the new secret.
  3. 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.

Related

  • Platform API (Multi-Tenant Onboarding)Build embedded WhatsApp onboarding into your SaaS. Programmatic connection creation, per-tenant webhook routing, HMAC-signed event webhooks.
  • Platform API: Event webhooksReceive HMAC-SHA256 signed lifecycle events: onboarding, connection.mode_resolved, disconnect, and coexistence heartbeat events.
Browse more docsStart Free Trial