Subscription lifecycle
The unified status model, scheduled cancellations, entitlement checks, every lifecycle operation, and the per-provider proration matrix.
Subscriptions are where billing providers disagree most: each has its own status vocabulary, and most
of them overload one status to mean both “canceled, access ended” and “canceled, but still paid up
until the end of the period”. revenue-sdk normalizes all of it into one seven-value status enum plus
one boolean.
The unified status model
type SubscriptionStatus =
| 'incomplete'
| 'trialing'
| 'active'
| 'past_due'
| 'unpaid'
| 'paused'
| 'canceled';
| Status | Meaning |
|---|---|
incomplete |
Created but the first payment has not succeeded yet. No access. |
trialing |
In a trial. Access granted. |
active |
Paid and current. Access granted. |
past_due |
A renewal payment failed; the provider is retrying. Grace period. |
unpaid |
Retries exhausted, the subscription still exists. No access. |
paused |
Deliberately suspended by the merchant or customer. No access. |
canceled |
Terminal. The subscription has ended and will not renew. |
Per-provider mapping
| Unified | Polar | Lemon Squeezy | Stripe | Paddle | Dodo Payments |
|---|---|---|---|---|---|
incomplete |
incomplete |
— | incomplete |
— | pending |
trialing |
trialing |
on_trial |
trialing |
trialing |
— |
active |
active |
active, cancelled¹ |
active |
active |
active |
past_due |
past_due |
past_due |
past_due |
past_due |
on_hold |
unpaid |
unpaid |
unpaid |
unpaid |
— | — |
paused |
paused |
paused |
paused |
paused |
— |
canceled |
canceled |
expired |
canceled, incomplete_expired |
canceled |
cancelled, failed, expired |
cancelled means “grace period until ends_at, still resumable” — it maps to
active with cancelAtPeriodEnd: true.
A dash means the provider has no equivalent state. Unrecognized provider statuses fall back to active
so a newly introduced status never breaks an entitlement check silently — inspect raw if you need the
exact provider value.
canceled is terminal — cancelAtPeriodEnd is the schedule
This is the decision that makes the model work: a scheduled cancellation is not a status. When a
customer cancels, the subscription keeps its current status (active or trialing) and gains:
cancelAtPeriodEnd: trueendsAt— the date access actually ends
Only when that date passes does the status become canceled. So:
subscription.status === 'canceled'; // access has ended, permanently
subscription.cancelAtPeriodEnd; // will not renew, but is still paid up
How cancelAtPeriodEnd is detected
| Provider | Detected from |
|---|---|
| Polar | cancel_at_period_end |
| Lemon Squeezy | status cancelled |
| Stripe | cancel_at_period_end || cancel_at !== null |
| Paddle | scheduled_change?.action === 'cancel' |
| Dodo Payments | cancel_at_next_billing_date |
Once a subscription is terminally canceled, cancelAtPeriodEnd is forced back to false — there is
nothing left to schedule.
A lifecycle timeline
Subscribe
A completed checkout creates the subscription.
// status: 'active' (or 'trialing' when the price has a trial)
// cancelAtPeriodEnd: false
// currentPeriodEnd: 2026-09-01Cancel
The customer cancels. Access is retained until the period ends.
const subscription = await client.subscriptions.cancel({ id });
// status: 'active' ← unchanged
// cancelAtPeriodEnd: true
// endsAt: 2026-09-01Uncancel
They change their mind before the period ends.
const subscription = await client.subscriptions.uncancel({ id });
// status: 'active'
// cancelAtPeriodEnd: false
// endsAt: undefinedPeriod end
If it was left canceled, the provider terminates it and sends a subscription.canceled webhook.
// status: 'canceled' ← terminal
// cancelAtPeriodEnd: false
// endedAt: 2026-09-01Checking entitlement
The whole point of the model is that entitlement is a two-line check that works on every provider:
import type { Subscription } from 'revenue-sdk';
const ENTITLED: ReadonlySet<Subscription['status']> = new Set(['active', 'trialing']);
export function isEntitled(subscription: Subscription): boolean {
return ENTITLED.has(subscription.status);
}
Note what is not in the check: cancelAtPeriodEnd is irrelevant to access. A subscription scheduled
to cancel is still paid for and still entitled until it flips to canceled.
Optional past_due grace
past_due means a renewal failed and the provider is retrying. Whether you keep access on during the
dunning window is a product decision — most SaaS products do, to avoid punishing an expired card:
const GRACE: ReadonlySet<Subscription['status']> = new Set(['active', 'trialing', 'past_due']);
export function isEntitled(subscription: Subscription, allowGrace = true): boolean {
return (allowGrace ? GRACE : ENTITLED).has(subscription.status);
}
Do not extend the grace to unpaid (retries exhausted) or paused (deliberately suspended).
Surfacing the end date
if (subscription.cancelAtPeriodEnd && subscription.endsAt) {
banner(`Your plan ends on ${subscription.endsAt.toLocaleDateString()}.`);
}
endsAt is “when access ends” — the effective date of a scheduled cancellation, or the end of a grace
period. endedAt is “when it actually terminated”, and is only set once the subscription is over.
Lifecycle operations
Cancel at period end
const subscription = await client.subscriptions.cancel({
id: 'SUBSCRIPTION_ID',
reason: 'too_expensive', // requires the cancellationReason capability
comment: 'Moving to the annual plan elsewhere',
});
reason is one of customer_service, low_quality, missing_features, other,
switched_service, too_complex, too_expensive, unused. Passing reason or comment to a
provider without the cancellationReason capability (Lemon Squeezy, Paddle) throws
unsupported — omit them for provider-agnostic code.
Revert a scheduled cancellation
const subscription = await client.subscriptions.uncancel({ id: 'SUBSCRIPTION_ID' });
Supported by all five providers. It only reverts a scheduled cancellation — a terminally canceled
subscription cannot be resurrected; create a new checkout instead.
Change plan
const subscription = await client.subscriptions.changePlan({
id: 'SUBSCRIPTION_ID',
product: yearlyPrice.checkoutRef,
quantity: 1,
prorationBehavior: 'prorate',
});
product is a Price.checkoutRef, not a Product.id. Polar and
Lemon Squeezy reject a quantity other than 1.
End a trial early
const subscription = await client.subscriptions.endTrial({ id: 'SUBSCRIPTION_ID' });
Bills the customer immediately and starts the paid cycle. Dodo Payments has no such operation and
throws unsupported.
Revoke immediately
const subscription = await client.subscriptions.revoke({ id: 'SUBSCRIPTION_ID' });
// status: 'canceled', access ends now
Terminates the subscription right away, with no remaining access. Lemon Squeezy cannot do this and
throws unsupported — its cancel always runs to the end of the period.
Proration behaviors
prorationBehavior controls what happens to the money when a plan changes mid-period:
| Behavior | Meaning |
|---|---|
prorate |
Credit the unused time and defer the difference to the next invoice. |
invoice_now |
Credit the unused time and invoice the difference immediately. |
none |
Switch now, bill nothing extra. |
Support and wire mapping per provider:
| Behavior | Polar | Lemon Squeezy | Stripe | Paddle | Dodo Payments |
|---|---|---|---|---|---|
prorate |
prorate |
provider default | create_prorations |
prorated_next_billing_period |
unsupported |
invoice_now |
invoice |
invoice_immediately |
always_invoice |
prorated_immediately |
prorated_immediately |
none |
unsupported | disable_prorations |
none |
do_not_bill |
do_not_bill |
| omitted | provider default | provider default | provider default | behaves as prorate |
behaves as invoice_now |
Polar has no equivalent of none: its next_period mode defers the plan change itself and reset
restarts the billing anchor — neither matches “switch now, bill nothing extra”, so the SDK refuses
rather than pick a lookalike. Dodo Payments has no defer-to-next-invoice mode, so prorate is refused.
The client checks prorationBehavior against capabilities.prorationBehaviors before the request:
if (client.capabilities.prorationBehaviors.includes('none')) {
await client.subscriptions.changePlan({ id, product, prorationBehavior: 'none' });
}
The Subscription model
id?string
Provider subscription identifier.
stringstatus?SubscriptionStatus
The unified status. canceled is terminal.
SubscriptionStatuscancelAtPeriodEnd?boolean
A cancellation is scheduled; access continues until endsAt.
booleancustomerId?string
The provider customer that owns the subscription.
stringproductId?string
The subscribed product, when the provider exposes it.
stringpriceId?string
The subscribed price, when the provider exposes it.
stringquantity?number
Subscribed quantity, when applicable.
numbercurrency?string
Lowercase ISO 4217 code.
stringamount?number
Amount per billing interval, in minor units.
numberinterval?'day' | 'week' | 'month' | 'year'
Billing interval.
'day' | 'week' | 'month' | 'year'intervalCount?number
Intervals per billing cycle.
numbercurrentPeriodStart?Date
Start of the current billing period.
DatecurrentPeriodEnd?Date
End of the current billing period / next renewal.
DatetrialEndsAt?Date
When the trial ends.
DatestartedAt?Date
When the subscription began.
DateendsAt?Date
When access ends — a scheduled cancellation date or the end of a grace period.
DateendedAt?Date
When the subscription actually terminated.
Datemetadata?Record<string, string | number | boolean>
Provider metadata, where available.
Record<string, string | number | boolean>raw?unknown
The untouched provider payload.
unknownListing subscriptions
const { items, cursor } = await client.subscriptions.list({ customerId: 'CUSTOMER_ID' });
The customerId filter requires the listSubscriptionsByCustomer capability — Lemon Squeezy cannot
filter by customer and throws unsupported (it filters by store, product, variant, or email instead).