Status mapping
The unified subscription status table with per-provider mappings, how cancelAtPeriodEnd is detected, the date fields, and checkout status mapping.
A complete reference of how each provider’s subscription status maps onto the unified
SubscriptionStatus union, plus how a scheduled cancellation is detected. For the conceptual overview
see Subscription lifecycle.
The unified union
type SubscriptionStatus =
| 'incomplete'
| 'trialing'
| 'active'
| 'past_due'
| 'unpaid'
| 'paused'
| 'canceled';
canceled is terminal. A scheduled “cancel at period end” keeps the status unchanged and sets
cancelAtPeriodEnd: true plus endsAt.
Status 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, pause_collection² |
paused |
— |
canceled |
canceled, incomplete_expired |
expired |
canceled, incomplete_expired |
canceled |
cancelled, failed, expired |
cancelled means “grace period until ends_at, still resumable” — it maps to
active with cancelAtPeriodEnd: true. The terminal status is expired.
² Stripe has two unrelated pause mechanisms. pause_collection leaves Stripe’s own status alone —
per Stripe’s docs, “the subscription status will be unchanged and will not be updated to paused” — while
Stripe’s raw paused status only occurs when a trial ends without a payment method. Both map to the
unified paused, except on a terminally canceled subscription: canceled is terminal and outranks a
leftover pause_collection.
A dash means the provider has no equivalent state. Any unrecognized provider status falls back to
active, so a newly introduced status can’t silently revoke a paying customer’s access; inspect
raw if you need the exact provider value.
cancelAtPeriodEnd detection
| Provider | Rule | Notes |
|---|---|---|
| Polar | cancel_at_period_end === true |
Set by PATCH /v1/subscriptions/{id}. |
| Lemon Squeezy | provider status is cancelled |
The status is the flag; the unified status stays active. |
| Stripe | cancel_at_period_end === true || cancel_at !== null |
Both fields must be checked — see the note below. |
| Paddle | scheduled_change?.action === 'cancel' |
Scheduling emits subscription.updated, not subscription.canceled. |
| Dodo Payments | cancel_at_next_billing_date === true |
Forced to false once the status is terminal. |
On Stripe and Dodo Payments, cancelAtPeriodEnd is normalized back to false when the subscription is
terminally canceled — there is nothing left to schedule.
Each of these rules exists because a provider encodes the schedule somewhere different; normalizing subscription status across billing providers covers the entitlement bugs they cause when handled per provider.
pauseAtPeriodEnd detection
A pause scheduled for the end of the period behaves like a scheduled cancellation: the status stays
unchanged and pauseAtPeriodEnd carries the schedule. Only the two providers that can schedule a pause
ever set it.
| Provider | Rule | Notes |
|---|---|---|
| Polar | pause_at_period_end === true |
Polar pauses at period end only; set by PATCH /v1/subscriptions/{id}. |
| Lemon Squeezy | always false |
Pauses take effect immediately. |
| Stripe | always false |
pause_collection takes effect immediately. |
| Paddle | scheduled_change?.action === 'pause' |
Both behaviors are supported; only the scheduled one sets the flag. |
| Dodo Payments | always false |
No pause endpoint and no paused status. |
resumesAt is the matching date field — when the subscription resumes automatically. It is absent for an
indefinite pause.
subscriptionChange: the edge, not the level
Everything above is a level — the state a subscription is in right now. A webhook event can also
carry the edge: WebhookEvent.subscriptionChange names the transition the provider’s event string
named, so it fires exactly once instead of staying set for the rest of the period. Its per-provider
coverage grid, with the exact event string behind every cell, is in
Webhook events.
Date fields
| Field | Polar | Lemon Squeezy | Stripe | Paddle | Dodo Payments |
|---|---|---|---|---|---|
currentPeriodStart |
current_period_start |
— | item current_period_start |
current_billing_period.starts_at |
previous_billing_date |
currentPeriodEnd |
current_period_end |
renews_at |
item current_period_end |
current_billing_period.ends_at |
next_billing_date |
trialEndsAt |
trial_end |
trial_ends_at |
trial_end |
item trial_dates.ends_at |
— |
resumesAt |
resumes_at |
pause.resumes_at |
pause_collection.resumes_at |
scheduled_change.resume_at while scheduled, else the resume change’s effective_at |
— |
startedAt |
started_at |
created_at |
start_date |
started_at |
created_at |
endsAt |
ends_at |
ends_at |
cancel_at, else current_period_end when scheduled |
scheduled_change.effective_at when scheduled |
next_billing_date when scheduled, else expires_at |
endedAt |
ended_at |
ends_at when expired |
ended_at |
canceled_at |
cancelled_at |
endsAt answers “when does access end” — a scheduled cancellation date or the end of a grace period.
endedAt answers “when did it actually terminate” and is only set once the subscription is over.
Checkout status mapping
| Unified | Polar | Lemon Squeezy | Stripe | Paddle | Dodo Payments |
|---|---|---|---|---|---|
complete |
succeeded |
— (always null) |
complete and payment_status !== 'unpaid' |
paid, completed |
payment_status: succeeded |
expired |
expired, failed |
— | expired |
canceled |
cancelled, failed |
open |
everything else | — | open, plus complete while payment_status === 'unpaid' |
everything else | everything else |
Lemon Squeezy checkouts carry no lifecycle status, so Checkout.status is always null
(checkoutStatus: false).