Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

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

PropType
id?string

Provider subscription identifier.

Typestring
status?SubscriptionStatus

The unified status. canceled is terminal.

TypeSubscriptionStatus
cancelAtPeriodEnd?boolean

A cancellation is scheduled; access continues until endsAt.

Typeboolean
pauseAtPeriodEnd?boolean

A pause is scheduled for the end of the period; the status stays unchanged until it takes effect.

Typeboolean
customerId?string

The provider customer that owns the subscription.

Typestring
productId?string

The subscribed product, when the provider exposes it.

Typestring
priceId?string

The subscribed price, when the provider exposes it.

Typestring
quantity?number

Subscribed quantity, when applicable.

Typenumber
currency?string

Lowercase ISO 4217 code.

Typestring
amount?number

Amount per billing interval, in minor units.

Typenumber
interval?'day' | 'week' | 'month' | 'year'

Billing interval.

Type'day' | 'week' | 'month' | 'year'
intervalCount?number

Intervals per billing cycle.

Typenumber
currentPeriodStart?Date

Start of the current billing period. On Stripe this lives on the subscription item; the SDK reads the first one.

TypeDate
currentPeriodEnd?Date

End of the current billing period / next renewal.

TypeDate
trialEndsAt?Date

When the trial ends.

TypeDate
resumesAt?Date

When a paused — or pause-scheduled — subscription resumes automatically; absent for an indefinite pause.

TypeDate
startedAt?Date

When the subscription began.

TypeDate
endsAt?Date

When access ends — a scheduled cancellation date or the end of a grace period.

TypeDate
endedAt?Date

When the subscription actually terminated.

TypeDate
meters?SubscriptionMeter[]

Per-meter usage for the current period. Only Polar reports it inline — absent everywhere else.

TypeSubscriptionMeter[]
metadata?Record<string, string | number | boolean>

Provider metadata, where available.

TypeRecord<string, string | number | boolean>
raw?unknown

The untouched provider payload.

Typeunknown

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

Last updated on August 8, 2026

Was this page helpful?