> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nozle.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# PlanChangeControl

> Plan picker for one subscription: exact quotes, paid upgrades, scheduled downgrades, withdrawal of a scheduled change and payment recovery.

`PlanChangeControl` lets a customer move one subscription to another plan: it loads the plans they may change to from your backend, shows the exact quote, applies the change, collects payment for an upgrade and recovers a payment after a reload.

**When to use**

* The subscription section of your billing settings page.
* Paid upgrades through [`Checkout`](/sdks/react/components/checkout), and downgrades scheduled for renewal.
* Letting the customer withdraw a scheduled change with "Keep current plan".

[`BillingPortal`](/sdks/react/components/billing-portal) renders this control for you when you pass `planChangeActions`.

**Light**

![The Change plan button, light theme](https://mintcdn.com/nozle-d78f01d8/8jEvxrWAvqijYzbo/images/react/components/plan-change-control/light.png?fit=max\&auto=format\&n=8jEvxrWAvqijYzbo\&q=85\&s=4bd50da6b81aff5408c92a1dd23b8faf)

**Dark**

![The Change plan button, dark theme](https://mintcdn.com/nozle-d78f01d8/8jEvxrWAvqijYzbo/images/react/components/plan-change-control/dark.png?fit=max\&auto=format\&n=8jEvxrWAvqijYzbo\&q=85\&s=acc964dd20074547c8d4027f5f1d1f1e)

**Minimal**

![The Change plan button, minimal theme](https://mintcdn.com/nozle-d78f01d8/8jEvxrWAvqijYzbo/images/react/components/plan-change-control/minimal.png?fit=max\&auto=format\&n=8jEvxrWAvqijYzbo\&q=85\&s=178151187a4ae60f8c1ec1dbc3d813cd)

**Neobrutal**

![The Change plan button, neobrutal theme](https://mintcdn.com/nozle-d78f01d8/8jEvxrWAvqijYzbo/images/react/components/plan-change-control/neobrutal.png?fit=max\&auto=format\&n=8jEvxrWAvqijYzbo\&q=85\&s=2b3e695bc26d7f7717ad511d55c5d4d0)

**Nozle**

![The Change plan button, nozle theme](https://mintcdn.com/nozle-d78f01d8/8jEvxrWAvqijYzbo/images/react/components/plan-change-control/nozle.png?fit=max\&auto=format\&n=8jEvxrWAvqijYzbo\&q=85\&s=4650aea92d5e2e1d675a777a514b1c14)

**Nozle Dark**

![The Change plan button, nozleDark theme](https://mintcdn.com/nozle-d78f01d8/8jEvxrWAvqijYzbo/images/react/components/plan-change-control/nozle-dark.png?fit=max\&auto=format\&n=8jEvxrWAvqijYzbo\&q=85\&s=262207243dbef084883bcb1fe273cb93)

## Importing PlanChangeControl

```tsx theme={null}
import {
  BillingPortalError,
  PlanChangeControl,
  type PlanChangeActions,
  type PlanChangeState,
} from '@nozle-js/react'
```

## Rendering PlanChangeControl

Pass the subscription and `actions`, the callbacks that call your authenticated backend (see [Live data](#live-data)):

```tsx components/change-plan.tsx theme={null}
'use client'

export function ChangePlan(props: {
  subscriptionId: string
  actions: PlanChangeActions
  onChanged: () => void
}) {
  return (
    <PlanChangeControl
      subscriptionId={props.subscriptionId}
      actions={props.actions}
      onChanged={props.onChanged}
      returnUrl="https://app.example.com/settings/billing"
      locale="en-US"
      timezone="America/New_York"
    />
  )
}
```

### What a change looks like

1. "Change plan" opens the plan list: name, price, interval, and whether each choice is an upgrade or a downgrade.
2. "Review change" calls `preview` and shows the review step with the quote in minor units: new plan charge, credits applied, due now, due at the effective date, the effective and renewal dates, and a Back button.
3. "Confirm upgrade" or "Confirm downgrade" calls `apply`. A paid upgrade opens [`Checkout`](/sdks/react/components/checkout); a downgrade is scheduled for renewal.

While a payment is pending, the control checks the status every 5 seconds. Retries reuse the same idempotency key, so a retry never applies a change twice.

## Data

`load` and `status` return a `PlanChangeState`:

```tsx theme={null}
// What your `load` and `status` actions return for an active Pro subscription.
export const state: PlanChangeState = {
  subscriptionId: 'sub_123',
  status: 'active',
  currentPlan: { code: 'pro', name: 'Pro', amountCents: 2900, currency: 'USD', interval: 'monthly' },
  endingAt: null,
  pendingChange: null,
  eligiblePlans: [
    { code: 'scale', name: 'Scale', amountCents: 9900, currency: 'USD', interval: 'monthly', operation: 'upgrade' },
    { code: 'free', name: 'Free', amountCents: 0, currency: 'USD', interval: 'monthly', operation: 'downgrade' },
  ],
  blockedReason: null,
  checkout: null,
  checkoutStatus: 'none',
}
```

### Live data

`actions` calls your authenticated backend. Each callback receives the external `subscriptionId` and an `AbortSignal`; never send the signal in the request body. Create the object once, outside the component:

```tsx lib/plan-change-actions.ts theme={null}
async function post<T>(path: string, body: object, signal?: AbortSignal): Promise<T> {
  const response = await fetch(path, {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
    signal,
  })
  if (!response.ok) {
    throw new BillingPortalError(
      response.status === 401 || response.status === 403 ? 'unauthorized' : response.status === 409 ? 'changed' : 'request',
    )
  }
  return response.json()
}

export const actions: PlanChangeActions = {
  load: ({ signal, ...body }) => post('/api/billing/plans/load', body, signal),
  status: ({ signal, ...body }) => post('/api/billing/plans/status', body, signal),
  preview: ({ signal, ...body }) => post('/api/billing/plans/preview', body, signal),
  apply: ({ signal, ...body }) => post('/api/billing/plans/apply', body, signal),
  withdraw: ({ signal, ...body }) => post('/api/billing/plans/withdraw', body, signal),
  verifyCheckout: ({ signal, ...body }) => post('/api/billing/plans/checkout-verify', body, signal),
  getCheckoutStatus: ({ signal, ...body }) => post('/api/billing/plans/checkout-status', body, signal),
}
```

Your backend fills the adapter with the Node, Python or Go SDK: it authenticates the customer, checks that the subscription is theirs, converts the SDK's snake\_case responses into these camelCase types, and keeps quotes and idempotency keys. [Billing portal and self-service](/sdks/react/billing-portal#upgrade-downgrade-and-recover-payment) lists each callback's input and return value, and links runnable backends.

### Data from the API

| Callback | Receives, besides `subscriptionId` and `signal` | Returns |
| - | - | - |
| `load` | nothing | `PlanChangeState`, when the control mounts |
| `status` | nothing | `PlanChangeState`, when the control re-reads state |
| `preview` | `targetPlanCode` | `PlanChangePreview`: the quote, with `quoteToken` |
| `apply` | `targetPlanCode`, `quoteToken`, `idempotencyKey`, `returnUrl` | `CheckoutResult` |
| `withdraw` | `pendingChangeId`, `idempotencyKey` | Anything; the control re-reads state |
| `verifyCheckout` | `checkoutId`, `verification` | `CheckoutStatus`; needed for Razorpay |
| `getCheckoutStatus` | `checkoutId` | `CheckoutStatus`; needed to recover a payment |

## Examples

### Plan list

"Change plan" opens a dialog with one choice per eligible plan, marked Upgrade or Downgrade. Inside a `BillingProvider`, each choice also shows the plan's description and lines from the public catalog.

![The Change plan dialog with Scale selected as an upgrade and Free as a downgrade, each with its price and lines, light theme](https://mintcdn.com/nozle-d78f01d8/8jEvxrWAvqijYzbo/images/react/components/plan-change-control-plans/light.png?fit=max\&auto=format\&n=8jEvxrWAvqijYzbo\&q=85\&s=63ee771ec4dda54bedfc00ae87844754)

```tsx theme={null}
'use client'
import { BillingProvider } from '@nozle-js/react'

export function ChangePlan({ subscriptionId, actions }: { subscriptionId: string; actions: PlanChangeActions }) {
  return (
    <BillingProvider publishableKey="pk_nozle_...">
      <PlanChangeControl subscriptionId={subscriptionId} actions={actions} />
    </BillingProvider>
  )
}
```

### Review step

After "Review change", the dialog shows the quote that `preview` returns. An end-of-period change says the current plan and access continue until the effective date; an immediate change with nothing due says "No payment is due now for this change."; otherwise the plan changes after the payment is confirmed.

![The Confirm upgrade step with new plan charge, credits applied, due now, due at effective date, and the Back, Close and Confirm upgrade buttons, light theme](https://mintcdn.com/nozle-d78f01d8/8jEvxrWAvqijYzbo/images/react/components/plan-change-control-confirm/light.png?fit=max\&auto=format\&n=8jEvxrWAvqijYzbo\&q=85\&s=25275775f4a126940bd1ec843986c9af)

```tsx theme={null}
import type { PlanChangePreview } from '@nozle-js/react'

// What your `preview` action returns for an immediate upgrade from Pro to Scale.
export const quote: PlanChangePreview = {
  operation: 'upgrade',
  timing: 'immediate',
  currency: 'USD',
  debitAmountCents: 9900,
  creditAmountCents: 1450,
  netAmountCents: 8450,
  amountDueNowCents: 8450,
  amountDueAtEffectiveCents: 0,
  effectiveAt: '2026-10-17T00:00:00Z',
  renewalAt: '2026-11-01T00:00:00Z',
  quoteToken: 'qt_...',
}
```

### Scheduled change

When `load` or `status` returns a `pendingChange`, the control shows "Current plan until \<date>, then \<plan> at \<price> / \<interval>." and a "Keep current plan" button that withdraws the change.

![A scheduled downgrade to Free with the Keep current plan button, light theme](https://mintcdn.com/nozle-d78f01d8/8jEvxrWAvqijYzbo/images/react/components/plan-change-control-scheduled/light.png?fit=max\&auto=format\&n=8jEvxrWAvqijYzbo\&q=85\&s=158c719e9084de05307f4b81af93d497)

```tsx theme={null}
// What your `load` and `status` actions return while a downgrade to Free waits for renewal.
export const scheduled: PlanChangeState = {
  subscriptionId: 'sub_123',
  status: 'active',
  currentPlan: { code: 'pro', name: 'Pro', amountCents: 2900, currency: 'USD', interval: 'monthly' },
  endingAt: null,
  pendingChange: {
    id: 'pc_123',
    plan: { code: 'free', name: 'Free', amountCents: 0, currency: 'USD', interval: 'monthly' },
    effectiveAt: '2026-11-01T00:00:00Z',
  },
  eligiblePlans: [],
  blockedReason: null,
  checkout: null,
  checkoutStatus: 'none',
}
```

## API reference

**subscriptionId** `string` *required*

The external subscription ID. A new ID resets the control.

**actions** `PlanChangeActions` *required*

Your backend callbacks. Each one receives `subscriptionId` and an `AbortSignal` besides the fields below. Keep the object stable across renders; a new object reloads the control.

**properties**

**load** `(input: PlanChangeInput) =\> Promise\<PlanChangeState\>` *required*

Fires when the control mounts. Returns the subscription's `PlanChangeState`.

**status** `(input: PlanChangeInput) =\> Promise\<PlanChangeState\>` *required*

Fires when the control re-reads state: when the plan list opens, before each preview, after a change, every 5 seconds while a payment is pending and the page is visible, on "Check subscription status" and when `refreshKey` changes. Returns `PlanChangeState`.

**preview** `(input: PlanChangePreviewInput) =\> Promise\<PlanChangePreview\>` *required*

Fires on "Review change" and receives `targetPlanCode`. Returns the quote as a `PlanChangePreview`, with `quoteToken`.

**apply** `(input: PlanChangeApplyInput) =\> Promise\<CheckoutResult\>` *required*

Fires on "Confirm upgrade" or "Confirm downgrade" and receives `targetPlanCode`, `quoteToken`, `idempotencyKey` and `returnUrl`. Returns a `CheckoutResult`.

**withdraw** `(input: PlanChangeWithdrawInput) =\> Promise\<unknown\>` *required*

Fires on "Confirm keep current plan" and receives `pendingChangeId` and `idempotencyKey`. May return anything; the control re-reads state.

**verifyCheckout** `(input: PlanChangeInput & \{ checkoutId: string; verification: RazorpayVerification \}) =\> Promise\<CheckoutStatus\>`

Fires after a Razorpay payment and receives `checkoutId` and `verification`. Returns `CheckoutStatus`. Needed for Razorpay.

**getCheckoutStatus** `(input: PlanChangeInput & \{ checkoutId: string \}) =\> Promise\<CheckoutStatus\>`

Fires while a payment is processing and receives `checkoutId`. Returns `CheckoutStatus`. Needed to recover a payment.

**onChanged** `() =\> void`

Fires after a change took effect, was scheduled or was withdrawn, and when a re-read finds the plan or the scheduled change changed. Refresh the rest of your page here.

**onError** `(error: BillingPortalError) =\> void`

Fires when a request fails and receives a `BillingPortalError` with code `unauthorized`, `request` or `changed`.

**refreshKey** `string`

Change it when something else updated this subscription; the control re-reads its state with `status`.

**returnUrl** `string`

Sent to `apply`, where the payment provider returns the customer. Defaults to the current page without its query.

**locale** `string` (default: en-US)

Formats amounts and dates. The text is English.

**timezone** `string` (default: UTC)

Time zone of the dates: an IANA name, or a Nozle `TZ_` name.

**nonce** `string`

CSP nonce for the control's `<style>` elements.

**appearance** `Appearance`

Theme, variables and element overrides on top of the provider's. See [Appearance](/sdks/react/styling).

### States

| State | What shows |
| - | - |
| Loading | "Loading plan changes…" and a "Check subscription status" button. |
| Active, with eligible plans | A "Change plan" button. |
| A change is scheduled | "Current plan until \<date>, then \<plan> at \<price> / \<interval>." and "Keep current plan". |
| Subscription is ending | "Keep your subscription before choosing another plan." |
| `blockedReason` set | "Plan changes are currently unavailable. Contact support for your options." |
| No eligible plans | "No other plans are available for this subscription." |
| Payment pending | "Complete payment to change your plan." or "Your change is processing. Your current plan remains active until confirmation.", the payment form, and "Check subscription status". |
| `needs_review` | "Payment needs review. Contact support before trying another payment." |
| Payment failed or expired | "Payment did not complete. Your current plan is unchanged." |
| Request over 30 seconds | "This request timed out. Check subscription status before retrying." |
| `changed` error | Back to the plan list with "Your subscription or price changed. Check the latest details and confirm again." |
| Apply failed | "Review and retry change", which retries with the same idempotency key. |

### Appearance

> **Element keys**
>
> `planChangeControl` (root, also class `nozle-plan-change`). The plan list and the review step use the [`UpgradeModal`](/sdks/react/components/upgrade-modal) elements `upgradeModal`, `upgradeModal__dialog` and `upgradeModal__actions`.
>
> Style them with `appearance.elements`. See [Elements](/sdks/react/styling#elements).

## Related

> **[CancellationControl](/sdks/react/components/cancellation-control)**
>
> Cancel at period end, or keep the subscription.

> **[BillingPortal](/sdks/react/components/billing-portal)**
>
> The whole customer portal, with this control inside.

> **[Billing portal and self-service](/sdks/react/billing-portal)**
>
> Backend adapters and runnable examples.

> **[UpgradeButton](/sdks/react/components/upgrade-button)**
>
> A single upgrade with your own preview.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.