# Verifying Standard Webhooks signatures, the three headers and the two mistakes

> Dodo signs id.timestamp.body with HMAC-SHA256. Verify the raw bytes, pass all three headers, and never write the comparison yourself.

Your webhook endpoint is a public URL that grants plans. There is no session,
no API key, no IP allowlist you can rely on. The signature is the whole
authentication story, and there are two ways people break it.

Dodo implements [Standard Webhooks](https://www.standardwebhooks.com), an open
spec several providers share. Learn it once and it pays off more than once.

## What is signed

Three headers arrive with every delivery:

```
webhook-id: msg_2c8ZaG3bQq1sD9
webhook-timestamp: 1730812345
webhook-signature: v1,K5oZ3f...base64...=
```

The signed string is the three parts joined by dots:

```
${webhook-id}.${webhook-timestamp}.${raw body}
```

HMAC-SHA256 with the endpoint's signing key (base64 after the `whsec_`
prefix), base64 encoded, prefixed with the version. The header can hold several
space-separated signatures, and a delivery is valid if any one matches.

Each covered part is doing a job:

- **The body is covered**, so nobody can change what you are told.
- **The id is covered**, so a valid signature cannot be moved to another
  event, and the id doubles as your idempotency key.
- **The timestamp is covered and checked**, so a captured delivery is useless
  a few minutes later. The library rejects anything more than five minutes
  off.

## Mistake one: verifying a re-serialised body

```ts
// Wrong. Fails for every event.
const body = await request.json();
verifier.verify(JSON.stringify(body), headers);
```

`JSON.parse` then `JSON.stringify` is not the identity function. Key order can
change, `1.0` becomes `1`, escapes normalise. The HMAC covers bytes, and those
are different bytes.

```ts
// Right. The exact bytes Dodo signed.
const payload = await request.text();
const event = verifier.verify(payload, headers);
```

A Next.js route handler receives an unparsed `Request`, so there is no body
parser to switch off. In an Express-style server you would need
`express.raw({ type: "application/json" })` on this route only.

## Mistake two: signing only the body

```ts
// Dangerous. Verifies, and is replayable forever.
const expected = createHmac("sha256", secret).update(payload).digest("base64");
if (expected === signature) { /* ... */ }
```

That accepts a delivery captured from your logs or a proxy, replayed any number
of times, days later. It also compares with `===`, which stops at the first
different byte and leaks timing. Use the library, which checks the timestamp
window, the multi-signature header and compares in constant time:

```ts
import { Webhook } from "standardwebhooks";

const event = new Webhook(process.env.DODO_PAYMENTS_WEBHOOK_KEY).verify(payload, {
  "webhook-id": id,
  "webhook-timestamp": timestamp,
  "webhook-signature": signature,
});
```

In this repo that lives in `verifyDodoWebhook` in
`src/lib/billing/dodo-events.ts`, which the adapter's `verifyWebhook` calls.
It uses `standardwebhooks` directly rather than the SDK's `unwrap`, because the
SDK needs an API key just to build the client that verifies.

## Status codes

The shared pipeline answers:

- **400** when the signature, a header or the timestamp is wrong. A retry
  would fail the same way.
- **500** when the signing key is unset or still the `.env.example`
  placeholder. Dodo keeps retrying until you set it.
- **200** once the event is processed, a duplicate, or a type you ignore.
- **500** for your own failures, where a retry can help.

Never answer 200 on a failed verification. It hides an attack and your own
misconfiguration behind a green delivery log.

## Testing it

Three requests that must fail, and one that must pass:

```bash
# No signature: 400
curl -i -X POST localhost:3000/api/webhooks/dodo -d '{"type":"payment.succeeded"}'

# Made-up signature: 400
curl -i -X POST localhost:3000/api/webhooks/dodo \
  -H 'webhook-id: msg_x' -H "webhook-timestamp: $(date +%s)" \
  -H 'webhook-signature: v1,bm90LXJlYWw=' \
  -d '{"type":"payment.succeeded","data":{}}'

# A real signature, replayed ten minutes later: 400

# A fresh signature from your own key: 200
bun run dodo:test-webhook -- --user <user id>
```

The replay is the one people skip, and it is the one that proves the timestamp
is checked. The unit tests in `dodo-events.test.ts` cover all four.

## Mock events from the Dodo CLI

`dodo wh trigger` sends mock payloads with **no signature headers**. This app
answers them 400, which is correct. Do not add an "unsafe" path to accept them;
use `dodo wh listen`, which forwards real, signed test events, or the fixture
script above.

## Rotating the key

Each endpoint has its own key. To rotate without dropping events, create the
new endpoint (or key) first, deploy the new value, then retire the old one.
Anything that failed in between can be resent from the endpoint's delivery log.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
