Skip to content

Order a plan change

POST
/subscription/changes
curl --request POST \
--url https://api.flotera.com/api/v1/subscription/changes \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: example' \
--cookie __Host-erm_session=<__Host-erm_session> \
--data '{ "target_plan": "example", "topup_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0" }'

Outcome depends on the direction computed from the effective prices. Effective dates follow the TARGET plan’s change policy (admin-set, frozen into the order when it is placed):

  • upgrade with covered — activated immediately (status=applied), entitlements now, money from the SAME UTC day by default (upgrade_effective=next_day moves billing to the next day while entitlements stay immediate);
  • upgrade without covered — a pending change (status=awaiting_payment); the panel then creates a top-up via POST /billing/topups with purpose={kind: subscription_change, change_id}, which links it to the order (topup_id). Activation is decided by the verified balance, never by a browser redirect;
  • downgrade — exactly one scheduled change (status=scheduled) on the first UTC day of the next calendar month by default (downgrade_effective=next_day moves it to the next UTC day), never gated by balance;
  • lateral — applied immediately.

The response is the ordered SubscriptionChange itself, read back from storage: 200 when it is already applied, 201 when it is scheduled or awaiting payment.

A tenant has at most one live pending change. Replacing it requires If-Match on the subscription version; a second unconditional request returns 409 change_already_pending.

The rule is re-read when the change fires, not frozen at order time: if the catalog price moved and a scheduled downgrade would now be an upgrade, it is rejected (direction_changed) rather than silently applied, and the tenant is notified.

Denied while frozen (403 account_frozen): leaving for a cheaper plan looks like an exit but is not one — the debt is unchanged and only a top-up above zero unfreezes the account (14 §3.6). A change ordered before the freeze still fires on schedule.

Idempotency-Key
required
string
>= 1 characters <= 128 characters /^[\x21-\x7E]{1,128}$/

Idempotency key, 1–128 ASCII, unique per (tenant_id, operation, key) for at least 24 hours. Retrying with the same body replays the stored response; a different body returns 409 idempotency_conflict.

If-Match
string
<= 256 characters

Optimistic concurrency guard on the resource version; mismatched tag returns 412.

Media typeapplication/json
object
target_plan
required

Internal catalog code. Not the public label — labels are not unique.

string
<= 64 characters /^[a-z][a-z0-9_-]{1,63}$/
topup_id
Any of:

UUID (v7 for new entities; v4 accepted during migration).

string format: uuid
Examplegenerated
{
"target_plan": "example",
"topup_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0"
}

Applied immediately (upgrade covered by balance, or lateral).

Media typeapplication/json
object
id
required

UUID (v7 for new entities; v4 accepted during migration).

string format: uuid
status
required
string
Allowed values: scheduled awaiting_payment applied cancelled superseded expired rejected
direction
required

Direction at order time. It explains why the change is called a downgrade; it does not decide anything when the change fires — the rule is re-read then.

string
Allowed values: upgrade downgrade lateral
target_plan
required

Internal catalog code. Not the public label — labels are not unique.

string
<= 64 characters /^[a-z][a-z0-9_-]{1,63}$/
source_plan
Any of:

Internal catalog code. Not the public label — labels are not unique.

string
<= 64 characters /^[a-z][a-z0-9_-]{1,63}$/
effective_at
required

RFC 3339 UTC with microsecond precision.

string format: date-time
effective_day
required

First UTC day the new price applies to.

string format: date
required_reserve_usd

One day of the target plan at the current fleet size, when gated.

string | null
/^-?[0-9]+(\.[0-9]{1,6})?$/
topup_id
Any of:

UUID (v7 for new entities; v4 accepted during migration).

string format: uuid
rejected_reason

reserve_not_covered — the balance no longer covered the reserve when the change fired; direction_changed — the catalog price moved and an ordered downgrade would now be an upgrade, so it was refused instead of silently costing more.

string | null
<= 64 characters
requested_at
required

RFC 3339 UTC with microsecond precision.

string format: date-time
applied_at

RFC 3339 UTC with microsecond precision, or null.

string | null format: date-time
Example
{
"status": "scheduled",
"direction": "upgrade",
"effective_at": "2026-07-25T10:15:30.123456Z",
"requested_at": "2026-07-25T10:15:30.123456Z"
}

Change scheduled or awaiting payment.

