Plan change events
Which webhook events fire for every kind of subscription plan change — upgrades, downgrades, and changes during a trial — what each payload carries, and why you should never detect a plan change from invoice.paid.
When you change a subscription’s plan — swapping a price, moving to a different billing interval, or changing it during a trial — PaymentKit emits a set of webhook events. Which events fire depends on three things:
- Whether the billing interval or contract terms change. A change that keeps the same terms is applied in place; a change to a different interval, interval count, contract length, or auto-renew setting splits the subscription (the original is cancelled and a new one is created).
- Whether the subscription still has trial time remaining.
- The net proration amount across the whole change (an “upgrade” nets to a charge, a “downgrade” nets to a credit).
There is no single “plan changed” event that fires for every case. This page lists exactly what fires for each one.
Do not detect plan changes from invoice.paid. A downgrade nets to a credit and produces a credit_note.created event, not a payment — so invoice.paid never fires. invoice.paid also fires for ordinary renewals, so it cannot tell a plan change apart from a normal billing cycle. Detect plan changes from the subscription and credit-note events below, then read the new price from the subscription’s items.
Detecting a plan change
Treat any of these events as “this subscription’s plan may have changed — re-check it,” then read the current price from the subscription’s items in the payload (data.object.items) or by re-fetching the subscription:
customer.subscription.plan_changed— a plan change while trialing (see In-trial changes)customer.subscription.createdandcustomer.subscription.cancelled— a change that crossed billing intervals (a split)customer.subscription.updated— a scheduled change was stored, or items were modifiedcredit_note.created— a downgrade issued a credit
Use invoice.paid only to confirm that a charge was collected, and credit_note.created for credits.
Two cases need care:
- In-trial changes emit a single authoritative event,
customer.subscription.plan_changed, which replacescustomer.subscription.updatedfor that change. - Same-interval changes keep the same subscription and emit
customer.subscription.updatedon it (alongside the billing events), so you detect them exactly like any other update — read the new price from the subscription’sitems. The only exception is a change that removes the subscription’s last item: that cancels it and emitscustomer.subscription.cancelledinstead.
The event matrix
The tables below list the events each kind of change emits, beyond the baseline product/price/customer events for any objects you created. “Upgrade” and “downgrade” mean the sign of the net proration across the whole change, not the direction of a single item. How the new charge is calculated depends on whether the subscription splits: a same-interval change prorates the new charge for the time left in the current period, while a cross-interval change starts a fresh billing period on the new subscription and charges its full price, crediting the original subscription for its unused time.
Immediate changes
For immediate changes (effective_at: "immediate", the default), PaymentKit always creates and collects a proration invoice — see proration and timing.
Same billing interval and terms
The plan is changed on the same subscription; no subscription is created or cancelled.
customer.subscription.updated fires on the subscription for every same-interval change that leaves it active, and is the last event of the change — detect the plan change from it and read the new price from the subscription’s items. (A change made while trialing emits customer.subscription.plan_changed instead, and a change that removes the subscription’s last item cancels it — see cross-interval changes for the split case.)
Cross-interval changes
A change to a different billing interval, interval count, contract length, or auto-renew setting splits the subscription: the original is cancelled (when all its items move) and one or more new subscriptions are created.
invoice.voided covers any pending renewal invoices voided when the original subscription is cancelled. The new subscription carries metadata.split_from_subscription_id linking it to the original — see lineage tracking.
In-trial changes
When the subscription still has trial remaining (its state is trialing and trial_end is in the future), the plan is changed in place regardless of interval — the subscription keeps its identity and its trial, and its plan is swapped. Nothing is billed at change time, because nothing has been paid yet.
No invoice, credit note, customer.subscription.created, customer.subscription.cancelled, or customer.subscription.updated is emitted. (If the subscription has an open setup invoice from the start of its trial, re-anchoring its period can additionally emit an invoice.updated.) If the trial is ended immediately (trial_behavior: "end_now"), the first real period is billed by the normal activation path after the change, which then emits the usual invoice.created / invoice.paid.
Scheduled changes
With effective_at: "period_end", the change is stored on the subscription and executed at the next renewal.
Delivery order is not guaranteed. Webhooks can arrive in any order. Handle events idempotently — do not assume, for example, that customer.subscription.created arrives before customer.subscription.cancelled, or that invoice.created arrives before credit_note.created.
customer.subscription.plan_changed
customer.subscription.plan_changed fires when a plan change is applied to a subscription that still has trial remaining (state trialing with a future trial_end). The subscription keeps its identity, its plan is swapped in place, and — because this event is emitted — the generic customer.subscription.updated is suppressed for that change. Treat plan_changed as the trial-time equivalent of updated.
It only fires on this in-place trial path. A non-trial plan change never emits it — you receive the cross-interval or same-interval events instead.
Webhook payload
The event follows the standard webhook envelope. data.object is the updated subscription (with the new billing_interval, billing_interval_count, items, and trial_end), and data.previous_attributes holds the subscription’s previous state, so you can see what the interval changed from.
The strategy and previous_billing_interval details below are carried on the stored event’s metadata (readable through the Events API), not in the webhook wire payload. Over webhooks, read the new interval from data.object.billing_interval and the previous interval from data.previous_attributes.billing_interval.
Event metadata (Events API)
Fetched through the Events API, the event’s metadata carries the plan-change specifics:
Strategy values
Sample payloads
All examples use the Stripe-compatible webhook envelope. Amount fields are in atomic units (for example, cents for USD) with the _atom suffix.
Downgrade: credit_note.created
A downgrade issues a credit to the customer’s balance. credit_note.created fires in both directions, so read amount_atom and reason rather than the event name — see Credit notes.
Downgrade: customer.updated
The customer’s credit balance changes when the credit is applied, so a customer.updated event is emitted.
Downgrade: invoice.created
A downgrade also creates a companion invoice that documents the credit note. It is born paid (billing_reason: "credit_note", status: "paid") and is not a collectible charge — it never emits invoice.paid.
Upgrade: invoice.paid
An upgrade collects the prorated charge, which emits invoice.paid on success.
Proration and timing options
Both the change-plan and change requests endpoints control proration and timing with two options.
proration_behavior
effective_at
An immediate change always uses always_invoice — create_prorations and none are rejected with effective_at: "immediate". Those two behaviors only apply when the change is deferred with effective_at: "period_end", where they take effect at renewal.
There is no option to charge the full new-plan price immediately with no proration credit. How much is collected depends on the change:
- A same-interval change prorates the new charge for the time left in the current period, and credits the old price’s unused time.
- A cross-interval change charges the new subscription’s full price for a fresh period, and credits the original subscription for its unused time — so the amount collected is the full new price minus that credit, not a prorated fraction.
- Deferring with
effective_at: "period_end"makes no mid-cycle adjustment: the full new price is billed at the next renewal.
If you need to bill the full new price immediately with no offsetting credit for the customer’s remaining time, that is not supported — use one of the options above.
Related
- Webhooks — the webhook envelope, signing, and the full event-type list
- Events — the Events API, where event
metadatais readable - Change subscription plan — the change-plan endpoint
- Subscription change requests — the recommended multi-step workflow
- Credit notes & credit balance — how downgrade credits work