Skip to content

The Lemon Squeezy subscription state machine, and who keeps access

on_trial, active, past_due, unpaid, paused, cancelled, expired. Which unlock your product, how to normalize them, and the two states people get wrong.

Lemon Squeezy3 min readships at docs/solutions/lemonsqueezy/subscription-states-and-access.md

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

A Lemon Squeezy subscription has seven statuses. Your app has one question: does this person get the product right now? The mapping is not "active means yes".

The table

Lemon SqueezyWhat happenedNormalizedAccess?
on_trialFree trial running, trial_ends_at settrialingYes
activePaid and currentactiveYes
past_dueA renewal failed; Lemon Squeezy retries 4 times over about 2 weekspast_dueYes
unpaidAll retries failedunpaidNo
paused, mode freeCollection paused, service continuesactiveYes
paused, mode voidCollection and service pausedpausedNo
cancelled, ends_at aheadWill not renew, still inside the paid periodactive + scheduled endYes, until ends_at
cancelled, ends_at passedThe paid period ran outcanceledNo
expiredOver: a cancellation ran out, or dunning closed an unpaid oneexpiredNo

Store both words: the normalized one your access rule reads, and Lemon Squeezy's own for support and for statuses nobody has mapped yet (map an unknown one to something that grants nothing). Decide access in one function from the normalized status. A billing layer that serves several providers then needs no Lemon Squeezy branch anywhere else.

past_due is a customer who wants to pay

Cards expire. Banks decline foreign charges at 3am. Many failed renewals succeed on a retry with no action from the customer. Locking people out on the first decline turns a bank hiccup into churn.

Keep access during past_due. Tell them the card failed: renews_at on a past-due subscription is the next retry, which is the date the warning should name. Cut access at unpaid. What happens after that is a store setting: cancel after a set period, or leave it unpaid so the customer can reactivate.

cancelled is not over

When someone cancels, Lemon Squeezy sets status: "cancelled", cancelled: true, and ends_at to the end of the period they paid for. Revoking access at cancellation is a refund you did not give them.

Normalize it to active with a scheduled end (cancelAt = ends_at), and show "Ends on June 1" instead of "Renews on". They can resume before ends_at: subscription_resumed fires and the status goes back to active. If you deleted their data at cancellation, that resume is a support ticket.

When ends_at passes, subscription_expired fires. If your webhook missed it (three retries is a short fuse), a nightly reconcile that re-reads every subscription closes the gap.

Which events move what

EventTypical change
subscription_creatednew subscription, on_trial or active
subscription_updatedany change; fires beside most of the others
subscription_payment_successa charge went through; past_due back to active
subscription_payment_failedactive to past_due
subscription_payment_recoveredpast_due or unpaid back to active
subscription_cancelledto cancelled, ends_at set
subscription_resumedcancelled back to active
subscription_paused / subscription_unpausedto or from paused
subscription_expiredto expired

Do not write the status from the payload. Deliveries arrive out of order and subscription_updated often lands next to the specific event for the same change. On every subscription event, fetch the subscription with getSubscription(id) and store what the API says now. Order stops mattering.

Plan changes

Upgrades and downgrades happen in the customer portal or through updateSubscription(id, { variantId }). The subscription keeps its id; its variant_id changes and subscription_updated fires. Name the plan from the current variant on every sync, never from the checkout's custom data, which still names the first plan the customer bought. A variant you cannot map reads as "paid, plan unknown" and unlocks no plan, never as the old plan.