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 Squeezy | What happened | Normalized | Access? |
|---|---|---|---|
on_trial | Free trial running, trial_ends_at set | trialing | Yes |
active | Paid and current | active | Yes |
past_due | A renewal failed; Lemon Squeezy retries 4 times over about 2 weeks | past_due | Yes |
unpaid | All retries failed | unpaid | No |
paused, mode free | Collection paused, service continues | active | Yes |
paused, mode void | Collection and service paused | paused | No |
cancelled, ends_at ahead | Will not renew, still inside the paid period | active + scheduled end | Yes, until ends_at |
cancelled, ends_at passed | The paid period ran out | canceled | No |
expired | Over: a cancellation ran out, or dunning closed an unpaid one | expired | No |
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
| Event | Typical change |
|---|---|
subscription_created | new subscription, on_trial or active |
subscription_updated | any change; fires beside most of the others |
subscription_payment_success | a charge went through; past_due back to active |
subscription_payment_failed | active to past_due |
subscription_payment_recovered | past_due or unpaid back to active |
subscription_cancelled | to cancelled, ends_at set |
subscription_resumed | cancelled back to active |
subscription_paused / subscription_unpaused | to or from paused |
subscription_expired | to 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.