HMAC signatures

How to confirm the request really came from DroidSender and not from someone else.

HeaderWhat it holds
X-DroidSender-Eventthe event name, the same one in the body
X-DroidSender-Deliveryunique id for this delivery attempt; deduplicate by it
X-DroidSender-Signaturethe signature, described below
User-AgentDroidSender-Webhooks/2.0

Every request carries the X-DroidSender-Signature header in the form t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" using your webhook's secret.

const crypto = require("crypto");

function signatureIsValid(rawBody, header, secret) {
  const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
  if (!match) return false;

  const [, t, received] = match;

  // the timestamp is inside what is signed: an intercepted delivery
  // cannot be replayed tomorrow, and t cannot be rewritten without the secret
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (age > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  // constant-time comparison: a plain === lets the secret be measured
  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(received, "hex"),
  );
}

Reject anything older than five minutes

Without the age check a replayed request still validates. Five minutes is the recommended window.

Use the raw body

The signature is computed over the JSON exactly as it arrived. If you verify it after parsing it into an object and serialising it again, the whitespace changes and the signature never matches.