How to handle Dodo Payments webhooks in Next.js 16
Verify the signature, store payments in your own Postgres, survive retries and out-of-order events, and check access without calling Dodo.
Short answer: read the raw request body, verify it with the Dodo SDK's webhooks.unwrap() while passing the request headers, and write the event into your own database with idempotent queries. Return 401 for a bad signature and let real errors return 500, so Dodo retries them. Then decide who has access by reading your own tables, never by calling Dodo on a page request.
This is how the webhook handler in Launchfast works. The code below is trimmed from the kit.
1. The route: raw body in, verified event out
Webhook signatures are computed over the exact bytes Dodo sent. Read the body as text and parse it only after verifying it. If you call request.json() first, you verify a re-serialised body that may not match.
// src/app/api/webhooks/dodo/route.ts
export async function POST(request: Request) {
const body = await request.text();
let event;
try {
event = verifyWebhook(body, request.headers);
} catch (error) {
if (error instanceof InvalidWebhookError) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
throw error;
}
// Errors here return 500, so Dodo retries the event later.
await handleWebhookEvent(event);
return Response.json({ received: true });
}
Only a bad signature gets a 4xx. A database outage should throw and produce a 500, because a 200 tells Dodo the event was handled and it won't send it again.
2. Always pass the headers
export function verifyWebhook(body: string, headers: Headers) {
const secret = requireEnv("DODO_PAYMENTS_WEBHOOK_SECRET");
try {
return dodo().webhooks.unwrap(body, { headers: Object.fromEntries(headers), key: secret });
} catch {
throw new InvalidWebhookError("Invalid webhook signature.");
}
}
The signature lives in the webhook-id, webhook-timestamp and webhook-signature headers. In the SDK version Launchfast uses, unwrap() skips verification when the headers are left out, so a handler that forgets them accepts forged events without any error. Keep a test that sends a request with a wrong signature and expects 401.
3. Make every write safe to repeat
Dodo retries events that didn't get a 2xx, so the same event can arrive twice. Each write has to give the same result the second time.
Payments are a ledger keyed by Dodo's payment ID. A duplicate insert does nothing:
await db
.insert(payment)
.values({ userId, provider: "dodo", providerPaymentId: charge.payment_id, amount: charge.total_amount /* … */ })
.onConflictDoNothing();
Two details matter here:
- Skip card updates. A
payment.succeededevent withis_update_payment_methodset is a zero-amount charge that only saves a new card. It isn't revenue. - Store money as integers in the currency's smallest unit (cents, paise). Format it only when you display it.
4. Ignore subscription events that arrive late
Events aren't guaranteed to arrive in order. If subscription.cancelled is processed before an older subscription.active that was retried, a naive upsert re-activates a cancelled subscription.
Store the event's timestamp and only apply an update that is newer:
const eventTime = new Date(event.timestamp);
await db
.insert(subscription)
.values({ userId, providerSubscriptionId: sub.subscription_id, status: sub.status, providerUpdatedAt: eventTime /* … */ })
.onConflictDoUpdate({
target: subscription.providerSubscriptionId,
set: { status: sub.status, providerUpdatedAt: eventTime /* … */ },
setWhere: lt(subscription.providerUpdatedAt, eventTime),
});
The setWhere makes Postgres do the comparison inside the same statement, so two deliveries running at once can't race each other.
5. Link payments to users on the server
The webhook tells you a Dodo customer ID, not your user. Create the Dodo customer yourself when the user starts checkout, and save the pair in a billing_customer table:
| Your table | Holds |
|---|---|
billing_customer | Your user ID and their Dodo customer ID, written server side at checkout |
payment | Every successful charge, keyed by Dodo's payment ID |
subscription | The latest known state of each subscription, plus providerUpdatedAt |
When an event arrives, look the user up by customer ID. Don't trust a user ID sent from the browser or copied into checkout metadata: it is only as trustworthy as the request that set it.
6. Decide access from your own tables
Once webhooks keep these tables in sync, the access rule is two small queries:
- A paid
one_timerow inpaymentgrants access. - Otherwise, the latest
subscriptionrow must havestatus = 'active'.
Pages call one function, getBillingStatus(userId), which reads only Postgres. Page loads stay fast, they don't fail when the payment provider has an outage, and the rule doesn't change if you add a second provider later.
Checklist
- Read the body with
request.text()and verify before parsing. - Pass the headers to
unwrap(), and test that a forged event gets401. - Return
500on real errors so Dodo retries. - Make inserts idempotent on the provider's IDs.
- Compare event timestamps before updating a subscription.
- Map Dodo customers to users through a table you write at checkout.
- Read access from your database, not from the provider.
Launchfast ships this handler, its tests and the admin revenue dashboard that reads the same tables. See what's inside.