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 MarketplaceWhat the script has to handle
- Pagination. Price lists go past 100 results. Use the SDK's auto-pagination.
- Skips. Subscriptions with a schedule attached (the next phase would overwrite your change) and multi-item subscriptions you haven't planned for.
- Idempotency. If the script crashes and you re-run it, nobody should get updated twice.
- Rate limits. Stripe allows roughly 100 requests per second in live mode, 25 in test. Stay well under.
- An audit log. One row per subscription with the outcome, so you can reconcile, retry failures, or roll back.
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
- Dry run in live mode. Run without
--liveand read the CSV: count, skips, anything unexpected. - Rehearse in test mode with a test key and a test clock, and fast-forward to renewal to see the real invoice.
- Pilot. Add a limit and run
--liveon five subscriptions. Check their upcoming invoices. - 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 script | Change Subscription Price App | |
|---|---|---|
| Who can run it | An engineer | Anyone with Stripe Dashboard access |
| Scheduling | Set up your own cron and monitoring | Built in: run now or pick a date |
| Audit trail | Whatever you log | CSV report per job |
| Item ID, quantity and proration handling | You code and test it | Built in |
| Rate limits and retries | You handle them | Batched for you |
| Flexibility | Unlimited | Covers 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 MarketplaceFrequently 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 MarketplaceRead the app user guide first · Questions? contact@finrite.co