Webhooks

Verifying signatures

Verify Inboundr webhook signatures: check the HMAC-SHA256 signature on each delivery in TypeScript, Python or PHP before trusting the payload.

Every delivery includes an X-Inboundr-Signature header. Verify it with your endpoint's secret, the whsec_… value shown once when you created the endpoint, before you trust the payload.

Deliveries still include the original X-Inbound-Signature header (without the r) with the same value, so handlers written before the rename keep working. Read X-Inboundr-Signature in new code, or use the inboundr npm package, whose readSignatureHeader accepts either.

Signature format#

Header
X-Inboundr-Signature: t=1721471400,v1=6a2f…e19b
  • t: the Unix timestamp when the signature was created.
  • v1: HMAC-SHA256(secret, "{t}.{rawBody}"), hex-encoded.

The signed message is the timestamp, a literal ., then the raw request body. Compute the HMAC over the exact bytes you received; don't re-serialize the JSON first.

Verify it#

TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=")),
  );
  const t = parts.t;
  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}

Use a constant-time comparison (timingSafeEqual / compare_digest / hash_equals), not ==, to avoid timing attacks.

Reject stale deliveries#

To defend against replay, reject deliveries whose timestamp t is too far from now (for example, more than 5 minutes):

ts
const ageSeconds = Math.abs(Date.now() / 1000 - Number(t));
if (ageSeconds > 300) return false;

Reading the raw body#

Frameworks that parse JSON automatically can change the bytes, so read the raw body first:

  • Express: use express.raw({ type: "application/json" }), then JSON.parse after verifying.
  • Next.js route handlers: await req.text(), verify, then JSON.parse.
  • Fastify: enable rawBody and hash that.
  • PHP: read file_get_contents("php://input"), verify, then json_decode.