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