Media typeapplication/json
object
id
required

UUID (v7 for new entities; v4 accepted during migration).

string format: uuid
status
required
string
Allowed values: scheduled awaiting_payment applied cancelled superseded expired rejected
direction
required

Direction at order time. It explains why the change is called a downgrade; it does not decide anything when the change fires — the rule is re-read then.

string
Allowed values: upgrade downgrade lateral
target_plan
required

Internal catalog code. Not the public label — labels are not unique.

string
<= 64 characters /^[a-z][a-z0-9_-]{1,63}$/
source_plan
Any of:

Internal catalog code. Not the public label — labels are not unique.

string
<= 64 characters /^[a-z][a-z0-9_-]{1,63}$/
effective_at
required

RFC 3339 UTC with microsecond precision.

string format: date-time
effective_day
required

First UTC day the new price applies to.

string format: date
required_reserve_usd

One day of the target plan at the current fleet size, when gated.

string | null
/^-?[0-9]+(\.[0-9]{1,6})?$/
topup_id
Any of:

UUID (v7 for new entities; v4 accepted during migration).

string format: uuid
rejected_reason

reserve_not_covered — the balance no longer covered the reserve when the change fired; direction_changed — the catalog price moved and an ordered downgrade would now be an upgrade, so it was refused instead of silently costing more.

string | null
<= 64 characters
requested_at
required

RFC 3339 UTC with microsecond precision.

string format: date-time
applied_at

RFC 3339 UTC with microsecond precision, or null.

string | null format: date-time
Example
{
"status": "scheduled",
"direction": "upgrade",
"effective_at": "2026-07-25T10:15:30.123456Z",
"requested_at": "2026-07-25T10:15:30.123456Z"
}

Missing/invalid credentials (code=unauthorized).

Media typeapplication/problem+json

RFC 9457 problem document with a stable machine code.

object
type
required
string format: uri
title
required
string
<= 256 characters
status
required
integer
>= 100 <= 599
code
required

Stable machine-readable error code (03 §2.4).

string
Allowed values: unauthorized forbidden csrf_rejected not_found conflict idempotency_conflict validation_failed rate_limited payload_too_large unsupported_agent_version temporarily_unavailable offline_queue_full online_queue_full plan_required server_limit_reached feature_not_entitled account_in_grace account_frozen payment_pending payment_expired payment_amount_mismatch change_already_pending change_already_applied change_effective reserve_not_covered direction_changed
detail
string
<= 2048 characters
instance
string
<= 512 characters
request_id

UUID (v7 for new entities; v4 accepted during migration).

string format: uuid
errors
Array<object>
<= 100 items
object
path
required

JSON Pointer to the offending field.

string
<= 512 characters
code
required
string
<= 64 characters
Example
{
"type": "https://ermeon.com/problems/validation",
"code": "unauthorized"
}

Frozen account (code=account_frozen), contract-priced target (code=feature_not_entitled) or missing scope (code=forbidden).

Media typeapplication/problem+json

RFC 9457 problem document with a stable machine code.

object
type
required
string format: uri
title
required
string
<= 256 characters
status
required
integer
>= 100 <= 599
code
required

Stable machine-readable error code (03 §2.4).

string
Allowed values: unauthorized forbidden csrf_rejected not_found conflict idempotency_conflict validation_failed rate_limited payload_too_large unsupported_agent_version temporarily_unavailable offline_queue_full online_queue_full plan_required server_limit_reached feature_not_entitled account_in_grace account_frozen payment_pending payment_expired payment_amount_mismatch change_already_pending change_already_applied change_effective reserve_not_covered direction_changed
detail
string
<= 2048 characters
instance
string
<= 512 characters
request_id

UUID (v7 for new entities; v4 accepted during migration).

string format: uuid
errors
Array<object>
<= 100 items
object
path
required

JSON Pointer to the offending field.

string
<= 512 characters
code
required
string
<= 64 characters
Example
{
"type": "https://ermeon.com/problems/validation",
"code": "unauthorized"
}

Resource does not exist in this tenant. Foreign identifiers also return 404 (anti-enumeration).

Media typeapplication/problem+json

RFC 9457 problem document with a stable machine code.

object
type
required
string format: uri
title
required
string
<= 256 characters
status
required
integer
>= 100 <= 599
code
required

