---
title: "Verifying a delivery"
description: "`verifyWebhookSignature`: constant-time, with a replay window, and the parsed event back."
url: "https://openemail.uk/docs/sdk/webhooks/verify"
area: "SDK"
category: "Webhooks"
---

# 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.
