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

# Upgrades, downgrades and cancellation

> Move a paying customer to a pricier or cheaper plan, or stop their plan renewing at the end of the period.

A customer on a **free** plan, or with no plan, changes plan with [`attach`](https://docs.integrateledger.com/getting-started/display-billing#sell-through-checkout). A customer on an active **paid** plan changes plan with the calls on this page. Each one takes an idempotency key that identifies this one intended change: store it and reuse it on every retry, so a retry never charges or refunds twice.

| The customer wants | Call | When it takes effect |
| - | - | - |
| A pricier paid plan | `upgradePaidPlan` | Now, with a full charge and a new period |
| A cheaper paid plan | `downgradePaidPlan` | At the period end (default), or now |
| To stop paying | `setSubscriptionCancellation` | At the period end |

One change at a time: while an upgrade is waiting for payment, or a downgrade or cancellation is scheduled, the others return `409` (`upgrade_pending`, `downgrade_scheduled`, `cancellation_scheduled`). Undo the scheduled one first.

## Upgrade

```typescript TypeScript theme={"system"}
const upgrade = await billing.upgradePaidPlan({
	idempotencyKey: `upgrade_${customerId}_scale`,
	body: { customerId, planId: "scale" },
});
```

```python Python theme={"system"}
upgrade = billing.upgrade_paid_plan(
    customer_id=customer_id,
    plan_id="scale",
    idempotency_key=f"upgrade_{customer_id}_scale",
)
```

Stripe charges the full new price and starts a new monthly period. The old plan's unspent credits stay until the old period would have ended, and are spent first; packs are untouched. Act on `status`:

| `status` | Meaning | What to do |
| - | - | - |
| `applied` | Charged; the new plan and its credits are active. | Show the new plan. |
| `pending_payment` | The invoice needs the customer, usually for 3-D Secure. | Send them to `paymentUrl`; the upgrade applies when they pay. |
| `processing` | Ledger is still reconciling with Stripe. | Retry with the same key in a moment. |
| `closed` | The payment failed or expired. Nothing changed. | Offer it again with a **new** key. |

The target must be a synced paid plan that costs more (`400 ineligible_upgrade`), and the Stripe subscription can't carry discounts (`400 ineligible_stripe_subscription`). Tax is fine: a taxed subscription stays taxed, and with [Automatic tax](https://docs.integrateledger.com/concepts/tax.md) on, an untaxed one starts being taxed when Stripe knows the customer's location. On `502 stripe_upgrade_failed` or `503 upgrade_reconciliation_required`, retry with the **same** key.

## Downgrade

```typescript TypeScript theme={"system"}
const downgrade = await billing.downgradePaidPlan({
	idempotencyKey: `downgrade_${customerId}_starter`,
	body: { customerId, planId: "starter" }, // timing: "period_end" by default
});
```

```python Python theme={"system"}
downgrade = billing.downgrade_paid_plan(
    customer_id=customer_id,
    plan_id="starter",
    idempotency_key=f"downgrade_{customer_id}_starter",
)  # timing="period_end" by default
```

By default the customer keeps the current plan and its credits until the period ends, and the renewal starts the cheaper plan. The response is `scheduled`, with `effectiveAt` set to the renewal. Undo it any time before then with `cancelScheduledDowngrade({ downgradeId, customerId })`; `getScheduledDowngrade` returns the one that's pending.

With `timing: "immediately"`, the cheaper plan starts now and the renewal date stays. The old plan's remaining credits end (`oldPlanCredits: "keep"` leaves them), the new plan's credits are prorated to the rest of the period, and `refund: "prorated"` refunds the unused share of the price difference to the card, plus the tax charged on it. The response is `applied`. Overage from the old plan always bills at its own rate.

The target must be a cheaper, synced paid plan (`400 ineligible_downgrade`). Moving to a free plan isn't a downgrade: cancel at period end, then attach the free plan.

## Cancel at period end

Renewal is set on the subscription's numeric `id`. Find the active paid subscription in `listCustomerSubscriptions` and offer the control only when its `cancellation.manageable` is `true`:

```typescript TypeScript theme={"system"}
const { items } = await billing.listCustomerSubscriptions({ customerId });
const paid = items.find(
	(item) => item.status === "active" && item.cancellation?.manageable === true,
);

if (paid) {
	await billing.setSubscriptionCancellation({
		customerId,
		subscriptionId: paid.id,
		body: { cancelAtPeriodEnd: true }, // false resumes
	});
}
```

```python Python theme={"system"}
subscriptions = billing.list_customer_subscriptions(customer_id=customer_id)
paid = next(
    (
        item
        for item in subscriptions["items"]
        if item["status"] == "active" and (item["cancellation"] or {}).get("manageable")
    ),
    None,
)

if paid:
    billing.set_subscription_cancellation(
        customer_id=customer_id,
        subscription_id=paid["id"],
        cancel_at_period_end=True,  # False resumes
    )
```

The response is `{ "cancelAtPeriodEnd": true, "cancelsAt": "2026-12-07T07:25:02.000Z" }`. Show `cancelsAt` and a **Resume** button: the plan and its credits last until then, and Stripe doesn't charge again. When the period ends, the subscription is `canceled`, its credits expire, and packs stay. Attach your free plan to give the customer an allowance again.

`cancellation` is `null` for free plans, pending Checkouts, and when Ledger couldn't read the subscription from Stripe. `409 subscription_not_manageable` means Stripe has a change Ledger won't override, such as a pause or a custom cancellation date; resolve it in Stripe. Ledger never refunds a cancellation. To end a subscription immediately, cancel it in Stripe and Ledger follows.

> [!NOTE]
>
> **Other ways.** Customers in the dashboard have a **Manage subscription** sheet for one-off changes. Over HTTP, see [upgrade](https://docs.integrateledger.com/api-reference/billing/upgrade.md), [downgrade](https://docs.integrateledger.com/api-reference/billing/downgrade.md) and [cancellation](https://docs.integrateledger.com/api-reference/customers/cancellation.md). Every error code is in [Errors](https://docs.integrateledger.com/errors.md).


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