Plan Versions and Migrations
curl --request POST \
--url https://api.nozle.app/core/api/v1/plans/{code}/version-migrations \
--header 'Authorization: Bearer <token>'import requests
url = "https://api.nozle.app/core/api/v1/plans/{code}/version-migrations"
headers = {"Authorization": "Bearer <token>"}
response = requests.post(url, headers=headers)
print(response.text)const options = {method: 'POST', headers: {Authorization: 'Bearer <token>'}};
fetch('https://api.nozle.app/core/api/v1/plans/{code}/version-migrations', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.nozle.app/core/api/v1/plans/{code}/version-migrations"
req, _ := http.NewRequest("POST", url, nil)
req.Header.Add("Authorization", "Bearer <token>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}require 'uri'
require 'net/http'
url = URI("https://api.nozle.app/core/api/v1/plans/{code}/version-migrations")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
response = http.request(request)
puts response.read_body<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.nozle.app/core/api/v1/plans/{code}/version-migrations",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}HttpResponse<String> response = Unirest.post("https://api.nozle.app/core/api/v1/plans/{code}/version-migrations")
.header("Authorization", "Bearer <token>")
.asString();Checkout & Subscriptions
Plan Versions and Migrations
List a plan’s versions, sell an older one, and move subscribers from one version to another at the end of their billing period.
POST
/
core
/
api
/
v1
/
plans
/
{code}
/
version-migrations
Plan Versions and Migrations
curl --request POST \
--url https://api.nozle.app/core/api/v1/plans/{code}/version-migrations \
--header 'Authorization: Bearer <token>'import requests
url = "https://api.nozle.app/core/api/v1/plans/{code}/version-migrations"
headers = {"Authorization": "Bearer <token>"}
response = requests.post(url, headers=headers)
print(response.text)const options = {method: 'POST', headers: {Authorization: 'Bearer <token>'}};
fetch('https://api.nozle.app/core/api/v1/plans/{code}/version-migrations', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.nozle.app/core/api/v1/plans/{code}/version-migrations"
req, _ := http.NewRequest("POST", url, nil)
req.Header.Add("Authorization", "Bearer <token>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}require 'uri'
require 'net/http'
url = URI("https://api.nozle.app/core/api/v1/plans/{code}/version-migrations")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
response = http.request(request)
puts response.read_body<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.nozle.app/core/api/v1/plans/{code}/version-migrations",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}HttpResponse<String> response = Unirest.post("https://api.nozle.app/core/api/v1/plans/{code}/version-migrations")
.header("Authorization", "Bearer <token>")
.asString();Base URL:
Oldest first. Read one version in full with
It returns
https://api.nozle.app/core/api/v1.
Auth: secret key only. A publishable key gets 403 secret_key_required.
A change to what a plan bills or grants, made while customers pay for it, saves the plan’s next version under the same code. Existing subscribers stay on the version they are on; new subscriptions get the current version. These endpoints read the versions and move subscribers between them. See Plan versioning for how versions are made and sold.
List the versions of a plan
GET /plans/pro/versions
Authorization: Bearer sk_...
{
"versions": [
{ "nozle_id": "1a90…", "version": 1, "is_current": false, "created_at": "2026-10-01T09:00:00Z", "amount_cents": 4900, "amount_currency": "USD", "active_subscriptions_count": 42 },
{ "nozle_id": "2b81…", "version": 2, "is_current": true, "created_at": "2026-10-15T09:00:00Z", "amount_cents": 5900, "amount_currency": "USD", "active_subscriptions_count": 7 }
]
}
GET /plans/pro?version=1. Without version, or with a publishable key, GET /plans/{code} returns the current version.
Subscribe to an older version
POST /subscriptions
{ "subscription": { "external_customer_id": "cus_123", "plan_code": "pro", "plan_version": 1, "external_id": "sub_cus_123" } }
plan_version is optional; without it the subscription gets the current version. The Engine’s POST /subscribe takes the same field.
| Response | When |
|---|---|
200 | The subscription, with plan_version. Sending the same external_id and plan_code again returns it unchanged, on its own version. |
422 plan_version: plan_version_change_not_supported | The subscription exists on another version. Use a migration. |
404 plan_version_not_found | The code has no such version. |
Preview a migration
POST /plans/pro/version-migrations/preview
Content-Type: application/json
{ "from_version": 1 }
to_version is optional and defaults to the current version. Nothing is written.
{
"plan_version_migration_preview": {
"plan_code": "pro",
"from_version": 1,
"to_version": 2,
"items": [
{ "external_subscription_id": "sub_1", "external_customer_id": "cus_1", "outcome": "move", "skip_reason": null, "effective_on": "2026-11-01", "current_amount_cents": 4900, "new_amount_cents": 5900 },
{ "external_subscription_id": "sub_2", "external_customer_id": "cus_2", "outcome": "skip", "skip_reason": "custom_pricing", "effective_on": null, "current_amount_cents": 3900, "new_amount_cents": 5900 }
]
}
}
Start a migration
POST /plans/pro/version-migrations
{ "from_version": 1 }
200 with status: "running"; the migration then works through the subscriptions in the background. Each subscription that can move is scheduled to move at the end of its current billing period, like a scheduled downgrade. Nothing is invoiced, credited or refunded when it runs; the subscription’s next invoice bills the new version, and usage of the period that ended is billed at the old version’s rates.
| Response | When |
|---|---|
404 plan_not_found | Unknown code or version. |
409 plan_version_migration_running | Another migration of this code is running. |
422 to_version: same_version | Both versions are the same. |
422 to_version: plan_pending_deletion | The target version is being deleted. |
Follow, list and cancel
GET /plans/pro/version-migrations/{id} returns the migration with one item per subscription:
{
"plan_version_migration": {
"nozle_id": "7a51…",
"plan_code": "pro",
"from_version": 1,
"to_version": 2,
"status": "succeeded",
"matched_count": 3,
"succeeded_count": 2,
"skipped_count": 1,
"failed_count": 0,
"started_at": "2026-10-22T10:00:00Z",
"finished_at": "2026-10-22T10:00:04Z",
"canceled_at": null,
"created_at": "2026-10-22T10:00:00Z",
"items": [
{ "external_subscription_id": "sub_1", "status": "succeeded", "skip_reason": null, "error": null, "effective_at": "2026-11-01T00:00:00Z", "move_status": "pending" },
{ "external_subscription_id": "sub_2", "status": "skipped", "skip_reason": "custom_pricing", "error": null, "effective_at": null, "move_status": null }
]
}
}
| Field | Meaning |
|---|---|
status | running, then succeeded once every subscription was handled (each move is then scheduled), completed_with_errors when some failed, failed, or canceled. |
item status | succeeded (the move is scheduled or done), skipped (see skip_reason), failed (see error; a rerun retries it), canceled. |
item move_status | For a scheduled move: pending until the move day, moved once the subscription is on the new version, canceled when the subscription ended first. |
item effective_at | The day the subscription starts on the new version. |
GET /plans/pro/version-migrations lists the code’s migrations, newest first, without items. POST /plans/pro/version-migrations/{id}/cancel stops the migration and unschedules every move that has not happened; those subscribers stay on their version.
The skip reasons are listed in Plan versioning.
A migration never charges anyone when it runs, and a move never makes a credit note. Each subscriber pays the old version for the period they already paid for, then the new version from their next period.