Verify signatures
Reject any callback whose Sendtier-Signature does not match your secret.
Read the raw body bytes before parsing JSON. The header is t=<Unix seconds>,v1=<hex HMAC-SHA256>. Sign the timestamp, a period and the exact body, using the whole secret string including whsec_ as the key. Reject timestamps more than 5 minutes off. Do not base64-decode the secret. Reject the request unless the verifier returns true.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, body, now = Date.now() / 1000) {
const fields = {};
const signatures = [];
for (const part of header.split(",")) {
const field = part.trim();
const split = field.indexOf("=");
const key = field.slice(0, split);
if (split < 0 || !["t", "v1"].includes(key)) continue;
const value = field.slice(split + 1);
if (key === "v1") { signatures.push(value); continue; }
if (Object.hasOwn(fields, key)) return false;
fields[key] = value;
}
if (!/^[+-]?\d+$/.test(fields.t ?? "") || signatures.length === 0) return false;
if (signatures.some(value => !/^[a-f\d]{64}$/i.test(value))) return false;
const seconds = BigInt(fields.t);
if (seconds < -(2n ** 63n) || seconds > 2n ** 63n - 1n || Math.abs(now - Number(seconds)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${fields.t}.`).update(body).digest();
let matched = false;
for (const value of signatures) {
matched = timingSafeEqual(Buffer.from(value, "hex"), expected) || matched;
}
return matched;
}Test it locally
Configure SENDTIER_WEBHOOK_SECRET with your test endpoint's secret and WEBHOOK_URL with its HTTPS URL.
body='{"id":"evt_example","type":"email.sent","created_at":"2026-10-06T12:00:00Z","test_mode":true,"data":{"email_id":"em_example"}}'
timestamp=$(date +%s)
signature=$(printf '%s.%s' "$timestamp" "$body" | openssl dgst -sha256 -hmac "$SENDTIER_WEBHOOK_SECRET" | awk '{print $NF}')
curl "$WEBHOOK_URL" -H 'Content-Type: application/json' \
-H "Sendtier-Signature: t=$timestamp,v1=$signature" --data-binary "$body"Rotate the secret
POST /v1/webhooks/{id}/rotate-secret returns the new secret once. For 24 hours each delivery carries two v1 signatures, one per secret; the code above accepts either. Deploy it before you rotate, then update your configured secret during the overlap. After 24 hours, only the new secret works.
Idempotent replays omit the secret and do not rotate again. Another rotation during the overlap returns 400 invalid_request.