Webhook Security

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.

Headers

HeaderExampleDescription
X-DoDevWebhook-Idevt_9c2a1d7b8e4c0f9a1b23f3The event id. Same as id in the body.
X-DoDevWebhook-Timestamp1757000001Unix time in seconds when this delivery attempt was made.
X-DoDevWebhook-Signaturet=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.

Computing the signature

  1. Take the raw request body exactly as received. Do not parse and re-serialize it; whitespace and key order matter.
  2. Take t from the X-DoDevWebhook-Signature header.
  3. Build the string t + "." + body.
  4. Compute HMAC-SHA256 of that string with your signing_secret as the key, hex-encoded.
  5. Compare it to v1 using a constant-time comparison.
  6. Reject the delivery if t is more than about five minutes from the current time. This limits replay.
Verify a signature
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);
}

Framework notes

Most web frameworks parse JSON before your handler runs and discard the raw bytes. You need the raw body for step 1:

  • Express: use express.raw({ type: "application/json" }) on the webhook route, or express.json({ verify: (req, res, buf) => { req.rawBody = buf } }).
  • Next.js route handlers: await request.text() gives you the raw body; parse it yourself after verifying.
  • Flask: request.get_data() returns the raw bytes.
  • FastAPI: await request.body().

Other precautions

  • Serve the endpoint over HTTPS only; send.dev refuses to register http:// URLs.
  • Store the signing_secret like any other credential, outside source control.
  • Treat the payload as untrusted input even after the signature checks out. Look the email up with Get an email if you need authoritative state rather than acting on the payload alone.
  • De-duplicate on the event id. Retries can deliver the same event twice.
  • If the secret is compromised, create a new endpoint (which gets a new secret), switch your handler to it, and delete the old endpoint in the dashboard.