Every webhook POST includes X-Sendmux-Signature in the form sha256=<hex>. Verify it before you process the event.
What to sign
- Use the raw request body bytes.
- Use the webhook signing secret returned when the subscription was created or rotated.
- Compute
HMAC-SHA256 and compare the full sha256=<hex> value.
- Compare in constant time.
Do not parse and re-serialise the JSON before verification. That can change
whitespace or field order and break the signature check.
Example verifier
const encoder = new TextEncoder();
function toHex(buffer) {
return [...new Uint8Array(buffer)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function constantTimeEqual(a, b) {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i += 1) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
export async function verifySendmuxWebhook(request, secret) {
const rawBody = await request.text();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(rawBody));
const expected = `sha256=${toHex(digest)}`;
const received = request.headers.get("X-Sendmux-Signature") ?? "";
if (!constantTimeEqual(received, expected)) {
return { ok: false };
}
return {
ok: true,
body: JSON.parse(rawBody),
eventId: request.headers.get("X-Sendmux-Event-Id"),
eventType: request.headers.get("X-Sendmux-Event-Type"),
};
}
Common mistakes
- Reading parsed JSON instead of the raw body.
- Comparing without the
sha256= prefix.
- Using a normal string comparison for secrets.
- Processing retries without deduping on
X-Sendmux-Event-Id.
Endpoint behaviour
Return a 2xx status once the event is accepted. For longer processing, store the event first, return quickly, and process it after the response.
Sendmux retries non-2xx responses and timeouts. See Webhooks setup.