Browse the docs
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#
X-Inboundr-Signature: t=1721471400,v1=6a2f…e19bt: 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#
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);
}import hmac, hashlib
def verify(raw_body: str, header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
expected = hmac.new(
secret.encode(), f"{parts['t']}.{raw_body}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))<?php
function verify(string $rawBody, string $header, string $secret): bool {
parse_str(str_replace(",", "&", $header), $parts);
$expected = hash_hmac("sha256", "{$parts['t']}.{$rawBody}", $secret);
return hash_equals($expected, $parts["v1"] ?? "");
}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):
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" }), thenJSON.parseafter verifying. - Next.js route handlers:
await req.text(), verify, thenJSON.parse. - Fastify: enable
rawBodyand hash that. - PHP: read
file_get_contents("php://input"), verify, thenjson_decode.