Plan Transition Policies
curl --request PUT \
--url https://api.nozle.app/core/api/v1/plans/{plan_code}/transition-policies/{target_plan_code} \
--header 'Authorization: Bearer <token>'import requests
url = "https://api.nozle.app/core/api/v1/plans/{plan_code}/transition-policies/{target_plan_code}"
headers = {"Authorization": "Bearer <token>"}
response = requests.put(url, headers=headers)
print(response.text)const options = {method: 'PUT', headers: {Authorization: 'Bearer <token>'}};
fetch('https://api.nozle.app/core/api/v1/plans/{plan_code}/transition-policies/{target_plan_code}', 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/{plan_code}/transition-policies/{target_plan_code}"
req, _ := http.NewRequest("PUT", 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/{plan_code}/transition-policies/{target_plan_code}")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Put.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/{plan_code}/transition-policies/{target_plan_code}",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "PUT",
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.put("https://api.nozle.app/core/api/v1/plans/{plan_code}/transition-policies/{target_plan_code}")
.header("Authorization", "Bearer <token>")
.asString();Checkout & Subscriptions
Plan Transition Policies
Define how a subscription moves from one plan to another: direction, timing, billing anchor, proration, and settlement.
PUT
/
core
/
api
/
v1
/
plans
/
{plan_code}
/
transition-policies
/
{target_plan_code}
Plan Transition Policies
curl --request PUT \
--url https://api.nozle.app/core/api/v1/plans/{plan_code}/transition-policies/{target_plan_code} \
--header 'Authorization: Bearer <token>'import requests
url = "https://api.nozle.app/core/api/v1/plans/{plan_code}/transition-policies/{target_plan_code}"
headers = {"Authorization": "Bearer <token>"}
response = requests.put(url, headers=headers)
print(response.text)const options = {method: 'PUT', headers: {Authorization: 'Bearer <token>'}};
fetch('https://api.nozle.app/core/api/v1/plans/{plan_code}/transition-policies/{target_plan_code}', 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/{plan_code}/transition-policies/{target_plan_code}"
req, _ := http.NewRequest("PUT", 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/{plan_code}/transition-policies/{target_plan_code}")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Put.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/{plan_code}/transition-policies/{target_plan_code}",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "PUT",
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.put("https://api.nozle.app/core/api/v1/plans/{plan_code}/transition-policies/{target_plan_code}")
.header("Authorization", "Bearer <token>")
.asString();Base URL:
Validation rules:
An unknown target plan returns
Downgrade at the end of the period. Subscription options list a downgrade only when it uses
Other
A new scheduled change while a change is already pending replaces the pending change and returns a new pending subscription ID. Withdrawing the old ID then returns
https://api.nozle.app/core/api/v1.
Auth: secret key only. When your organization has API key permissions, reads need plan_transition_policy:read and changes need plan_transition_policy:write. Keys that existed before this permission have the same access to it that they have to plan.
A plan transition policy tells Nozle how to move a subscription from one plan (the source) to another (the target). Policies are stored per pair of plans: starter to growth and growth to starter are two separate policies. Nozle does not infer the direction from the prices; you set it.
Changing an existing subscription’s plan needs a policy for that exact pair. Without one, these calls return 422 plan_transition_policy_required:
- Checkout for a customer that already has an active subscription, including Entity subscription checkout and change-plan
- Subscription change preview (
POST /subscriptions/preview) - Settlement transitions of type
downgrade, preview and apply
eligible_plans list usually means no policy is configured.
A subscription on an override plan (a plan made from a catalog plan for one customer, for example a negotiated price or a subscription created from a quote) uses the policies of the catalog plan it was made from. Its options list the same eligible plans as a subscription on the catalog plan, and preview, checkout and transitions use the catalog plan’s policy. The change replaces the override plan with the target catalog plan, so the negotiated price and other overrides do not carry over. A self-serve change from an override plan to its own catalog plan returns 422 plan_already_active; a merchant plan change through POST /subscriptions can still replace it with the catalog plan.
One fallback exists. When the current plan is free and the target plan is paid, Nozle uses the organization’s free-to-paid policy (Settings, General, “Checkout & billing policy” in the dashboard) as an upgrade. Every other pair, including paid to paid, paid to free, and free to free, needs its own policy. The dashboard sets only the free-to-paid default; create pair policies with this API.
Create or update a policy
PUT /plans/starter/transition-policies/growth
Authorization: Bearer sk_...
Content-Type: application/json
{
"plan_transition_policy": {
"direction": "upgrade",
"timing": "immediate",
"billing_anchor": "keep_anchor",
"proration_behavior": "prorate_immediately",
"credit_action": "credit"
}
}
PUT creates the policy for the pair, or updates the existing one. It returns 200 in both cases. On a new policy, fields you leave out take their defaults. On an existing policy, fields you leave out keep their current values. To return a field to its default, send the default value. PATCH on the same path does the same thing.
| Field | Values | Default | Meaning |
|---|---|---|---|
direction | upgrade, downgrade | required | How Nozle treats the change. An immediate upgrade is payment-gated; an immediate downgrade is applied at once. |
timing | immediate, end_of_period | immediate | When the change takes effect. |
billing_anchor | keep_anchor, reset_anchor | keep_anchor | Keep the current renewal day, or start a new period on the change date. |
proration_behavior | prorate_immediately, none | prorate_immediately | Whether the unused part of the current period is prorated. |
credit_action | credit, refund, offset, none | none | What happens to the unused amount of the current plan. |
refund_mode | prorated, full | prorated | How much is refunded when credit_action is refund. |
final_invoice_action | generate, skip | generate | Whether a final invoice is created for the old plan. |
policy | prorate_keep_anchor, full_term_reset_anchor, next_period | derived | A summary of the fields above. Nozle derives it: next_period for end_of_period timing, full_term_reset_anchor for reset_anchor, otherwise prorate_keep_anchor. You can send only policy to apply its preset. |
end_of_periodtiming needsproration_behavior: "none"andcredit_action: "none". Becauseproration_behaviordefaults toprorate_immediately, send"proration_behavior": "none"with everyend_of_periodpolicy.reset_anchorneedsimmediatetiming (reset_requires_immediate_timing).refund_mode: "full"needscredit_action: "refund"(requires_refund_action).credit_action: "offset"needsfinal_invoice_action: "generate"(must_generate_for_offset).- Source and target must differ (
must_be_different).
422 with code: "validation_errors" and the failing field in error_details:
{
"status": 422,
"error": "Unprocessable Entity",
"code": "validation_errors",
"error_details": { "billing_anchor": ["reset_requires_immediate_timing"] }
}
The two
end_of_period rules currently return a long untranslated message in error_details.proration_behavior and error_details.credit_action instead of a short code. Check which field is present rather than matching the text.404 plan_not_found. An invalid enum value returns 422 with value_is_invalid for that field.
Response
{
"plan_transition_policy": {
"nozle_id": "0d6a4e1c-2b7f-4f0e-9b57-7c2a5d1e8f33",
"source_plan_code": "starter",
"target_plan_code": "growth",
"direction": "upgrade",
"policy": "prorate_keep_anchor",
"timing": "immediate",
"billing_anchor": "keep_anchor",
"proration_behavior": "prorate_immediately",
"credit_action": "credit",
"refund_mode": "prorated",
"final_invoice_action": "generate",
"created_at": "2026-10-12T18:24:51Z",
"updated_at": "2026-10-12T18:24:51Z"
}
}
Read, list, and delete
| Request | Result |
|---|---|
GET /plans/{plan_code}/transition-policies/{target_plan_code} | 200 with the policy, or 404 plan_transition_policy_not_found when the pair has none. |
GET /plans/{plan_code}/transition-policies | 200 with {"plan_transition_policies": [...]}: every policy whose source is this plan. |
DELETE /plans/{plan_code}/transition-policies/{target_plan_code} | 200 with the deleted policy. |
Common setups
Upgrade now, keep the renewal day, credit the unused time:{ "plan_transition_policy": { "direction": "upgrade", "timing": "immediate", "credit_action": "credit" } }
end_of_period timing and keep_anchor:
{
"plan_transition_policy": {
"direction": "downgrade",
"timing": "end_of_period",
"billing_anchor": "keep_anchor",
"proration_behavior": "none",
"credit_action": "none"
}
}
Proration on a mid-cycle upgrade
When a subscription moves to another plan in the middle of its billing period, the policy for that pair decides what is charged:timing: "immediate"applies the change now. Withend_of_period, the change waits for the next period and nothing is prorated (proration_behaviorandcredit_actionmust benone).proration_behavior: "prorate_immediately", the default, prorates the unused part of the current period.nonedoes not prorate it.credit_actiondecides what happens to that unused amount of the current plan:creditcredits it,refundrefunds it (refund_modeproratedrefunds the unused part,fullthe whole amount),offsetneedsfinal_invoice_action: "generate", andnone, the default, does nothing with it.billing_anchor: "keep_anchor"keeps the current renewal day.reset_anchorstarts a new period on the change date.- An immediate upgrade with
proration_behavior: "none"andbilling_anchor: "keep_anchor"still bills the current plan for its period up to the change, as a cancellation does: its usage in arrears and, for a plan paid in arrears, its base fee for the days used. A self-serve checkout charges that invoice. The new plan is not charged or credited for the rest of the period, and no credit note is issued; its base fee starts at the next billing anchor. Before, such a change billed nothing, so that usage and fee were never invoiced. The same applies toPOST /subscriptionswithproration_behavior: none. - An immediate upgrade is payment-gated: checkout returns a provider result when money is due, or
type: "completed"when nothing is due.
POST /subscriptions/preview. The preview returns credit_amount_cents, debit_amount_cents, net_amount_cents, amount_due_now_cents and the policy it applied.
How a policy shapes checkout
When a customer with an active subscription checks out a different plan, Nozle resolves the policy for the pair and then:| Policy | Checkout result |
|---|---|
end_of_period timing | type: "scheduled" with transition_direction, transition_policy and effective_at. subscription_id is the pending subscription. |
upgrade with immediate timing | A payment-gated upgrade: a provider result when money is due, or type: "completed" when nothing is due. |
downgrade with immediate timing | The downgrade is applied and type: "completed" is returned. |
422 responses from plan changes:
| Code | Cause |
|---|---|
plan_transition_policy_required | No policy for the pair, and the free-to-paid fallback does not apply. |
plan_already_active | The target is the current plan. |
subscription_not_active | A transition call named a subscription that is not active. Checkout with a terminated subscription ID was treated as a new checkout in testing. |
target_plan_is_not_a_downgrade | A downgrade transition was requested on a pair whose policy direction is upgrade. |
plan_upgrade_invoice_missing | An upgrade checkout to a plan that bills in arrears; nothing is invoiced up front to pay. |
409 pending_subscription_changed. A paid upgrade checkout does not replace or withdraw a pending downgrade: it is refused with 409 subscription_change_pending. Withdraw the pending change first. Scheduling a change cancels an open upgrade checkout. See Paid upgrades: current limits.