Skip to the documentation
SDK

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.

webhook-handler.ts
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.