Skip to navigation

Change a price

Move a product and its existing subscriptions to a new price in one request, with a dry run to check the result first.
View as Markdown

You cannot change the amount of a price after you create it. To change what a plan costs, create a new price on the same product, then call the bulk-migrate endpoint. It does two things for each product you list:

  • It makes the new price the product’s default price.
  • If you set migrate_subscriptions to true, it moves subscriptions from the source price to the new price. The source price is the source_price_id you send. If you send none, it is the product’s current default price.

What happens to existing subscriptions

  • No charge at migration. The endpoint creates no invoice and no proration.
  • The new price applies from each subscription’s next invoice. That is the next renewal, the first paid invoice after a trial, or the invoice when a paused subscription resumes.
  • Metered prices bill the current period at the new price. Metered usage is billed at the end of the period, at the price the item is on then. So usage recorded before the migration in the current period is also billed at the new price. The new price must use the same meter (see the validation rules).
  • Invoices that already exist keep the old amount. For example, an open invoice on a past-due subscription still asks for the old amount.
  • Only active items on the source price move. An item that is scheduled to start at the end of the period is not active yet, so it keeps its price. When it starts, it bills the old price. To change it, update the subscription’s items.
  • A scheduled plan change still runs. If a subscription has a plan change scheduled for the end of the period, that change runs before the renewal. Items it changes get the price it was scheduled with, which can be the old price. Items it does not change keep the new price.
Subscription stateResult
active, active_unsettledMoved. The next renewal bills the new price.
trialingMoved. The first paid invoice bills the new price.
pausedMoved. The subscription bills the new price when it resumes.
past_dueMoved. Open invoices keep the old amount. The next renewal bills the new price.
incomplete, scheduledMoved.
cancelled and any other stateNot moved.

Migrate a price

curl -X POST https://app.paymentkit.com/api/{account_id}/prices/bulk-migrate \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"dry_run": false,
"migrations": [
{
"product_id": "prod_abc123",
"target_price_id": "price_new440",
"migrate_subscriptions": true
}
]
}'

To check the result first, send the same request with "dry_run": true. A dry run runs every check and changes nothing. In each result, subscriptions_migrated shows how many subscription items would move. After a real run, it shows how many moved.

A dry run plans the items in order, as the real run does. If two items name the same product, the second item uses the default price and the subscription moves that the first item would make.

Request fields

FieldRequiredDescription
migrationsYesList of products to migrate. At least one. Each item is checked and saved on its own.
migrations[].product_idYesThe product to migrate.
migrations[].target_price_idYesThe new price. It must belong to the product and it must be active. It becomes the product’s default price.
migrations[].migrate_subscriptionsNoDefault false. When true, active subscription items on the source price move to the target price.
migrations[].source_price_idNoThe price to move subscriptions off. Default: the product’s current default price. It must belong to the same product and it must not be the target price. Use it when subscriptions are on a price that is not the default. Requires migrate_subscriptions: true. The item fails if you set it with migrate_subscriptions: false.
dry_runNoDefault false. When true, the endpoint runs every check and returns the results, including how many subscriptions would move, but changes nothing.

Response

The response is HTTP 200 even when some items fail. Check errors.

{
"dry_run": false,
"total_requested": 1,
"successful": 1,
"failed": 0,
"results": [
{
"product_id": "prod_abc123",
"old_default_price_id": "price_old449",
"new_default_price_id": "price_new440",
"source_price_id": "price_old449",
"subscriptions_migrated": 1250
}
],
"errors": []
}
FieldDescription
dry_runtrue when the request was a dry run, so nothing was changed.
total_requested, successful, failedCounts of migration items.
results[].old_default_price_idThe product’s default price before the migration. null if it had none.
results[].source_price_idThe price the subscriptions moved off. null when migrate_subscriptions is false or there was no source price.
results[].subscriptions_migratedNumber of subscription items moved. On a dry run, the number that would move.
errors[]product_id, target_price_id and an error message for each failed item.

Validation rules

Each item is checked before anything changes. An item that fails a check is listed in errors and changes nothing.

  • The product and the prices must exist in your account. An ID from another account is reported as “not found”.
  • The target price (and the source price, if given) must belong to the product.
  • The target price must be active.
  • source_price_id needs migrate_subscriptions set to true. If you give a source price and migrate_subscriptions is false, the item fails.
  • When migrate_subscriptions is true:
    • The target price must be recurring.
    • The source price must be recurring too. This includes the product’s current default price when you give no source_price_id. If the default is a one-time price, the item fails with “Source price is not recurring”.
    • The target price must match the source price: same currency, same billing interval (for example month), same interval count, and same usage type (licensed or metered). A metered target must use the same meter as the source.
    • If you give source_price_id, it must be different from target_price_id.
  • When migrate_subscriptions is false, the price checks are only the active check: currency, interval and the recurring check do not apply. No subscription changes, so you can set a one-time price as the default.
  • When migrate_subscriptions is true, the product has no default price and you give no source_price_id, there is nothing to move. Of the price checks, only the active and recurring checks apply.

