Skip to content

The Dodo subscription lifecycle, which event means what and who keeps access

active, past_due, on_hold, paused, cancelled, failed, expired. Map each to access on purpose, re-read the subscription on every event, and let a grace period decide how failed renewals feel.

Dodo Payments3 min readships at docs/solutions/dodo/subscription-lifecycle-events.md

Tags: dodo · subscriptions · webhooks · lifecycle · dunning · entitlements

A subscription is a small state machine, and every provider names the states a little differently. This repo stores Dodo's word as provider_status and a shared word as status, which is what access reads:

Dodo statusWhat happenedStored asAccess
pendingcheckout started, first payment not settledincompleteno
activepaying, all normalactiveyes
active, inside trial_period_daysfree trialtrialingyes
past_duea renewal failed, grace period openpast_dueyes
on_holda renewal failed, no grace (or it ended)unpaidno
pausedsuspended on purposepausedno
cancelledendedcanceledno
failedthe first payment never went throughincompleteno
expiredthe term ran outexpiredno

An unknown word maps to incomplete, which grants nothing until someone maps it.

Failed renewals: grace period or not

When a renewal charge fails, Dodo moves the subscription to on_hold, or to past_due if you set a grace period (Settings -> Subscriptions -> Subscription Grace Period). Dodo's own description of the two is the rule this repo follows: past due keeps access until the window ends, on hold is loss of access.

That puts the policy in one place, Dodo's dashboard:

  • No grace period: the first declined renewal switches paid features off. Strict, and it costs you customers whose bank refused one charge.
  • A grace period of a week or two: they keep working while they fix the card, and most do.

Turn on Payment Retries too (Settings -> Recovery). They are off by default, so without them Dodo never charges a failed renewal again on its own: the customer has to update their card in the portal, which charges what is due and makes the subscription active.

The customer needs to hear about it. On payment.failed for a renewal whose subscription is now past_due or unpaid, the adapter emits a payment.failed billing event and the shared core mails a warning with a link to /billing. A first payment declined at checkout gets no warning: the buyer saw it on Dodo's page.

Trials

Dodo has no trial status. A trialing subscription is active with trial_period_days above zero, counted from created_at. The adapter works out the trial end and stores trialing until then. The trial length comes from the catalogue (trialDays in src/lib/pricing.ts), sent with each checkout, so a trial on the Dodo product itself is overridden.

Cancelling at the period end

A cancellation in Dodo's portal sets cancel_at_next_billing_date and leaves the subscription active until then. That arrives as subscription.updated, so subscribe to it. The row stores cancel_at_period_end = true, and the billing page says "Ends on" the next billing date instead of "Renews on". Always read that through scheduledEnd(subscription), never the flag alone.

Plan changes

subscription.plan_changed fires when the customer switches plans in the portal. The subscription now has a different product_id, while the checkout metadata still names the plan they first bought. The adapter names the plan from the product only (BILLING_PRICE_* maps it back to the catalogue), so a downgrade is never read as the old plan. A product no env var names is stored with no plan: shown as paid, unlocking no plan, and logged.

Re-read on every event

Every subscription.* handler throws the payload away and calls dodo.subscriptions.retrieve(id). Dodo retries a failed delivery for about a day, so events arrive out of order: a subscription.active from before a cancellation can land after it. Storing whatever Dodo says now makes the order irrelevant, and makes a resend from the dashboard (or bun run billing:reconcile) a safe way to repair anything.

Which events to subscribe

All of these, on both the test and the live endpoint:

subscription.active   subscription.updated    subscription.renewed
subscription.plan_changed   subscription.past_due   subscription.on_hold
subscription.paused   subscription.unpaused   subscription.cancelled
subscription.failed   subscription.expired

plus payment.succeeded and payment.failed for receipts and warnings. The list in code is HANDLED_EVENTS in src/lib/billing/dodo-events.ts. The easy ones to miss are subscription.updated (scheduled cancellations) and subscription.plan_changed (portal switches): without them the app keeps the old truth.