---
id: "web/product-changes"
title: "Web SDK product changes"
description: "Product changes let an existing subscriber upgrade or downgrade to a different product directly from your web app, using the same purchase and paywall flows you already use for new subscriptions. Product changes work for subscriptions billed through RevenueCat Billing or Stripe Billing. This article explains how product changes work in the Web SDK (purchases-js) and shows you how to configure change paths, mint subscriber tokens on your backend, and start a product change from a purchase or a paywall."
permalink: "/docs/web/product-changes"
slug: "/web/product-changes"
version: "current"
original_source: "docs/web/product-changes.mdx"
---

> **AI agents:** This is the Markdown version of a RevenueCat documentation page. For the complete documentation index, see [llms.txt](https://www.revenuecat.com/docs/llms.txt).

Product changes let an existing subscriber upgrade or downgrade to a different product directly from your web app, using the same purchase and paywall flows you already use for new subscriptions. Product changes work for subscriptions billed through RevenueCat Billing or Stripe Billing. This article explains how product changes work in the Web SDK (`purchases-js`) and shows you how to configure change paths, mint subscriber tokens on your backend, and start a product change from a purchase or a paywall.

:::warning Feature in beta
Product changes in the Web SDK are in beta. They support [RevenueCat Billing](https://www.revenuecat.com/docs/web/web-billing/configuring-overview) and [Stripe Billing](https://www.revenuecat.com/docs/web/integrations/stripe) subscriptions, but not Paddle Billing yet. See [Beta limitations](#beta-limitations) for details.
:::

## How it works

The standard purchase flow always starts a new subscription. A product change instead moves an existing subscriber from their current product to a new one, handling the billing transition for you:

1. **Your backend mints a subscriber token.** It calls RevenueCat's server-side API to issue a short-lived access token for the customer, using your secret API key.
2. **Your web app starts the flow.** It passes the token in `productChangeInfo` to either `purchase()` or `presentPaywall()`.
3. **RevenueCat checks the configured change paths.** If a path exists from the customer's current product to the selected product, checkout opens in product-change mode and processes the transaction as an upgrade or a downgrade accordingly.
4. **If no change path exists,** the flow falls back to a regular purchase and creates a new subscription instead.

:::info
RevenueCat Billing customers can already change their subscription themselves from the [Customer Portal](https://www.revenuecat.com/docs/web/web-billing/customer-portal). Product changes in the Web SDK let you build the same capability into your own app, on your own paywall or purchase flow.
:::

### Supported billing providers

Product changes are available for subscriptions billed through RevenueCat Billing and Stripe Billing. Paddle Billing isn't supported yet.

| Billing provider                                            | Product changes in the Web SDK |
| ----------------------------------------------------------- | ------------------------------ |
| [RevenueCat Billing](https://www.revenuecat.com/docs/web/web-billing/configuring-overview) | ✅ Supported                   |
| [Stripe Billing](https://www.revenuecat.com/docs/web/integrations/stripe)                  | ✅ Supported                   |
| [Paddle Billing](https://www.revenuecat.com/docs/web/integrations/paddle)                  | ❌ Not yet supported           |

### Change paths

A change path defines that a subscriber is allowed to move from one product to another, and whether that move is an upgrade (a move upwards) or a downgrade (a move downwards). Paths are configured per billing provider in the RevenueCat dashboard under the **Product catalog**. For RevenueCat Billing, they're the same paths that power subscription changes in the Customer Portal. See [Defining subscription change paths](https://www.revenuecat.com/docs/web/web-billing/product-setup#defining-subscription-change-paths).

### Currency

Product changes are only supported between products in the same currency, on both RevenueCat Billing and Stripe Billing. Customers can only change to a product that has a price in their existing currency, so every target product you offer needs a price in the currency the customer is already billed in. For RevenueCat Billing prices, see [Supporting multiple currencies](https://www.revenuecat.com/docs/web/web-billing/multi-currency-support) for how prices are set per currency.

### Upgrade and downgrade behavior

Billing behavior follows the direction of the change path. How the unused time on the current subscription is handled depends on the billing provider:

- **Upgrades apply immediately.** The customer moves to the new product and is charged for it right away. On RevenueCat Billing, they receive a prorated refund for any unused time on their current subscription. On Stripe Billing, Stripe applies a prorated credit for the unused time against the new purchase by default, rather than issuing a refund.
- **Downgrades are scheduled.** On both providers, the customer keeps their current product until the end of the current billing cycle, and the new product takes effect at the next renewal.

For RevenueCat Billing, see [Upgrade behavior and Downgrade behavior](https://www.revenuecat.com/docs/web/web-billing/product-setup#upgrade-behavior) and the [subscription lifecycle](https://www.revenuecat.com/docs/web/web-billing/subscription-lifecycle#subscription-change-upgrade-or-downgrade) for the full behavior, including how free trials and discounts are handled. For Stripe Billing, Stripe manages the proration and billing logic, so check your Stripe subscription settings for how the change is billed.

### Why a backend token is required

A product change modifies a customer's existing subscription, so it must be authenticated. Your backend holds a secret API key and uses it to mint a short-lived subscriber access token from RevenueCat's server-side API. Only the token is sent to the browser, which keeps the secret key out of your frontend code and ensures product changes can only be started for customers you've authenticated.

## Prerequisites

- The [Web SDK](https://www.revenuecat.com/docs/web/web-billing/web-sdk) integrated in your app, configured with [RevenueCat Billing](https://www.revenuecat.com/docs/web/web-billing/configuring-overview) or [Stripe Billing](https://www.revenuecat.com/docs/web/integrations/stripe).
- Products and Offerings set up for your billing provider: [created in RevenueCat Billing](https://www.revenuecat.com/docs/web/web-billing/product-setup) or [imported from Stripe](https://www.revenuecat.com/docs/web/integrations/stripe#4-import-stripe-products-into-revenuecat).
- A [secret API v2 key](https://www.revenuecat.com/docs/projects/authentication) with the `iam:authorization:issue_token` permission, plus `customer_information:subscriptions:read` if your endpoint also looks up the customer's subscription ID as shown below.
- A backend where you can add an endpoint. Never ship your API secret in frontend code.

## Configure product change paths

Product changes only take place between products with a change path defined between them. If the customer's current product has no change paths, the flow creates a new purchase instead.

To define paths, go to the **Product catalog** in the RevenueCat dashboard, open the **Products** tab, and select **Subscription changes** for your web billing provider. Each rule names a source product, the products it can be upgraded to, and the products it can be downgraded to. For step-by-step instructions, see [Defining subscription change paths](https://www.revenuecat.com/docs/web/web-billing/product-setup#defining-subscription-change-paths).

## Create a token endpoint on your backend

Add an endpoint that your web app can call to fetch a subscriber access token for the signed-in customer. The endpoint resolves the customer's App User ID from their authenticated session, then calls RevenueCat's `authenticate` endpoint with your secret API key. It also looks up the ID of the subscription being changed with the [list subscriptions](https://www.revenuecat.com/api-v2#tag/Customer/operation/list-subscriptions) endpoint, so the client can pass it in `productChangeInfo` (see [Identifying the source subscription](#identifying-the-source-subscription)):

**Node.js**

```js
// POST /api/product-change-token
// Mints a short-lived subscriber access token for the signed-in customer
// and looks up the ID of the subscription being changed.
// RC_SECRET_API_KEY, RC_PROJECT_ID and RC_APP_ID are server-side env vars —
// the secret API key must never be exposed to the browser.
export async function handleProductChangeToken(req, res) {
  // Take the App User ID from the authenticated session, never from the
  // request body. A body-supplied ID would let any caller mint a token for
  // another customer and change their subscription.
  const appUserId = req.session?.appUserId;
  if (!appUserId) {
    res.status(401).json({ error: "Not authenticated" });
    return;
  }

  const response = await fetch(
    `https://api.revenuecat.com/v2/projects/${process.env.RC_PROJECT_ID}/apps/${process.env.RC_APP_ID}/authenticate`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.RC_SECRET_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ app_user_id: appUserId }),
    },
  );

  const data = await response.json();
  if (!response.ok) {
    res.status(response.status).json(data);
    return;
  }

  // Look up the subscription being changed, so the client can identify it
  // unambiguously with its ID. If you already track RevenueCat subscription
  // IDs in your own database, use that instead of calling the API here.
  const subscriptionsResponse = await fetch(
    `https://api.revenuecat.com/v2/projects/${process.env.RC_PROJECT_ID}/customers/${appUserId}/subscriptions`,
    {
      headers: { Authorization: `Bearer ${process.env.RC_SECRET_API_KEY}` },
    },
  );
  const subscriptions = await subscriptionsResponse.json();
  // With a single active subscription this picks the right one. If your
  // customers can hold several, select the subscription being changed.
  const subscription = subscriptions.items?.find((s) => s.gives_access);

  // Only the short-lived token reaches the browser, never the secret key.
  res.json({
    access_token: data.access_token,
    expires_at: data.expires_at,
    subscription_id: subscription?.id,
  });
}
```

The response contains a short-lived `access_token` to pass to the SDK, its `expires_at` timestamp, and the `subscription_id` of the subscription being changed.

:::danger Keep the secret key server-side
The secret API key can issue tokens for any customer. Never bundle it into frontend code, and never mint a token for an App User ID taken from the request — a caller could then change another customer's subscription. Always derive the App User ID from the authenticated session.
:::

## Start a product change

You can start a product change directly with `purchase()`, or let the customer pick their new product from a [Web Paywall](https://www.revenuecat.com/docs/web/paywalls) with `presentPaywall()`. In both cases, pass a `productChangeInfo` object containing the subscriber token.

### The API is internal during the beta

`productChangeInfo` — on both `purchase()` and `presentPaywall()` — and `PurchaseResult.productChange` are tagged `@internal` in the SDK while the feature is in beta. This has two practical consequences:

- **TypeScript projects need to suppress type errors.** The internal fields are stripped from the SDK's published type definitions, so passing `productChangeInfo` or reading `productChange` fails to compile. Add a `// @ts-expect-error` comment above each usage, as the examples below do. The `ProductChangeInfo` type isn't exported either, so declare the object inline rather than importing a type for it. Plain JavaScript projects work without changes, but editors won't autocomplete the internal fields.
- **The API may change in any release.** Internal APIs are excluded from the SDK's semver guarantees, so these field names, shapes, and behavior can change — even in a patch release — until the feature reaches general availability. Pin an exact `purchases-js` version while you're on the beta, and check the [changelog](https://github.com/RevenueCat/purchases-js/releases) before upgrading. Once the API becomes public at GA, remove the `// @ts-expect-error` comments: an unused directive is itself a compile error.

### With purchase()

Fetch a token and the subscription ID from your backend, then call `purchase()` with the target Package and `productChangeInfo`:

**Web (JS/TS)**

```ts
// Fetch a short-lived subscriber token and the ID of the subscription being
// changed from your backend. Your endpoint identifies the customer from
// their session, so send nothing in the body.
const tokenResponse = await fetch("/api/product-change-token", {
  method: "POST",
});
const { access_token: subscriberToken, subscription_id: subscriptionId } =
  await tokenResponse.json();

// Start the purchase flow in product-change mode.
const purchaseResult = await purchases.purchase({
  rcPackage: targetPackage, // the package the subscriber is changing to
  // @ts-expect-error productChangeInfo is marked as internal during the beta
  productChangeInfo: {
    // Uniquely identifies the subscription being changed — see "Identifying
    // the source subscription" below.
    subscriptionId,
    subscriberToken,
  },
});
```

### From a paywall

Pass `productChangeInfo` to `presentPaywall()` to present the paywall in product-change mode. When the customer picks a Package, checkout starts as a product change if a change path exists from their current product; otherwise it starts as a regular purchase:

**Web (JS/TS)**

```ts
const purchaseResult = await purchases.presentPaywall({
  htmlTarget: document.getElementById("paywall-container"),
  // Optionally pass an offering from purchases.getOfferings();
  // otherwise the customer's current Offering is used.
  // @ts-expect-error productChangeInfo is marked as internal during the beta
  productChangeInfo: {
    subscriptionId,
    subscriberToken,
  },
});
```

### Identifying the source subscription

`productChangeInfo` accepts two optional fields to identify the subscription being changed:

- `subscriptionId` — the subscription's public ID (`sub...`). This always uniquely identifies the source subscription. Your backend can fetch it with the REST API's [list subscriptions](https://www.revenuecat.com/api-v2#tag/Customer/operation/list-subscriptions) endpoint and pass it to the client alongside the token, as in the [token endpoint example](#create-a-token-endpoint-on-your-backend) above.
- `productIdentifier` — the product identifier of the current subscription, available client-side from `CustomerInfo.activeSubscriptions` via `getCustomerInfo()`. RevenueCat infers the source subscription from the product, which fails with an error if the customer holds more than one non-expired subscription to the same product.

Prefer `subscriptionId` whenever you can determine one: it identifies the subscription directly, while `productIdentifier` relies on inference and is best treated as a fallback for setups where a customer can only ever hold one subscription per product.

You can provide either, both (they must refer to the same subscription), or neither. When both are omitted, RevenueCat infers the source on the assumption that the customer has exactly one active subscription. If a customer can hold multiple active subscriptions, always provide `subscriptionId`.

### Handle the result

Both `purchase()` and `presentPaywall()` resolve with a `PurchaseResult`. When a product change took place, its `productChange` property describes the outcome:

**Web (JS/TS)**

```ts
// @ts-expect-error productChange is marked as internal during the beta
const change = purchaseResult.productChange;

if (change) {
  // The product the customer changed to.
  const newProductId = purchaseResult.storeTransaction.productIdentifier;

  if (change.changeType === "immediate") {
    // Upgrade: the customer is on the new product now.
    console.log(`Switched to ${newProductId}`);
  } else {
    // Downgrade: the change is scheduled for the end of the
    // current billing cycle.
    console.log(`Will switch to ${newProductId} at renewal`);
  }
} else {
  // No product change took place — this was a regular purchase.
}
```

`changeType` is `"immediate"` for upgrades and `"deferred"` for downgrades, matching the direction of the configured change path. The identifier of the product the customer changed to is available on `purchaseResult.storeTransaction.productIdentifier`. `productChange` is absent when the flow fell back to a regular purchase.

## Beta limitations

While product changes in the Web SDK are in beta, the following limitations apply:

- **Paddle Billing isn't supported yet.** Product changes work for RevenueCat Billing and Stripe Billing subscriptions. Subscriptions billed through Paddle can't be changed through this flow yet; support is planned.
- **Billing behavior isn't configurable in RevenueCat.** Upgrades always apply immediately and downgrades always take effect at the end of the current billing cycle. On RevenueCat Billing, upgrades issue a prorated refund for unused time. On Stripe Billing, Stripe manages proration and applies a prorated credit by default.
- **The API is marked internal.** `productChangeInfo` and `PurchaseResult.productChange` are excluded from the SDK's public TypeScript definitions and semver guarantees during the beta. See [The API is internal during the beta](#the-api-is-internal-during-the-beta) for what this means for your code.
- **Modifying payment information not possible.** Existing payment method and billing addresses are used and previewed in the checkout, but aren't editable — this functionality is planned for a future update.
- **Web SDK only.** It's not currently possible to handle product changes through Web Purchase Links or Funnels.

## FAQs

### Which version of the Web SDK do I need?

Use the latest `purchases-js` version, and at least 1.52.3. Because the product change API is [internal during the beta](#the-api-is-internal-during-the-beta), TypeScript projects need to suppress type errors when using it.

### Will the API change before general availability?

The runtime shape of `productChangeInfo` and `PurchaseResult.productChange` is unlikely to change before the public release, but while the API is marked internal it isn't covered by the SDK's semver guarantees, so a change can't be ruled out. This applies equally if you consume the UMD bundle, where the TypeScript definitions don't affect you. Pin an exact SDK version and check the changelog before upgrading — see [The API is internal during the beta](#the-api-is-internal-during-the-beta).

### Can I limit which products customers can change to?

Yes. A product change can only follow a change path you've defined, so define paths only for the moves you want to allow — for example, only from your monthly product to your annual product, leaving discounted or cohort-specific products without a path. Keep in mind that on a paywall, selecting a product with no change path from the customer's current product [falls back to a regular purchase](#how-it-works). See [Defining subscription change paths](https://www.revenuecat.com/docs/web/web-billing/product-setup#defining-subscription-change-paths).

### Can a customer change from a monthly to an annual plan of the same tier?

Yes. A billing-cadence change is treated like any other product change: define a change path from the monthly product to the annual product, and the standard billing behavior applies — configured as an upgrade, the change takes effect immediately and the unused time is prorated, as a refund on RevenueCat Billing or a credit on Stripe Billing.

### Do trials and discounts carry over to the new product?

No. An active free trial or discount ends when the product change takes effect, and doesn't carry over to the new subscription. Optionally carrying discounts over — prorated onto the new subscription — is planned for a future update.

### Which webhook events are sent for a product change?

The events depend on the billing provider.

- **RevenueCat Billing.** For an immediate upgrade, RevenueCat sends `INVOICE_ISSUANCE`, `PRODUCT_CHANGE`, and `RENEWAL` together once payment succeeds. For a scheduled downgrade, `PRODUCT_CHANGE` is sent when the downgrade is scheduled, followed by `INVOICE_ISSUANCE` and `RENEWAL` at the start of the next billing cycle.
- **Stripe Billing.** RevenueCat sends `PRODUCT_CHANGE` when the change takes effect. `INVOICE_ISSUANCE` is only sent for RevenueCat Billing purchases, so don't wait for it on Stripe subscriptions.

See [Webhook event types and fields](https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields) for the events each billing provider supports.

### Does the `PRODUCT_CHANGE` event include the source and destination products?

On RevenueCat Billing, yes: `product_id` is the product the customer changed from, and `new_product_id` is the product they changed to. On Stripe Billing, `product_id` carries the Stripe product ID, and `price_id` and `new_price_id` identify the prices the customer changed from and to. See [Product change events](https://www.revenuecat.com/docs/web/integrations/stripe/multi-price-migration#product-change-events) in the Stripe multi-price guide.

### How can I tell that a subscription has a pending downgrade?

A pending downgrade isn't reflected in `CustomerInfo`. It's visible on the subscription object in the REST API — for example via [list subscriptions](https://www.revenuecat.com/api-v2#tag/Customer/operation/list-subscriptions) — where `pending_changes` describes the product that takes effect at the next renewal, and `auto_renewal_status` is `will_change_product`.

### Can a pending downgrade be cancelled through the Web SDK?

Not directly during the beta. RevenueCat Billing customers can cancel a scheduled change from the [Customer Portal](https://www.revenuecat.com/docs/web/web-billing/customer-portal) as usual, and starting a new product change on the same source subscription overwrites the pending one.

## Next steps

- Define which moves are allowed between your products in [subscription change paths](https://www.revenuecat.com/docs/web/web-billing/product-setup#configure-subscription-changes).
- Review how changes affect billing in the [subscription lifecycle](https://www.revenuecat.com/docs/web/web-billing/subscription-lifecycle).
- Present your Offerings with a [Web Paywall](https://www.revenuecat.com/docs/web/paywalls).
