Skip to navigation

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.

View as Markdown

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.created and customer.subscription.cancelled — a change that crossed billing intervals (a split)
  • customer.subscription.updated — a scheduled change was stored, or items were modified
  • credit_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 replaces customer.subscription.updated for that change.
  • Same-interval changes keep the same subscription and emit customer.subscription.updated on it (alongside the billing events), so you detect them exactly like any other update — read the new price from the subscription’s items. The only exception is a change that removes the subscription’s last item: that cancels it and emits customer.subscription.cancelled instead.

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.

Net resultEvents emitted
Upgrade (net charge)invoice.created, invoice.finalized, invoice.paid, customer.subscription.updated
Downgrade (net credit)credit_note.created, customer.updated, invoice.created, customer.subscription.updated — no invoice.paid
Zero netcustomer.subscription.updated — the items change going forward with no invoice or credit note

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.

Net resultEvents emitted
Upgrade (net charge)customer.subscription.created, customer.subscription.cancelled, invoice.created, invoice.finalized, invoice.paid, invoice.voided
Downgrade (net credit)customer.subscription.created, customer.subscription.cancelled, credit_note.created, customer.updated, invoice.created — no invoice.paid

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.

Event emitted
customer.subscription.plan_changed only

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.

WhenEvents emitted
At scheduling timecustomer.subscription.updated (the pending change is stored on the subscription)
At execution (renewal)The same events as the equivalent immediate change

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.

{
"id": "evt_prod_a1b2c3d4e5f6g7h8",
"object": "event",
"type": "customer.subscription.plan_changed",
"created": 1704067200,
"livemode": true,
"data": {
"object": {
"id": "sub_prod_a1b2c3d4e5f6g7h8",
"state": "trialing",
"billing_interval": "year",
"billing_interval_count": 1,
"trial_end": "2026-02-01T00:00:00Z",
"items": [
{
"id": "si_prod_1a2b3c4d5e6f7g8h",
"price_id": "price_prod_annual_plan",
"quantity": 1
}
]
},
"previous_attributes": {
"billing_interval": "month",
"billing_interval_count": 1
}
}
}

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:

FieldDescription
strategyHow the trial was handled: in_place_preserve (trial kept) or in_place_end_now (trial ended immediately)
in_placeAlways true — the change was applied on the same subscription
previous_billing_intervalBilling interval before the change (e.g. month)
previous_billing_interval_countBilling interval count before the change
billing_intervalBilling interval after the change (e.g. year)
billing_interval_countBilling interval count after the change
trial_endThe subscription’s trial end (ISO 8601), or null
{
"id": "evt_prod_a1b2c3d4e5f6g7h8",
"type": "customer.subscription.plan_changed",
"aggregate_type": "subscription",
"aggregate_id": "sub_prod_a1b2c3d4e5f6g7h8",
"data": { "...": "the updated subscription" },
"previous_data": { "...": "the subscription's previous state" },
"metadata": {
"strategy": "in_place_preserve",
"in_place": true,
"previous_billing_interval": "month",
"previous_billing_interval_count": 1,
"billing_interval": "year",
"billing_interval_count": 1,
"trial_end": "2026-02-01T00:00:00Z"
},
"created": "2026-01-01T00:00:00Z"
}

Strategy values

strategyMeaning
in_place_preserveThe trial’s original end date is kept. The first charge is at trial end, at the new price.
in_place_end_nowThe trial is ended immediately and the first period is billed at the new price straight away (through the normal activation path).

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.

{
"id": "evt_prod_b2c3d4e5f6g7h8i9",
"object": "event",
"type": "credit_note.created",
"created": 1704067200,
"livemode": true,
"data": {
"object": {
"id": "cn_prod_a1b2c3d4e5f6g7h8",
"customer_id": "cus_prod_a1b2c3d4e5f6g7h8",
"amount_atom": 2500,
"currency": "usd",
"type": "issued",
"reason": "proration_excess"
}
}
}

Downgrade: customer.updated

The customer’s credit balance changes when the credit is applied, so a customer.updated event is emitted.

{
"id": "evt_prod_c3d4e5f6g7h8i9j0",
"object": "event",
"type": "customer.updated",
"created": 1704067200,
"livemode": true,
"data": {
"object": {
"id": "cus_prod_a1b2c3d4e5f6g7h8",
"credit_balance": { "usd": 2500 }
},
"previous_attributes": {
"credit_balance": { "usd": 0 }
}
}
}

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.

{
"id": "evt_prod_d4e5f6g7h8i9j0k1",
"object": "event",
"type": "invoice.created",
"created": 1704067200,
"livemode": true,
"data": {
"object": {
"id": "in_prod_a1b2c3d4e5f6g7h8",
"status": "paid",
"billing_reason": "credit_note",
"currency": "usd",
"total_amount_atom": 2500,
"due_amount_atom": 0,
"paid_amount_atom": 2500
}
}
}

Upgrade: invoice.paid

An upgrade collects the prorated charge, which emits invoice.paid on success.

{
"id": "evt_prod_e5f6g7h8i9j0k1l2",
"object": "event",
"type": "invoice.paid",
"created": 1704067200,
"livemode": true,
"data": {
"object": {
"id": "in_prod_a1b2c3d4e5f6g7h8",
"status": "paid",
"billing_reason": "subscription_update",
"currency": "usd",
"total_amount_atom": 5000,
"due_amount_atom": 0,
"paid_amount_atom": 5000
},
"previous_attributes": {
"status": "open",
"due_amount_atom": 5000,
"paid_amount_atom": 0
}
}
}

Proration and timing options

Both the change-plan and change requests endpoints control proration and timing with two options.

proration_behavior

ValueEffect
always_invoiceCreate a proration invoice immediately and collect it now (an upgrade), or issue a credit note (a downgrade).
create_prorationsCreate floating proration line items (invoice_id is null) that are billed on the next renewal invoice. No immediate invoice or charge.
noneMake no billing adjustment for the mid-cycle change. The items change going forward.
charge_fullCharge the full new-plan price immediately with no proration and no credit, and reset the billing cycle to now. A charge_full change never nets to a credit — even a price decrease collects the full new amount, so it emits the charge-shaped events (invoice.paid), never credit_note.created. Detect it the same way as an upgrade: a same-interval change emits customer.subscription.updated, a cross-interval change emits customer.subscription.created + customer.subscription.cancelled.

effective_at

ValueEffect
immediate (default)Apply the change now.
period_endStore the change and apply it at the end of the current billing period.

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