# 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.

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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
