Skip to main content
Skip to navigation

Web SDK product changes

Let existing subscribers upgrade or downgrade their subscription from your web app with the Web SDK

AIAsk AIChatGPTClaude

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.

⚠️Feature in beta

Product changes in the Web SDK are in beta. They support RevenueCat Billing and Stripe Billing subscriptions, but not Paddle Billing yet. See 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.

RevenueCat Billing customers can already change their subscription themselves from the 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 providerProduct changes in the Web SDK
RevenueCat Billing✅ Supported
Stripe Billing✅ Supported
Paddle Billing❌ 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.

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 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 and the subscription lifecycle 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

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.

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 endpoint, so the client can pass it in productChangeInfo (see Identifying the source subscription):

// 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.

❗️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 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 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:

// 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:

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 endpoint and pass it to the client alongside the token, as in the token endpoint example 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:

// @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 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, 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.

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. See 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 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 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 — 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 as usual, and starting a new product change on the same source subscription overwrites the pending one.

Next steps

Was this page helpful?