Verify that a webhook delivery came from send.dev and was not replayed
Every delivery is signed with the endpoint's signing_secret using HMAC-SHA256. Verify the signature before trusting the payload; anyone who learns your webhook URL can otherwise POST fabricated events to it.
| Header | Example | Description |
|---|---|---|
X-DoDevWebhook-Id | evt_9c2a1d7b8e4c0f9a1b23f3 | The event id. Same as id in the body. |
X-DoDevWebhook-Timestamp | 1757000001 | Unix time in seconds when this delivery attempt was made. |
X-DoDevWebhook-Signature | t=1757000001,v1=5f1c… | The timestamp again, and the hex HMAC. |
The timestamp in the signature header is per delivery attempt, so a retried event carries a new timestamp and a new signature.
t from the X-DoDevWebhook-Signature header.t + "." + body.signing_secret as the key, hex-encoded.v1 using a constant-time comparison.t is more than about five minutes from the current time. This limits replay.import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the unparsed request body (a Buffer or string).
export function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const header = headers["x-dodevwebhook-signature"];
if (!header) return false;
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=", 2))
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Most web frameworks parse JSON before your handler runs and discard the raw bytes. You need the raw body for step 1:
express.raw({ type: "application/json" }) on the webhook route, or express.json({ verify: (req, res, buf) => { req.rawBody = buf } }).await request.text() gives you the raw body; parse it yourself after verifying.request.get_data() returns the raw bytes.await request.body().http:// URLs.signing_secret like any other credential, outside source control.id. Retries can deliver the same event twice.