A lifetime deal is the easiest thing to sell and the easiest to get wrong. The checkout is the same as a subscription's. The webhook is the same event. The difference lives in two fields, and reading the wrong one hands out a lifetime plan for a $19 monthly charge.
Dodo models it on the product
Dodo carries one price per product, and the price has a type:
recurring_price: a subscription product. Billed every month or year.one_time_price: a single payment product. Charged once.
So Pro monthly, Pro yearly and Pro lifetime are three products. The checkout does not say which kind it is selling; the product does:
await dodo.checkoutSessions.create({
product_cart: [{ product_id: "pdt_...", quantity: 1 }],
customer: { customer_id },
metadata: { userId, planSlug, priceId },
return_url: `${origin}/billing?checkout=success`,
cancel_url: `${origin}/pricing?checkout=cancelled`,
// Subscriptions only: the catalogue's trial, 0 to override the product's.
subscription_data: { trial_period_days: 7 },
});
Dodo copies metadata onto the payment and the subscription it creates, which
is how the webhook knows the user without guessing from an email.
One event, two meanings
payment.succeeded fires for a one-time purchase, for a subscription's first
payment, and for every renewal. What separates them is on the payment:
function isOneTimePayment(payment) {
const ids = [...(payment.subscription_ids ?? []), payment.subscription_id].filter(Boolean);
return !payment.is_update_payment_method && ids.length === 0;
}
Two traps in that function:
subscription_idnull is not enough. A payment that starts several subscriptions at once leavessubscription_idnull and lists them insubscription_ids. Dodo's own docs say to read the list.- A card change is a payment too. When a subscriber updates their card,
Dodo may take a zero charge with
is_update_payment_method: true. It is not a purchase and deserves no receipt.
Store them apart
A subscription is a row that changes: active, past due, cancelled. A one-time purchase is a row that mostly does not: paid, then maybe refunded. Mixing them in one table means every entitlement query needs a special case.
This repo keeps billing_subscriptions and billing_purchases separate. A
purchase is keyed on the Dodo payment id (Dodo has no separate order object)
and carries the product, amount, currency, the refunded amount and Dodo's
invoice URL. Access asks both:
import { hasPlan } from "@/lib/billing";
await hasPlan(user.id, "pro"); // a live subscription OR a paid lifetime purchase
A higher plan wins whichever way it was bought, so a Team subscriber who also owns Pro lifetime is on Team.
What to re-read and what to trust
For a subscription, always re-read it from Dodo before storing it. Retries run
for about a day, so an old subscription.active can land after the
subscription.cancelled that replaced it.
For a one-time payment, the signed payment.succeeded payload is the payment,
and it is safe to store as is, because the purchase upsert only moves status
forward (pending < failed < paid < partially_refunded < disputed < refunded).
A late "paid" can never undo a refund that was recorded first.
Refunds take access back
A refund or a dispute names the payment, not the product. Re-read the payment: it carries every refund and dispute so far, and the status follows from them.
| Payment state | Purchase status | Access |
|---|---|---|
| succeeded | paid | yes |
| part refunded | partially_refunded | yes |
| fully refunded | refunded | no |
| dispute open, accepted or lost | disputed | no |
A partial refund is usually a goodwill credit, not a cancellation, so it keeps
access. A dispute you later win stays disputed in this repo; fix that row by
hand.
Buying lifetime while subscribed
It happens: someone on Pro monthly buys Pro lifetime. The checkout allows it
(the purchase is worth more than the subscription), and /billing then warns
that the subscription is paying for nothing, with a button to cancel it in
Dodo's portal. Nothing is cancelled automatically: money decisions stay with
the customer.
Test both without a card
bun run dodo:test-webhook -- --user <id> signs a one-time
payment.succeeded with your webhook key, posts it to the running app, and
replays it. You should see processed, then duplicate, and the purchase on
/billing. Subscriptions need a real test-mode checkout, because every
subscription event is re-read from Dodo.