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

# Change Plan

> Modify an existing subscription's plan, enabling both upgrades and downgrades to different pricing tiers.<br/><br/>Note&colon; By default this uses the customer's existing payment information. Set collect_via_payment_link to charge via a hosted checkout page instead.

## Scheduled Plan Changes

Use the `effective_at` parameter to control when the plan change takes effect:

| Value | Behavior |
| - | - |
| `immediately` | Apply the plan change right away. This is the default. |
| `next_billing_date` | Schedule the change for the next billing date. The customer retains access to their current plan until the billing period ends. |

<Info>
  Scheduled plan changes are ideal for downgrades — customers keep their current plan benefits until the end of the billing period, then automatically switch to the new plan.
</Info>

To cancel a scheduled plan change before it takes effect, use the [Cancel Scheduled Plan Change](/api-reference/subscriptions/cancel-change-plan) endpoint.

## Payment Failure Handling

Use the `on_payment_failure` parameter to control what happens when the plan change payment fails:

| Value | Behavior |
| - | - |
| `prevent_change` | Keep subscription on current plan until payment succeeds. Plan change remains pending. |
| `apply_change` | Apply plan change immediately regardless of payment outcome. |

<Info>
  If `on_payment_failure` is not specified, the behavior defaults to your business-level setting configured in the dashboard.
</Info>

## Collecting via Payment Link

Set `collect_via_payment_link` to `true` to send the customer to a hosted checkout page instead of charging their saved payment method. The response then includes `payment_id`, `payment_link`, `client_secret`, and `expires_on` — redirect the customer to `payment_link` to complete the payment.

This requires:

| Requirement | Why |
| - | - |
| Business has the `allow_plan_change_via_payment_link` capability enabled | Off by default; toggled in **Settings → Subscriptions → Collect Plan Change Payments by Payment Link**. |
| `effective_at: immediately` | A scheduled change doesn't charge anything until it applies, so there's nothing to collect via checkout yet. |
| `on_payment_failure: prevent_change` | The subscription must stay on its current plan until the checkout payment actually succeeds. Send the field explicitly in the request. |

A request that doesn't meet these returns `422`. While the link is unpaid, the subscription stays on its current plan and a further `change-plan` request returns `409`, unless you set `cancel_older_payment_link`.

### Redirecting After Payment

Set `return_url` to send the customer back to your site after they pay the link. The redirect adds `subscription_id`, `payment_id`, and `status` as query parameters.

* `status` is the status of the plan-change payment, not the status of the subscription.
* When the payment fails, the subscription stays active on its current plan. To try again, call `change-plan` again to get a new link.
* The new plan can apply shortly after the redirect, when the payment webhook arrives.

`return_url` needs `collect_via_payment_link: true`. Without it, the request returns `422`.

### Replacing a Pending Payment Link

If the customer leaves the checkout without paying, set `cancel_older_payment_link` to `true` on the next `change-plan` request. Dodo Payments cancels the unpaid link, and the new plan change replaces the pending one. The cancelled link no longer accepts a payment.

The request is validated before the link is cancelled, so an invalid request keeps the customer's link. The link is cancelled only if the customer has not started to pay:

| Previous payment | Response |
| - | - |
| Not opened, opened but not paid, or declined | `200` with the new link |
| Customer is paying now, for example during 3D Secure | `409` `PLAN_CHANGE_PAYMENT_IN_PROGRESS`. Retry after the payment completes or fails. |
| Already paid | `409` `PLAN_CHANGE_PAYMENT_ALREADY_COMPLETED`. The previous plan change applies. |
| The link could not be cancelled | `503` `PLAN_CHANGE_LINK_CANCEL_FAILED`. Nothing changed, so you can retry. |

Each `change-plan` request issues its own invoice. The replaced plan change and its invoice are cancelled.

