Skip to content

Balance, tariff and charge estimate

GET
/billing
curl --request GET \
--url https://api.flotera.com/api/v1/billing \
--cookie __Host-erm_session=<__Host-erm_session>

Read-after-write view from Billing PG (short dedicated cache, never the generic 60 s dashboard TTL).

Billing view.

Media typeapplication/json

Wallet projection (14 §6.4): balance read after write from Billing PG, account lifecycle, subscription and the daily burn.

object
tenant_id
required

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

string format: uuid
currency
required
string
Allowed value: USD
balance_usd
required

May be negative: a daily charge is posted in full even when it takes the balance below zero. Debt is more honest than an under-charge (14 §3.4).

string
/^-?[0-9]+(\.[0-9]{1,6})?$/
lifecycle
required
object
state
required

active — balance above zero, full plan capabilities.

grace — the daily charge took the balance to zero or below. Nothing is taken away: the same capabilities, the same server-days, the same charges. It exists so that a weekend without a payment does not cost the tenant control of the farm (14 §3.4).

frozen — the grace deadline passed. No new server-days, telemetry is acknowledged but not stored, and only the reads listed in 13 §5.4 stay open. There is no active → frozen edge: a freeze always goes through grace, so “frozen without warning” is impossible by construction.

string
Allowed values: active grace frozen
grace_started_at

Posting time of the charge that took the balance to zero or below.

string | null format: date-time
grace_until

Exact freeze boundary — always a UTC midnight, so a frozen day is frozen in whole and cannot be billed as a partially served day (14 §2.4). Repeated charges do not extend it: debt does not buy time.

string | null format: date-time
frozen_at

RFC 3339 UTC with microsecond precision, or null.

string | null format: date-time
required_topup_usd

Smallest top-up that takes the balance strictly above zero and therefore recovers the account. null when the account is active. The panel must show this number rather than let the user guess: paying exactly to zero does not unfreeze (14 §3.6).

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

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

string
<= 64 characters /^[a-z][a-z0-9_-]{1,63}$/
public_label
string | null
<= 64 characters
monthly_price_per_server_usd

Frozen price of the tenant’s current assignment, not the catalog row: a catalog price change must not restate what was already charged.

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

UTC calendar day.

string format: date
status
required

unassigned is not a plan: it means no assignment row exists yet, so nothing can be priced. Being on the free plan is active — free is a plan, not a subscription state (D-14).

string
Allowed values: active change_awaiting_payment downgrade_scheduled cancelled unassigned
pending_change
Any of:
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
usage
required
object
managed_servers
required
integer
yesterday_server_days
required

Billable managed server-days of the last closed UTC day (14 §2.4).

integer
yesterday_charge_usd
required

Decimal value serialized as a string, never a JSON number.

string
/^-?[0-9]+(\.[0-9]{1,6})?$/
estimated_daily_charge_usd
required

Current fleet size at the current daily rate. An empty paid fleet costs exactly zero — the price is per server and there is no landing fee — so this is 0 rather than a floor.

string
/^-?[0-9]+(\.[0-9]{1,6})?$/
estimated_days_left
required

floor(balance / estimated_daily_charge_usd); null when nothing is being charged. Never render null as “unlimited”.

integer | null
spent_mtd_usd
required

Spend of the current calendar month in timezone, computed by the same window expression that /billing/ledger?range=month cuts on. The invariant is load-bearing and tested: this tile equals minus the sum of the returned non-topup rows of the same period. Keeping it is a deliberate addition to the projection of 14 §6.4 — without it the journal and the tile could only be reconciled by eye.

string
/^-?[0-9]+(\.[0-9]{1,6})?$/
next_settlement_at
required

When the next UTC day is posted: H+1:15 of the closed day.

string format: date-time
timezone
required

IANA timezone the tenant’s calendar labels use. Settlement itself is UTC — this zone labels spent_mtd_usd and every /billing/ledger?range= window, so ledger timestamps must be rendered in it or rows and tiles describe different days.

string
<= 64 characters
generated_at
required

RFC 3339 UTC with microsecond precision.

string format: date-time
Example
{
"currency": "USD",
"balance_usd": "12.340000",
"lifecycle": {
"state": "active"
},
"subscription": {
"status": "active",
"pending_change": {
"status": "scheduled",
"direction": "upgrade",
"effective_at": "2026-07-25T10:15:30.123456Z",
"requested_at": "2026-07-25T10:15:30.123456Z"
}
},
"usage": {
"yesterday_charge_usd": "12.340000",
"estimated_daily_charge_usd": "12.340000"
},
"spent_mtd_usd": "12.340000",
"next_settlement_at": "2026-07-25T10:15:30.123456Z",
"generated_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"
}

Authenticated but not permitted. code=forbidden — role/scope mismatch; code=feature_not_entitled — the plan does not include the capability; code=account_frozen — the account is frozen and this operation is declared x-ermeon-frozen: deny. The three are deliberately distinct: only the last one is fixed by a top-up (13 §5.6).

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"
}