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 '@nextmail/sdk' export async function POST(request: Request) { try { const event = await verifyWebhookSignature({ payload: await request.text(), headers: request.headers, secret: process.env.NEXTMAIL_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.
import { verifyWebhookSignature, type WebhookEventPayload } from '@nextmail/sdk' const event = (await verifyWebhookSignature({ payload, headers, secret })) as WebhookEventPayload switch (event.type) { case 'email.received': console.log(event.data.from, event.data.subject, event.data.attachments.length) break case 'email.bounced': console.log(event.data.recipients) break}WebhookEventPayload is the body as a union of every event, so comparing type narrows data to that event, and WebhookEventData<'email.clicked'> names the data of one event. Every shape is listed on the webhook events page of the API docs.
Google Workspace