Webhooks
How Dodo tells your app about payments, which events the kit handles, and the rules that keep billing correct.
When something happens in Dodo (a payment succeeds, a subscription renews or is cancelled, a refund goes through), Dodo sends a signed POST request to:
https://yourdomain.com/api/webhooks/dodoThe handler is in src/app/api/webhooks/dodo/route.ts, and the logic in src/lib/billing/dodo.ts.
Registering it
- With setup: registered automatically when you deploy, and the secret is saved to Vercel as
DODO_PAYMENTS_WEBHOOK_SECRET. - By hand: in Dodo, Developer → Webhooks → Add endpoint, enter the URL, select all events (or at least the ones below), click Create endpoint, and copy the signing secret from the endpoint's Overview tab into
DODO_PAYMENTS_WEBHOOK_SECRET.
Test mode and live mode have separate webhook endpoints and secrets. When you switch to live, register the endpoint again in live mode.
Events handled
| Event | What the app does |
|---|---|
subscription.* (active, renewed, on_hold, cancelled, expired, plan_changed…) | Creates or updates the user's row in subscription: status, product, next billing date, cancel-at-period-end, price |
payment.succeeded | Adds a row to payment, the ledger of every successful charge. A one-time payment grants access |
refund.succeeded (full refunds only) | Marks the payment as refunded, which removes access from a one-time purchase |
Other events are accepted and ignored. Zero-amount charges that only update a card aren't recorded as revenue.
After each event that changes a user's billing, repository access is brought in line (invite after payment, removal after a full refund), if you use it.
Responses
| Status | Meaning | Dodo's reaction |
|---|---|---|
200 {"received":true} | Processed (or ignored) | Done |
401 {"error":"Invalid signature"} | The signature didn't match DODO_PAYMENTS_WEBHOOK_SECRET | Retries later |
500 | Something failed while saving, such as the database being unreachable | Retries later |
The rules
These are why billing stays correct even when things go wrong:
- Every request is verified. The signature is checked with the request's headers and your secret. The Dodo SDK silently skips verification when headers aren't passed, so
verifyWebhook()always passes them. - Safe to receive twice. Dodo retries, so the same event can arrive more than once. Payments are inserted once by their ID, and subscriptions are updated in place.
- Old events can't overwrite new ones. Events can arrive out of order. A subscription event older than the stored one (by its timestamp) is ignored.
- Users are linked through
billing_customer, which is created on your server at checkout. Metadata from the browser is never trusted.
Seeing what was delivered
In Dodo, open Developer → Webhooks, select the endpoint, and check its delivery logs. Each attempt shows the status code your app returned, and you can resend an event from there.
On Vercel, check Logs for lines starting with [dodo]. For example, [dodo] payment.succeeded: unknown customer cus_… means the payment's customer isn't linked to any user in this database. See Unknown customer.