Skip to content

One-time payments and subscriptions on Dodo Payments, side by side

Dodo decides one-time or recurring from the product, and payment.succeeded fires for both. Tell them apart by the payment's subscriptions, store purchases separately, and let a refund take access back.

Dodo Payments3 min readships at docs/solutions/dodo/one-time-payments-with-dodo.md

Tags: dodo · one-time · lifetime · subscriptions · checkout · webhooks · entitlements

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_id null is not enough. A payment that starts several subscriptions at once leaves subscription_id null and lists them in subscription_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 statePurchase statusAccess
succeededpaidyes
part refundedpartially_refundedyes
fully refundedrefundedno
dispute open, accepted or lostdisputedno

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.