Verifying a delivery
`verifyWebhookSignature`: constant-time, with a replay window, and the parsed event back.
In a request handler
A webhook URL is public. Anything on the internet can POST the right-shaped JSON at it, so a handler that reads payload.type without checking the signature is an open write API.
import { verifyWebhookSignature } from '@openemail/sdk' export async function POST(request: Request) { try { const event = await verifyWebhookSignature({ payload: await request.text(), headers: request.headers, secret: process.env.OPENEMAIL_WEBHOOK_SECRET!, toleranceSeconds: 300, }) console.log(event.type, event.data) } catch { return new Response('bad signature', { status: 400 }) } return new Response(null, { status: 204 })}Pass the RAW body. Parsing and re-serialising changes key order and whitespace, and the signature will not match. headers takes a Headers object or a plain object such as Node’s req.headers, and X-OpenEmail-Signature is found whatever its case.
Two things this handles that a hand-rolled check usually does not: it compares the MAC in constant time, so the correct prefix cannot be recovered by timing it, and it rejects a delivery more than toleranceSeconds old in either direction, five minutes unless you say otherwise, so a captured request is not replayable for ever. toleranceSeconds: 0 turns the replay check off. Both bugs are silent. A handler with either one passes every test you would think to write.
It throws on every failure: a missing X-OpenEmail-Signature header, one not in the form t=<seconds>,v1=<hex>, a timestamp outside the window, or a signature that does not match. On success it resolves to the body parsed as a WebhookPayload, so there is no second JSON.parse to get wrong. Pass a type argument such as EmailOpenedData to type data.
It needs globalThis.crypto.subtle, which Node 20+, Bun, Deno and Cloudflare Workers all provide.