Subscription lifecycle
Entitlement checks, the unified status model, scheduled cancellations and pauses, and every lifecycle operation the client exposes.
Every provider has its own status vocabulary, and most overload one value 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 two scheduling booleans.
Checking entitlement
The point of the model is that entitlement is a set membership test that works on every provider:
import type { Subscription } from 'revenue-sdk';
const ENTITLED: ReadonlySet<Subscription['status']> = new Set(['active', 'trialing']);
// past_due means a renewal failed and the provider is still retrying. Keeping access on
// during that window is a product decision — most SaaS products do.
const GRACE: ReadonlySet<Subscription['status']> = new Set([...ENTITLED, 'past_due']);
export function isEntitled(subscription: Subscription, allowGrace = true): boolean {
return (allowGrace ? GRACE : 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. 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.
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. |
An unrecognized provider status falls back to active, so a newly introduced status never silently
revokes a paying customer’s access — inspect raw for the exact provider value. The per-provider
mapping table is in Status mapping.
Scheduled cancellations and pauses
This is the decision that makes the model work: a scheduled cancellation is not a status. The
subscription keeps its current status and gains cancelAtPeriodEnd: true plus endsAt, the date access
actually ends. Only when that date passes does the status become canceled:
// checkout completes → status: 'active', cancelAtPeriodEnd: false
// subscriptions.cancel → status: 'active', cancelAtPeriodEnd: true, endsAt: 2026-09-01
// subscriptions.uncancel→ status: 'active', cancelAtPeriodEnd: false, endsAt: undefined
// the period ends → status: 'canceled', cancelAtPeriodEnd: false, endedAt: 2026-09-01
Treat cancelAtPeriodEnd as meaningless once the status is terminal — there is nothing left to
schedule, so check status === 'canceled' first. A pause scheduled for the end of the period behaves
identically: pauseAtPeriodEnd: true with
the status unchanged, plus resumesAt when the pause has an automatic end date. status only becomes
paused once the pause takes effect.
Every provider encodes both schedules somewhere different — Stripe’s flexible billing mode, for one,
sets only cancel_at when a customer cancels through the portal — and the SDK reads all of them. The
detection rules are in
Status mapping.
The flags are levels; webhooks carry the edge
status, cancelAtPeriodEnd and pauseAtPeriodEnd describe where a subscription is, never when it
got there. cancelAtPeriodEnd stays true for the rest of the period, so a handler keyed off it
re-sends its “your plan ends soon” email on every later update event. WebhookEvent.subscriptionChange
names the transition instead — 'cancel_scheduled' | 'past_due' | 'paused' | 'resumed' | 'uncanceled',
derived only from the provider’s event string, so it appears exactly once. Keep entitlement on the
level; use the edge for side effects. See Webhooks and the coverage grid in
Status mapping.
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. On Stripe the SDK sends the existing subscription
item’s ID with the change, so the classic double-billing bug cannot happen through the client; if the
subscription comes back with no items, nothing is sent and provider_error is thrown instead.
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; on Lemon Squeezy it throws validation unless the subscription is actually
trialing, because the underlying reset would otherwise move the billing day — see
Lemon Squeezy.
Pause
const subscription = await client.subscriptions.pause({
id: 'SUBSCRIPTION_ID',
behavior: 'period_end', // must be in capabilities.pauseBehaviors
resumesAt: new Date('2026-10-01'), // omit to pause indefinitely
});
Requires the pause capability — Dodo Payments has no pause endpoint and no paused status, so both
pause and resume throw unsupported. behavior is checked against capabilities.pauseBehaviors
before the request; omitting it always works and uses the provider’s own default. An immediate pause
sets status: 'paused'; a period-end pause leaves status alone and sets pauseAtPeriodEnd: true until
it takes effect. resumesAt schedules the automatic resume — on Polar it must fall after the current
period end.
Resume
const subscription = await client.subscriptions.resume({ id: 'SUBSCRIPTION_ID' });
Always takes effect immediately, and also clears a pause that was merely scheduled. Gated by the same
pause capability. On Polar it starts a new billing period and charges the customer; on Stripe
it costs one extra read, because the SDK has to see which of Stripe’s two pause mechanisms is in play
before picking an endpoint.
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. |
The client checks the value against capabilities.prorationBehaviors before the request and throws
unsupported otherwise. Not every provider offers all three — Polar has no none, Dodo Payments no
prorate — so read the capability rather than hard-coding a behavior:
if (client.capabilities.prorationBehaviors.includes('none')) {
await client.subscriptions.changePlan({ id, product, prorationBehavior: 'none' });
}
Support per provider is in the capability matrix.
How each behavior maps onto the provider's own wire value
| 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 |
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.
booleanpauseAtPeriodEnd?boolean
A pause is scheduled for the end of the period; the status stays unchanged until it takes effect.
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. On Stripe this lives on the subscription item; the SDK reads the first one.
DatecurrentPeriodEnd?Date
End of the current billing period / next renewal.
DatetrialEndsAt?Date
When the trial ends.
DateresumesAt?Date
When a paused — or pause-scheduled — subscription resumes automatically; absent for an indefinite pause.
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.
Datemeters?SubscriptionMeter[]
Per-meter usage for the current period. Only Polar reports it inline — absent everywhere else.
SubscriptionMeter[]metadata?Record<string, string | number | boolean>
Provider metadata, where available.
Record<string, string | number | boolean>raw?unknown
The untouched provider payload.
unknownWhich provider field each date comes from is in
Status mapping; meters is
Polar-only.
Listing 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).
Canceled subscriptions are included on every provider, Stripe included, where the SDK always sends
status=all.