Skip to content

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.

Dodo Payments3 min readships at docs/solutions/dodo/standard-webhooks-signature-verification.md

Tags: dodo · webhooks · standard-webhooks · hmac · security · signature

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, 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

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

// 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

// 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:

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:

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