Stable machine-readable error code (03 §2.4).

string
Allowed values: unauthorized forbidden csrf_rejected not_found conflict idempotency_conflict validation_failed rate_limited payload_too_large unsupported_agent_version temporarily_unavailable offline_queue_full online_queue_full plan_required server_limit_reached feature_not_entitled account_in_grace account_frozen payment_pending payment_expired payment_amount_mismatch change_already_pending change_already_applied change_effective reserve_not_covered direction_changed
detail
string
<= 2048 characters
instance
string
<= 512 characters
request_id

UUID (v7 for new entities; v4 accepted during migration).

string format: uuid
errors
Array<object>
<= 100 items
object
path
required

JSON Pointer to the offending field.

string
<= 512 characters
code
required
string
<= 64 characters
Example
{
"type": "https://ermeon.com/problems/validation",
"code": "unauthorized"
}

A live pending change already exists (code=change_already_pending), the tenant has no plan assignment (code=plan_required), or the fleet exceeds the target plan limit (code=server_limit_reached).

Media typeapplication/problem+json

RFC 9457 problem document with a stable machine code.

object
type
required
string format: uri
title
required
string
<= 256 characters
status
required
integer
>= 100 <= 599
code
required

Stable machine-readable error code (03 §2.4).

string
Allowed values: unauthorized forbidden csrf_rejected not_found conflict idempotency_conflict validation_failed rate_limited payload_too_large unsupported_agent_version temporarily_unavailable offline_queue_full online_queue_full plan_required server_limit_reached feature_not_entitled account_in_grace account_frozen payment_pending payment_expired payment_amount_mismatch change_already_pending change_already_applied change_effective reserve_not_covered direction_changed
detail
string
<= 2048 characters
instance
string
<= 512 characters
request_id

UUID (v7 for new entities; v4 accepted during migration).

string format: uuid
errors
Array<object>
<= 100 items
object
path
required

JSON Pointer to the offending field.

string
<= 512 characters
code
required
string
<= 64 characters
Example
{
"type": "https://ermeon.com/problems/validation",
"code": "unauthorized"
}

If-Match entity tag does not match the current version.

Media typeapplication/problem+json

RFC 9457 problem document with a stable machine code.

object
type
required
string format: uri
title
required
string
<= 256 characters
status
required
integer
>= 100 <= 599
code
required

Stable machine-readable error code (03 §2.4).

string
Allowed values: unauthorized forbidden csrf_rejected not_found conflict idempotency_conflict validation_failed rate_limited payload_too_large unsupported_agent_version temporarily_unavailable offline_queue_full online_queue_full plan_required server_limit_reached feature_not_entitled account_in_grace account_frozen payment_pending payment_expired payment_amount_mismatch change_already_pending change_already_applied change_effective reserve_not_covered direction_changed
detail
string
<= 2048 characters
instance
string
<= 512 characters
request_id

UUID (v7 for new entities; v4 accepted during migration).

string format: uuid
errors
Array<object>
<= 100 items
object
path
required

JSON Pointer to the offending field.

string
<= 512 characters
code
required
string
<= 64 characters
Example
{
"type": "https://ermeon.com/problems/validation",
"code": "unauthorized"
}

Request failed validation (code=validation_failed), with per-field errors.

Media typeapplication/problem+json

RFC 9457 problem document with a stable machine code.

object
type
required
string format: uri
title
required
string
<= 256 characters
status
required
integer
>= 100 <= 599
code
required

Stable machine-readable error code (03 §2.4).

string
Allowed values: unauthorized forbidden csrf_rejected not_found conflict idempotency_conflict validation_failed rate_limited payload_too_large unsupported_agent_version temporarily_unavailable offline_queue_full online_queue_full plan_required server_limit_reached feature_not_entitled account_in_grace account_frozen payment_pending payment_expired payment_amount_mismatch change_already_pending change_already_applied change_effective reserve_not_covered direction_changed
detail
string
<= 2048 characters
instance
string
<= 512 characters
request_id

UUID (v7 for new entities; v4 accepted during migration).

string format: uuid
errors
Array<object>
<= 100 items
object
path
required

JSON Pointer to the offending field.

string
<= 512 characters
code
required
string
<= 64 characters
Example
{
"type": "https://ermeon.com/problems/validation",
"code": "unauthorized"
}