Stripe price change guides

Writing a Stripe price migration script: a complete example

The API call is one line. The migration script around it — dry runs, pagination, retries, logging — is what keeps a billing change safe. Here's a version you can adapt.

8 min read · By , Founder · Updated

Short answer

List subscriptions with subscriptions.list({ price: OLD }), and for each one call subscriptions.update with the existing item ID, the new price, the existing quantity, and proration_behavior: 'none'. Wrap it in a dry-run flag, an idempotency key, throttling, and a CSV log. The full script is below.

Don't want to write and maintain it? The Change Subscription Price App does the same migration from your Stripe Dashboard with no code, with item swaps, quantities, no-proration and batching built in.

Change Subscription Price App — by FinriteUpdate a price across all your Stripe subscriptions from the dashboard, in bulk. Built by former Stripe Billing product managers and engineers.

View on Stripe Marketplace

What the script has to handle

The script

// migrate-price.mjs — node migrate-price.mjs price_old price_new [--live]
import Stripe from 'stripe';
import fs from 'node:fs';

const [OLD, NEW] = process.argv.slice(2);
const LIVE = process.argv.includes('--live');   // default is a dry run
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const log = fs.createWriteStream(`migration-${Date.now()}.csv`);
log.write('subscription,customer,status,result,detail\n');

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

for await (const sub of stripe.subscriptions.list({ price: OLD, status: 'all', limit: 100 })) {
  if (!['active', 'trialing', 'past_due'].includes(sub.status)) continue;
  const row = (result, detail = '') =>
    log.write(`${sub.id},${sub.customer},${sub.status},${result},${detail}\n`);

  if (sub.schedule) { row('skipped', 'has subscription schedule'); continue; }
  const item = sub.items.data.find((i) => i.price.id === OLD);
  if (sub.items.data.length > 1) { row('skipped', 'multiple items'); continue; }

  if (!LIVE) { row('dry-run', `${item.id} qty ${item.quantity}`); continue; }

  try {
    await stripe.subscriptions.update(
      sub.id,
      {
        items: [{ id: item.id, price: NEW, quantity: item.quantity }],
        proration_behavior: 'none',
      },
      { idempotencyKey: `migrate-${sub.id}-${NEW}` },
    );
    row('updated');
  } catch (err) {
    row('failed', JSON.stringify(err.message));
  }
  await sleep(50);   // ~20 req/s, far under the live-mode limit
}
log.end();

The SDK already retries network errors and 429 responses. Raise maxNetworkRetries in the client options for large runs.

How to run it safely

  1. Dry run in live mode. Run without --live and read the CSV: count, skips, anything unexpected.
  2. Rehearse in test mode with a test key and a test clock, and fast-forward to renewal to see the real invoice.
  3. Pilot. Add a limit and run --live on five subscriptions. Check their upcoming invoices.
  4. Full run, then re-run for failures only. The idempotency key makes re-runs safe within Stripe's idempotency window, and the price filter means migrated subscriptions drop out of the list anyway.

Subscriptions created by Substack, beehiiv or Ghost

If a platform created the subscriptions, run the script with your own secret or restricted key, not through another platform's integration. Stripe doesn't let one platform modify subscriptions another created. Each platform has its own rules on which prices to use, so read the Substack, beehiiv or Ghost guide first.

Script or app?

Your own scriptChange Subscription Price App
Who can run itAn engineerAnyone with Stripe Dashboard access
SchedulingSet up your own cron and monitoringBuilt in: run now or pick a date
Audit trailWhatever you logCSV report per job
Item ID, quantity and proration handlingYou code and test itBuilt in
Rate limits and retriesYou handle themBatched for you
FlexibilityUnlimitedCovers standard price swaps; Subscription Schedules not yet supported

Change Subscription Price App — by FinriteUpdate a price across all your Stripe subscriptions from the dashboard, in bulk. Built by former Stripe Billing product managers and engineers.

View on Stripe Marketplace

Frequently asked questions

What's the API call to change a Stripe subscription's price?

POST /v1/subscriptions/{id} with items[0][id] set to the existing subscription item ID, items[0][price] set to the new price ID, and proration_behavior=none. Without the item ID, Stripe adds the new price as a second item and both get billed.

How do I list every subscription on a specific price?

subscriptions.list accepts a price parameter that filters to subscriptions containing that recurring price. Combine it with status and page through results with auto-pagination.

What are Stripe's API rate limits for a bulk migration?

Stripe documents a global limit of around 100 requests per second in live mode and 25 in test mode or sandboxes, with lower limits on some endpoints. Run the migration well below that and retry 429 responses with backoff.

Do I need to pass quantity when changing the price?

For per-seat or multi-quantity subscriptions, pass the existing quantity alongside the new price, and check afterwards that it's unchanged. Stripe's change-price docs warn that quantity can reset if you don't.

Is there a no-code alternative to writing this script?

Yes. The Change Subscription Price App is a Stripe App that runs the same migration from the Dashboard, with run-now or scheduled jobs and a CSV report per job.

Related guides

Rather not maintain a migration script?

The Change Subscription Price App runs this whole loop from your Stripe Dashboard: filtering, batching, no-proration updates, scheduling, and a per-subscription CSV report.

Install from the Stripe App Marketplace

Read the app user guide first · Questions? contact@finrite.co