<Info>
  See the [Subscription Upgrade & Downgrade Guide](/developer-resources/subscription-upgrade-downgrade#collecting-payment-via-a-checkout-link) for the full flow, including declined payments, replacing a pending link, and what happens if the link expires.
</Info>

## Discount Codes

You can apply one or more **stacked discount codes** when changing plans by passing the `discount_codes` array (max 20 entries, applied in array order). The singular `discount_code` field is deprecated but still works for existing integrations; it cannot be combined with `discount_codes` in the same request.

| `discount_codes` value | Behavior |
| - | - |
| Not provided (`null` / omitted) | Existing discounts with `preserve_on_plan_change=true` are preserved if applicable to the new product. |
| `[]` (empty array) | **All** existing discounts are removed from the subscription. |
| `["CODE_A", "CODE_B", ...]` | Replaces any existing discounts with this stacked set, validated and applied in array order. |

<Tip>
  Use discount codes during plan changes to offer promotional pricing on upgrades, or pass codes when migrating customers to a new plan tier.
</Tip>

<Tip>
  Use `prevent_change` for critical upgrades where you want to ensure payment before granting access to premium features.
</Tip>


## OpenAPI

````yaml post /subscriptions/{subscription_id}/change-plan
openapi: 3.1.0
info:
  title: public
  description: ''
  license:
    name: Apache 2.0
    url: https://www.apache.org/licenses/LICENSE-2.0
  version: 1.118.3
servers:
  - url: https://test.dodopayments.com/
    description: Test Mode Server Host
  - url: https://live.dodopayments.com/
    description: Live Mode Server Host
security: []
tags:
  - name: Products
  - name: Payments
  - name: Subscriptions
  - name: Addons
  - name: Customers
  - name: Refunds
  - name: Disputes
  - name: Events
  - name: License Keys
  - name: Entitlements
  - name: Licenses
  - name: Discounts
  - name: Meters
  - name: Credit Entitlements
  - name: Credit Entitlement Balances
  - name: Outgoing Webhooks
  - name: Checkout
  - name: Webhook Events
  - name: Payment Connector Webhooks
  - name: Moderation
paths:
  /subscriptions/{subscription_id}/change-plan:
    post:
      tags:
        - Subscriptions
      operationId: update_subscription_plan_handler
      parameters:
        - name: subscription_id
          in: path
          description: Subscription Id
          required: true
          schema:
            type: string
          example: sub_Iuaq622bbmmfOGrVTqdXv
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateSubscriptionPlanReq'
        required: true
      responses:
        '200':
          description: >-
            Subscription plan changed. A link request can return checkout
            details. A pending plan change applies after payment succeeds.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChangePlanResponse'
        '409':
          description: >-
            A pending plan change already exists for this subscription
            (PendingPlanChangeExists). With `cancel_older_payment_link`, the
            previous payment is paid (PlanChangePaymentAlreadyCompleted) or in
            progress (PlanChangePaymentInProgress).
        '422':
          description: >-
            Subscription is inactive or on-demand, or the payment-link request
            is not supported.
        '500':
          description: Something went wrong :(
        '503':
          description: >-
            The previous payment link could not be cancelled
            (PlanChangeLinkCancelFailed). A retry is safe.
      security:
        - API_KEY: []
      x-codeSamples:
        - lang: JavaScript
          source: >-
            import DodoPayments from 'dodopayments';


            const client = new DodoPayments({
              bearerToken: process.env['DODO_PAYMENTS_API_KEY'], // This is the default and can be omitted
            });


            const response = await
            client.subscriptions.changePlan('sub_Iuaq622bbmmfOGrVTqdXv', {
              product_id: 'product_id',
              proration_billing_mode: 'prorated_immediately',
              quantity: 0,
            });


            console.log(response.payment_id);
        - lang: Python
          source: |-
            import os
            from dodopayments import DodoPayments

            client = DodoPayments(
                bearer_token=os.environ.get("DODO_PAYMENTS_API_KEY"),  # This is the default and can be omitted
            )
            response = client.subscriptions.change_plan(
                subscription_id="sub_Iuaq622bbmmfOGrVTqdXv",
                product_id="product_id",
                proration_billing_mode="prorated_immediately",
                quantity=0,
            )
            print(response.payment_id)
        - lang: Go
          source: "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/dodopayments/dodopayments-go\"\n\t\"github.com/dodopayments/dodopayments-go/option\"\n)\n\nfunc main() {\n\tclient := dodopayments.NewClient(\n\t\toption.WithBearerToken(\"My Bearer Token\"),\n\t)\n\tresponse, err := client.Subscriptions.ChangePlan(\n\t\tcontext.TODO(),\n\t\t\"sub_Iuaq622bbmmfOGrVTqdXv\",\n\t\tdodopayments.SubscriptionChangePlanParams{\n\t\t\tUpdateSubscriptionPlanReq: dodopayments.UpdateSubscriptionPlanReqParam{\n\t\t\t\tProductID:            dodopayments.F(\"product_id\"),\n\t\t\t\tProrationBillingMode: dodopayments.F(dodopayments.UpdateSubscriptionPlanReqProrationBillingModeProratedImmediately),\n\t\t\t\tQuantity:             dodopayments.F(int64(0)),\n\t\t\t},\n\t\t},\n\t)\n\tif err != nil {\n\t\tpanic(err.Error())\n\t}\n\tfmt.Printf(\"%+v\\n\", response.PaymentID)\n}\n"
        - lang: Java
          source: >-
            package com.dodopayments.api.example;


            import com.dodopayments.api.client.DodoPaymentsClient;

            import com.dodopayments.api.client.okhttp.DodoPaymentsOkHttpClient;

            import
            com.dodopayments.api.models.subscriptions.SubscriptionChangePlanParams;

            import
            com.dodopayments.api.models.subscriptions.SubscriptionChangePlanResponse;

            import
            com.dodopayments.api.models.subscriptions.UpdateSubscriptionPlanReq;


            public final class Main {
                private Main() {}

                public static void main(String[] args) {
                    DodoPaymentsClient client = DodoPaymentsOkHttpClient.fromEnv();

                    SubscriptionChangePlanParams params = SubscriptionChangePlanParams.builder()
                        .subscriptionId("sub_Iuaq622bbmmfOGrVTqdXv")
                        .updateSubscriptionPlanReq(UpdateSubscriptionPlanReq.builder()
                            .productId("product_id")
                            .prorationBillingMode(UpdateSubscriptionPlanReq.ProrationBillingMode.PRORATED_IMMEDIATELY)
                            .quantity(0)
                            .build())
                        .build();
                    SubscriptionChangePlanResponse response = client.subscriptions().changePlan(params);
                }
            }
        - lang: Kotlin
          source: >-
            package com.dodopayments.api.example


            import com.dodopayments.api.client.DodoPaymentsClient

            import com.dodopayments.api.client.okhttp.DodoPaymentsOkHttpClient

            import
            com.dodopayments.api.models.subscriptions.SubscriptionChangePlanParams

            import
            com.dodopayments.api.models.subscriptions.SubscriptionChangePlanResponse

            import
            com.dodopayments.api.models.subscriptions.UpdateSubscriptionPlanReq


            fun main() {
                val client: DodoPaymentsClient = DodoPaymentsOkHttpClient.fromEnv()

                val params: SubscriptionChangePlanParams = SubscriptionChangePlanParams.builder()
                    .subscriptionId("sub_Iuaq622bbmmfOGrVTqdXv")
                    .updateSubscriptionPlanReq(UpdateSubscriptionPlanReq.builder()
                        .productId("product_id")
                        .prorationBillingMode(UpdateSubscriptionPlanReq.ProrationBillingMode.PRORATED_IMMEDIATELY)
                        .quantity(0)
                        .build())
                    .build()
                val response: SubscriptionChangePlanResponse = client.subscriptions().changePlan(params)
            }
        - lang: Ruby
          source: |-
            require "dodopayments"

            dodo_payments = Dodopayments::Client.new(
              bearer_token: "My Bearer Token",
              environment: "test_mode" # defaults to "live_mode"
            )

            response = dodo_payments.subscriptions.change_plan(
              "sub_Iuaq622bbmmfOGrVTqdXv",
              product_id: "product_id",
              proration_billing_mode: :prorated_immediately,
              quantity: 0
            )

            puts(response)
        - lang: PHP
          source: |-
            <?php

            require_once dirname(__DIR__) . '/vendor/autoload.php';

            use Dodopayments\Client;
            use Dodopayments\Core\Exceptions\APIException;

            $client = new Client(
              bearerToken: getenv('DODO_PAYMENTS_API_KEY') ?: 'My Bearer Token',
              environment: 'test_mode',
            );

            try {
              $response = $client->subscriptions->changePlan(
                'sub_Iuaq622bbmmfOGrVTqdXv',
                productID: 'product_id',
                prorationBillingMode: 'prorated_immediately',
                quantity: 0,
                adaptiveCurrencyFeesInclusive: true,
                addons: [['addonID' => 'addon_id', 'quantity' => 0]],
                cancelOlderPaymentLink: true,
                cancelScheduledChangePlan: true,
                collectViaPaymentLink: true,
                discountCode: 'discount_code',
                discountCodes: ['string'],
                effectiveAt: 'immediately',
                metadata: ['foo' => 'string'],
                onPaymentFailure: 'prevent_change',
                returnURL: 'return_url',
              );

              var_dump($response);
            } catch (APIException $e) {
              echo $e->getMessage();
            }
        - lang: C#
          source: |-
            using System;
            using DodoPayments.Client;
            using DodoPayments.Client.Models.Subscriptions;

            DodoPaymentsClient client = new();

            SubscriptionChangePlanParams parameters = new()
            {
                SubscriptionID = "sub_Iuaq622bbmmfOGrVTqdXv",
                ProductID = "product_id",
                ProrationBillingMode = ProrationBillingMode.ProratedImmediately,
                Quantity = 0,
            };

            var response = await client.Subscriptions.ChangePlan(parameters);

            Console.WriteLine(response);
        - lang: Rust
          source: |-
            use dodopayments::Client;

            #[tokio::main]
            async fn main() -> dodopayments::Result<()> {
                let client = Client::from_env()?;
                let subscription_id = "subscription_id";
                let result = client
                    .subscriptions()
                    .change_plan()
                    .subscription_id(subscription_id)
                    .body(dodopayments::models::SubscriptionsChangePlanParams {
                            product_id: Some("product_id".to_string()),
                            proration_billing_mode: Some("prorated_immediately".to_string()),
                            quantity: Some(0),
                            ..Default::default()
                        })
                    .await?;
                println!("{result:?}");
                Ok(())
            }
components:
  schemas:
    UpdateSubscriptionPlanReq:
      type: object
      required:
        - product_id
        - quantity
        - proration_billing_mode
      properties:
        adaptive_currency_fees_inclusive:
          type:
            - boolean
            - 'null'
          description: >-
            Whether adaptive currency fees should be included in the price
            (true) or added on top (false).

            If not specified, uses the subscription's stored setting.
        addons:
          type:
            - array
            - 'null'
          items:
            $ref: '#/components/schemas/AttachAddonReq'
          description: |-
            Addons for the new plan.
            Note : Leaving this empty would remove any existing addons
        cancel_older_payment_link:
          type: boolean
          description: >-
            Cancel the payment link of a pending plan change, so that this
            change

            can replace it.


            The link is cancelled only if the customer has not started to pay. A

            paid or in-progress payment gives a `409`. A failed cancel gives a

            `503`, and a retry is safe.


            The request is validated before the cancel. A later failure, for
            example

            an amount below the minimum, leaves the subscription on its current
            plan

            with no open link. A retry is safe.


            The preview route shares this request body and ignores this field.
        cancel_scheduled_change_plan:
          type: boolean
          description: >-
            Replace a scheduled plan change with this one.


            The scheduled change is cancelled by the transaction that applies
            this

            change. A change that never applies leaves the schedule in place.


            `effective_at: next_billing_date` is allowed. The new schedule then

            replaces the old one in the request transaction.


            A pending plan change still gets a `409`. This field does not affect
            it.


            The preview route shares this request body, so a preview that sets
            this

            field also passes the scheduled-change `409`.
        collect_via_payment_link:
          type: boolean
          description: >-
            Collect the plan-change amount with a payment link. The customer
            then

            pays on a checkout page.


            The business needs the `allow_plan_change_via_payment_link`
            capability.

            The request needs `effective_at: immediately`. The request also
            needs

            `on_payment_failure: prevent_change`.


            The preview route shares this request body and ignores this field.
        discount_code:
          type:
            - string
            - 'null'
          description: >-
            DEPRECATED: Use discount_codes instead. Cannot be used together with
            discount_codes.
          deprecated: true
          x-stainless-deprecation-message: Use `discount_id` instead.
        discount_codes:
          type:
            - array
            - 'null'
          items:
            type: string
          description: >-
            Stacked discount codes to apply to the new plan. Max 20.

            Cannot be used together with discount_code.

            If provided, replaces any existing discount codes.

            Empty array removes all discounts.

            If not provided (None), existing discounts with
            preserve_on_plan_change=true are preserved.
        effective_at:
          $ref: '#/components/schemas/EffectiveAt'
          description: |-
            When to apply the plan change.
            - `immediately` (default): Apply the plan change right away
            - `next_billing_date`: Schedule the change for the next billing date
        metadata:
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/Metadata'
              description: >-
                Metadata for the payment. If not passed, the metadata of the
                subscription will be taken
        on_payment_failure:
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/OnPaymentFailure'
              description: >-
                Controls behavior when the plan change payment fails.

                - `prevent_change`: Keep subscription on current plan until
                payment succeeds

                - `apply_change` (default): Apply plan change immediately
                regardless of payment outcome


                If not specified, uses the business-level default setting.
        product_id:
          type: string
          description: Unique identifier of the product to subscribe to
        proration_billing_mode:
          $ref: '#/components/schemas/ProrationBillingMode'
          description: Proration Billing Mode
        quantity:
          type: integer
          format: int32
          description: Number of units to subscribe for. Must be at least 1.
          minimum: 0
        return_url:
          type:
            - string
            - 'null'
          description: >-
            The URL that receives the customer after they pay the payment link.

            Needs `collect_via_payment_link: true`. Without it, the request gets
            a

            `422`. A change that collects no money issues no link and does not
            use

            the URL. The preview route validates this field but does not use it.


            The redirect adds `subscription_id`, `payment_id` and `status`. The

            `status` value is the status of the plan-change payment. It is not
            the

            status of the subscription. When that payment fails, the
            subscription

            stays active on its current plan. To try again, call this endpoint

            again to get a new link. The new plan can apply after the redirect,

            when the payment webhook arrives.
    ChangePlanResponse:
      type: object
      description: >-
        Handles for a hosted checkout page that settles a plan change.


        The four fields repeat `UpdatePaymentMethodResponse` and a subset of

        `CreateSubscriptionResponse`. A shared type would rename the generated
        SDK

        types for all three routes, so each route keeps its own.
      properties:
        client_secret:
          type:
            - string
            - 'null'
          description: Client secret for an embedded checkout.
        expires_on:
          type:
            - string
            - 'null'
          format: date-time
          description: When the link stops working.
        payment_id:
          type:
            - string
            - 'null'
          description: Id of the payment that settles the plan change.
        payment_link:
          type:
            - string
            - 'null'
          description: Checkout page URL. Give this to the customer.
    AttachAddonReq:
      type: object
      title: Attach Addon Request
      required:
        - addon_id
        - quantity
      properties:
        addon_id:
          type: string
        quantity:
          type: integer
          format: int32
          description: Number of units of this addon.
          minimum: 0
    EffectiveAt:
      type: string
      description: When to apply a subscription plan change.
      enum:
        - immediately
        - next_billing_date
    Metadata:
      type: object
      title: Metadata
      description: >-
        Arbitrary key-value metadata. Values can be string, integer, number, or
        boolean.
      additionalProperties:
        oneOf:
          - type: string
            title: String
          - type: integer
            title: Integer
            format: int64
          - type: number
            title: Number
            format: double
          - type: boolean
            title: Boolean
        title: Metadata Value
        description: Metadata value can be a string, integer, number, or boolean
    OnPaymentFailure:
      type: string
      description: >-
        Specifies how to handle subscription plan changes when payment fails.


        This enum controls whether the subscription should be updated
        immediately

        or only after payment succeeds.
      enum:
        - prevent_change
        - apply_change
    ProrationBillingMode:
      type: string
      title: Proration Billing Mode
      enum:
        - prorated_immediately
        - full_immediately
        - difference_immediately
        - do_not_bill
  securitySchemes:
    API_KEY:
      type: http
      scheme: bearer

````

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