If you give no source_price_id, the source price is the product’s current default price. If the target price is already the default price, no subscriptions move.

To move subscriptions to a different billing interval (for example monthly to yearly), use subscription change requests instead. This endpoint does not change a subscription’s billing interval, so it rejects those targets.

Limits

One request has two limits:

  • Migration items: at most 500 in migrations. A request with more returns 422 and nothing changes. Each item counts, even one that moves no subscriptions.
  • Subscription items: at most 7,500 moved in total. PaymentKit adds up the items that all migration items in the request would move, before it changes anything.

When the subscription-item total is more than 7,500:

  • The request returns 400. Nothing changes and no events are sent. A dry run returns the same 400.
  • If several migration items add up to more than 7,500, the message gives the limit and the total counted up to the first item over it (the full total can be higher). Split the migration items into smaller requests. If you split the items of one product across requests, give each item an explicit source_price_id. Without it, a later request reads the source as the product’s current default, which an earlier request may already have changed to the new price, and then it moves nothing.
  • If one migration item (one source price) alone would move more than 7,500 items, the message names its product. The items on one source price cannot be split into smaller requests, so bulk-migrate cannot move them. This is not supported yet. A product with items on several prices can still be split: send one item per source price, each with its source_price_id.

Rarely, subscriptions are added to a source price while a request runs, after the total was checked. If an item would then take the request over 7,500, the request still returns 200. The items before it are saved. That item and every item after it are listed in errors with a “Not applied” message, and nothing changed for them. Send them again in a new request.

Each item is all or nothing

For one item, the default price switch and all subscription moves are saved together. If something fails part way, none of that item’s changes are saved and the item is listed in errors. Each item is saved on its own, so a later failure does not undo an item that already succeeded.

When an item fails, PaymentKit does not apply the later items for the same product in that request. Each one is listed in errors with “Not applied: an earlier item for this product failed. Fix it and send all the items for this product again.” A dry run gives the same result. This rule prevents a later item from changing the default price that the failed item needs. Items for other products still run.

Events

For each subscription that moves, PaymentKit sends a customer.subscription.updated event. In the Events API, the event’s metadata has action: "items_changed", with previous_items and items showing the item’s price before and after. The product also gets a product.updated event for the new default price. See webhooks.

A dry run sends no events.

Example: lower a plan from 449to449 to 440 a month

Your “Pro” plan (prod_pro) has a default price of 449amonth(‘price449‘).Youwantnewandexistingsubscriberstopay449 a month (`price_449`). You want new and existing subscribers to pay 440 a month.

  1. Create a new recurring price on prod_pro: $440, USD, interval month, interval count 1. Say it gets the ID price_440.

  2. Run a dry run to see how many subscription items will move and to catch any errors:

    curl -X POST https://app.paymentkit.com/api/{account_id}/prices/bulk-migrate \
    -H "Authorization: Bearer sk_live_..." \
    -H "Content-Type: application/json" \
    -d '{
    "dry_run": true,
    "migrations": [
    {"product_id": "prod_pro", "target_price_id": "price_440", "migrate_subscriptions": true}
    ]
    }'

    Check results[0].subscriptions_migrated and errors. Nothing has changed yet.

  3. Send the same request with "dry_run": false.

After the migration:

  • price_440 is the default price of prod_pro.
  • Subscriptions on price_449 in the states listed in the table above (active, trialing, paused, past-due and others) now point at price_440. Cancelled subscriptions do not change. No one is charged now.
  • On each moved subscription’s next invoice, the moved item bills 440(unlessascheduledplanchangechangesthatitem,seeabove).Quantity,discounts,taxandotheritemsapplyasbefore.Anitemscheduledtostartattheendoftheperioddidnotmoveandstillbills440 (unless a scheduled plan change changes that item, see above). Quantity, discounts, tax and other items apply as before. An item scheduled to start at the end of the period did not move and still bills 449.
  • A past-due subscription’s open 449invoicestillasksfor449 invoice still asks for 449.

If some subscribers are on another $449 price that is not the default (for example price_449_legacy), send a second item for the same product with "source_price_id": "price_449_legacy" to move them too. You can send both items in one request. Put the item without source_price_id first: after an item runs, the product’s default price is the new price, so a later item without source_price_id has no subscriptions to move. If you send the items in separate requests, give each one an explicit source_price_id (here price_449 and price_449_legacy), so the order does not matter.