Customers API
Manage customers — create, list, update, and access their billing data.
Every endpoint on this page acts on one environment. With a session token the X-Environment
header is what picks it, and leaving it out means live. See
Choosing the Environment.
Create Customer
POST /api/v1/customers
Request:
{
"company_name": "Acme Corp",
"email": "billing@acme.com",
"external_id": "usr_9f3k2x",
"product_id": "456",
"subscription_term": "annual"
}
| Field | Type | Required | Description |
|---|---|---|---|
company_name | string | No | Customer's company name. For an individual, send first_name and last_name instead — the response's display_name picks the company name, else the full name, else external_id. |
credit_limit | number | No | The most the customer may owe before their paid services pause. Send 0 to require prepayment. Omit it, or send null, for no limit at all. A customer with no credit limit is never blocked by a plan's free quota. A service priced free_quota_block stops when its quota runs out, but only for customers who have a credit limit. Without one the customer keeps using the service, and pays nothing for it, because that pricing sets the price to zero. A prepaid customer is unaffected: a limit of zero still stops the service. |
email | string | No | Primary contact email |
external_id | string | Yes | The customer's unique identifier in your SaaS platform (e.g., your user ID, org ID). This is how the Charging API matches usage events to the correct customer. May contain any printable Latin-1 (ISO/IEC 8859-1) character except whitespace; spaces and non-Latin characters (e.g. Cyrillic) are rejected. |
initial_funds | number | No | Money a prepaid customer starts with. Omit it or send 0 and they are created with nothing to spend, so the billing engine blocks their paid services until you record a payment. Accepted on a prepaid create only: sending it for a postpaid customer is rejected with 422, since a credit limit already governs what they may spend, and Update Customer refuses it with a 422: a later top up is a balance adjustment. Read it back as available_funds; the customer's balance reports the negative of it, because a positive balance means the customer owes. |
product_id | string | Yes | Plan to assign. The plan is what gives the customer a billing account, so without one they could not be charged and would have no entitlements. A request that omits it (or sends it empty) is rejected with 422 and nothing is created. Also rejected with 422 when it names a trial whose plan has been disabled: the trial is not deleted (existing customers may still be on it) but can no longer be assigned to a new one. To move a customer to another plan later, use Change Customer Plan. |
enable_trial | boolean | No | Start the customer on the plan's trial instead of the paid plan itself. Rejected with 422 and nothing created when the plan has no trial variant to start. Omit or send false to create a normal paying customer. |
trial_days | number | No | Trial length in days, 1 to 365. Only meaningful with enable_trial: true; omit it to use the trial's own default length. With neither, the trial has no end date and runs until you end it. |
billing_model | string | No | How the customer pays: postpaid (the default) uses the service now and settles up to the credit limit, prepaid loads money first and spends only what is loaded. Omit it and the customer is postpaid, exactly as every customer created before this field existed. Cannot be changed afterwards: the billing engine records it only at creation, so sending it on Update Customer is rejected with 422, and a customer on the wrong arrangement has to be replaced rather than corrected. A prepaid customer is always created on a credit limit of 0, whatever credit_limit says; give them money to spend with initial_funds instead. |
subscription_term | string | No | Renewal term for the assigned plan: monthly (default) or annual. annual requires a plan that defines an annual fee — otherwise it is rejected with 422 and no customer is created, rather than falling back to monthly. When a free trial actually starts (enable_trial on a plan that has a trial), the term is accepted but downgraded to monthly, because a trial bills monthly until it converts; switch the term after conversion. The response always reports the term actually in effect, or null when it could not be confirmed. |
timezone | string | No | IANA timezone name, such as America/New_York, that anchors the customer's billing period. Their period closes at midnight in this zone, so it also decides which day a usage event is billed in. Must be a name from List Timezones; an unknown name is rejected with 422 and no customer is created. Omit to use the billing environment's own timezone, which Get the Default Timezone reports. Cannot be changed after creation: the billing engine only applies a timezone change when a billing period closes, so it is not accepted on update. |
Response: 200 OK
{
"id": "789",
"company_name": "Acme Corp",
"email": "billing@acme.com",
"external_id": "usr_9f3k2x",
"product_id": "456",
"product_name": "Growth",
"subscription_term": "annual",
"timezone": "America/New_York",
"status": "active",
"balance": 0.00,
"billing_model": "postpaid",
"credit_limit": 500.00,
"available_funds": 500.00,
"is_trial": false,
"trial_end_date": null,
"main_product_id": null,
"main_product_name": null
}
The response describes the customer as saved — it is read back from the billing engine, not echoed
from your request, so it matches what GET /api/v1/customers/{id} returns, except that created_at
is precise to the microsecond here and to the second there, and that subscription_term is null
when the term could not be confirmed (see above). Three consequences worth knowing:
product_idnames the plan the customer actually ended up on. When a free trial starts, that is the plan's trial variant — so the id differs from theproduct_idyou sent,is_trialistrue,trial_end_datecarries the end of the trial (null for a trial with no end date), andmain_product_id/main_product_namename the parent plan it converts to.timezonenames the zone in effect, including the environment default you inherit by omitting it — except on the degraded path below, where an omitted timezone comes backnull.billing_modelnames the arrangement the customer was created on, andavailable_fundshow much they may still spend. Both are described under Get Customer.- Every customer created through this endpoint has a plan. Customers created before that became
a rule can still exist without one, and they report
product_idandproduct_nameas empty strings, notnull, so when you read customers, test for an empty value rather than fornull.
In the rare case where the customer is created but cannot be read back afterwards, the response is
built from what was written instead: the plan and trial fields still describe the customer, but
anything only the billing engine knows comes back at its default — an inherited timezone and the
computed service status, and credit_limit as null, which normally means "no limit" but here
only means the limit was not read. The customer itself is created either way; a follow-up GET
returns the complete record.
A well-formed request with subscription_term: "annual" can still fail with 502 if the
annual term cannot be applied to the new account. The customer is rolled back in that case — the
error means nothing was created, so the request is safe to retry.
List Customers
GET /api/v1/customers?limit=20&offset=0
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
limit | int | Items per page (default: 20) |
offset | int | Items to skip |
search | string | Search by name, email, ID |
status | string | Filter by status |
with_terminated | bool | Include terminated customers, which are left out by default |
with_lifecycle_dates | bool | Fill in suspended_at and terminated_at for the suspended and terminated customers on this page. Off by default, because each of those customers costs an extra lookup, so ask for it on an export rather than on a page you are browsing |
Every row carries billing_model, credit_limit and available_funds alongside the balance, so
you can tell how a customer pays and what they have left without reading each one. There is no
query parameter for the billing model: the billing engine ignores it as a list filter, so narrow
the returned rows yourself.
Get Customer
GET /api/v1/customers/{customer_id}
Returns full customer details including plan information and balance.
{customer_id} takes either identifierEvery /customers/{customer_id} route on this page accepts the numeric id returned when the
customer was created, or the external_id you gave them, so your own identifier works without
looking the number up first. Matching on external_id ignores case.
A value made only of digits is always read as the numeric id. Address a customer whose
external_id is all digits by their number instead.
The response describes how the customer pays, and how much they may still spend:
| Field | Type | Description |
|---|---|---|
billing_model | string | postpaid, which uses the service now and settles up to the credit limit, or prepaid, which loads money first and spends only what is loaded. Recorded at creation and never changed afterwards. |
credit_limit | number | The most the customer may owe before paid services are blocked. null means no limit at all, which is not the same as 0 (pay upfront, no debt allowed). |
available_funds | number | How much the customer may still spend: the credit limit less the balance, rounded. null when the customer has no credit limit, since there is no ceiling to count down from, so this is deliberately not 0. A customer sitting exactly at their limit reports 0, never a small negative rounding artefact; a customer who is genuinely past their limit reports a real negative figure, which is how far past it they are. |
Both are on every row of List Customers too, so a list consumer needs no follow-up call to work out what a customer has left.
available_funds describes the customer's recorded position, not a spending guarantee: money
already reserved by usage in flight is not subtracted from it, so usage worth exactly this
amount can still be refused if a session is holding funds that have not been spent yet.
It also reports where the customer's current billing period began:
| Field | Type | Description |
|---|---|---|
billing_period_start | string | First day of the current billing period (YYYY-MM-DD), read in the customer's own timezone: everything charged since then is still uninvoiced, and an on-demand invoice can cover no less than one day of it. null when the billing backend records no period yet. Only this endpoint carries it; a list row does not. |
The response also carries two distinct status fields:
| Field | Type | Description |
|---|---|---|
status | string | Lifecycle status: active, suspended, or terminated. |
service_status | string | Operational service status — whether paid services are usable right now, and why not. One of open, credit_exceeded, no_available_funds, spending_limit_reached, service_limited, service_limit_delayed, suspended, suspension_delayed, provisionally_terminated, blocked, payment_frozen, billing_paused, in_progress, closed, exported. A customer can be active yet have a blocking service_status (e.g. credit_exceeded when their balance has reached the credit limit). |
Update Customer
PATCH /api/v1/customers/{customer_id}
Request:
{
"company_name": "Acme Corporation",
"email": "new-billing@acme.com"
}
| Field | Type | Required | Description |
|---|---|---|---|
external_id | string | No | New identifier in your SaaS platform. Also renames the underlying billing account. |
company_name | string | No | New company name (omit to leave unchanged). Same for first_name, last_name, email and address. |
credit_limit | number | No | New credit limit. Omit to leave unchanged; send null to clear it (no limit). Rejected with 422 for a prepaid customer, whose arrangement exists to stop them owing anything at all. |
initial_funds | number | n/a | Not accepted. Rejected with 422 naming the field. Opening funds are taken at creation only; to move money afterwards use Adjust Customer Balance, which records the change against the customer. Declared and refused rather than left off, because an undeclared key is dropped just as silently. |
billing_model | string | n/a | Not accepted. Sending it is rejected with 422 naming the field, whichever arrangement you send, including the one the customer is already on. The billing engine records the arrangement only at creation and would take this value, discard it and report success, so the request is refused rather than answered with a change that never happened. To move a customer between arrangements, create a new customer. |
Moving a customer to a different plan is not part of this request — it rewrites their subscription, so it has its own endpoint: Change Customer Plan.
Answers with the full customer as saved — the same body GET /api/v1/customers/{id} returns,
including the plan, the renewal term and any trial, whichever fields you updated.
Change Customer Plan
PATCH /api/v1/customers/{customer_id}/product
Request:
{
"product_id": "789",
"subscription_term": "monthly"
}
| Field | Type | Required | Description |
|---|---|---|---|
product_id | string | Yes | Plan to move the customer onto. Rejected with 422 when it names a trial whose plan has been disabled: the trial is not deleted (existing customers may still be on it) but can no longer be assigned to another customer. Naming a trial starts it for the trial's own default length; with no default the trial has no end date and runs until you end it with End Free Trial. |
subscription_term | string | No | Renewal term for the new plan: monthly (default) or annual. annual requires a plan that defines an annual fee — otherwise it is rejected with 422 and the plan is not changed. When a trial is involved (starting or ending one) the term is downgraded to monthly instead. The response always reports the term actually in effect, or null when it could not be confirmed. |
Changes the customer's plan immediately. The charges follow at the customer's next billing run,
after midnight in their time zone: the unused part of the current subscription fee is refunded, and
the new plan is charged for the rest of the billing period. Nothing is written to the customer's
transactions on the day of the change. On the annual term, one charge covers twelve billing
periods. The twelve are the rest of the current period and the next eleven. As on the monthly
term, the current period is charged only for its remaining days.
The plan and the term are applied in that order and only the plan change is unwound on
failure, so a 200 can mean the plan moved while the term did not. Read
subscription_term in the response rather than assuming the requested term took effect.
Get Customer Transactions
GET /api/v1/customers/{customer_id}/transactions
Returns the customer's ledger: one page of usage charges, subscription fees, taxes, payments,
credits, refunds and manual charges, newest first, each classified by kind. Default: last 30
days. Alongside the rows the response carries a services facet, whose id values are what the
service filter accepts, and a summary over every matching record rather than the returned page.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
from_date | string | Earliest record to include (YYYY-MM-DD). Defaults to 30 days ago. |
to_date | string | Latest record to include (YYYY-MM-DD). Defaults to today. |
type | string | Filter to one type, matching the row's kind: payment, credit, refund, charge, usage, subscription, tax or other. There is no all value: omit the parameter for every type. An unknown value is a 422. The values differ from type on List Payments, which selects one of that page's four summary cards. |
service | string | Filter to one service, using an id from the services facet. |
search | string | Search the item, the comment and the record reference. The item and the comment match any part of the value, ignoring case but not accents. A reference matches as a whole value: paste it in full, or send the record's own number, which is also what reaches a reference of TR-1234. The pay_, crd_ and xdr_ prefixes are ignored rather than checked, so 3642 and pay_3642 name the same record. A number can appear in a comment as well, so it can return more than the record carrying it. Nothing else is searched: not the service, which service filters, and not the payment method or the record description. |
limit | integer | Page size, 1–500 (default 50) |
offset | integer | Pagination offset |
A subscription row is described in PortaAIM's own words rather than the billing backend's:
public_comment reads Subscription Fee, Activation Fee, Cancellation Fee or Subscription Refund, item_name names the plan as it is called now, and period_from / period_to carry the
period the charge covers as YYYY-MM-DD in the customer's own billing timezone, the same way an
invoice dates its period. Those two are null on every other row, including a DID or IPTV charge,
which the billing backend also files under subscriptions. The cause of a refund is not published,
because the billing backend does not record one.
type and service narrow different things, and neither replaces the other. A service holds
several types at once: a credit and a manual charge are both recorded against Credits /
Adjustments, so only type tells those apart. Every filter narrows summary and total along
with the page, except the services facet, which is taken over the whole window before any filter
runs, so narrowing by type never removes an option the service filter accepts.
A row billed under a session also names that session (session_id) and the reservation it was
taken against (lock_id). Both are the values your own charge request supplied or was answered
with, so they join a billed row to your logs, and they are the same ids List Events
filters by. Both are null on a row billed under no session, and on a payment, which is recorded
against the customer rather than a session.
This is the billing record. For the charging log, including the attempts that never became a
charge, use List Events with customer_id.
Get Customer Transactions Grouped by Session
GET /api/v1/customers/{customer_id}/transactions/grouped
Returns the same ledger with the transactions of one session folded into one row. It takes exactly the query parameters Get Customer Transactions takes, and narrows by them the same way.
One AI request can bill several transactions: a charge on the service that earned the revenue,
and charges on the services that carried the cost, all sharing the session id you sent when
charging.
Each such set comes back as one row carrying its own amount, cost and margin, with the
transactions themselves under members.
A transaction billed under no session, such as a subscription fee or a payment, comes back as a row
holding only itself. Nothing is left out, so summary reports the same figures the flat ledger
reports for the same filters. total counts rows of this list, not the transactions inside them.
margin is null unless every member reports a cost: a margin over part of a session reads higher
than the session earned. cost_count says how many of them do, and cost sums those.
Get Customer Invoices
GET /api/v1/customers/{customer_id}/invoices
Returns the customer's invoices, newest first, with the standard total/limit/offset/has_more
pagination fields. Omit both dates to get the full history. Each invoice has the same shape as on
List Invoices — including customer_name, which is the customer's display label
(company name, falling back to the contact name, then the account name), not the bare account name.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
from_date | string | Only invoices issued on or after this date (YYYY-MM-DD). Omit for no lower bound. |
to_date | string | Only invoices issued on or before this date (YYYY-MM-DD). Omit for no upper bound. |
limit | integer | Page size, 1–500 (default 50) |
offset | integer | Pagination offset |
include_summary | boolean | Also return the category totals, over this customer's whole matching window rather than this page. Defaults to false. See Category Totals. |
Both bounds are inclusive. A value that does not start with a date the API can read is rejected
with 422 naming the field; a trailing time component is accepted and ignored.
This endpoint takes no status or hide_void parameter, and returns the customer's history
complete, voided invoices included. The tenant-wide list leaves those out by default, so the two
endpoints can report different counts for the same customer.
Before August 2026 the date parameters were accepted but had no effect on this endpoint — every request returned the customer's whole history. If you were passing a range and relying on getting everything back, you will now receive only the invoices issued inside it.
Adjust Customer Balance
POST /api/v1/customers/{customer_id}/adjust-balance
Request:
{
"amount": 50.00,
"type": "payment",
"reason": "Payment received"
}
| Field | Type | Description |
|---|---|---|
amount | number | Adjustment amount. Must be greater than zero: type is what decides whether the balance rises or falls. At most five decimal places; a payment or refund takes at most the decimal places of the customer's currency, which Get Refundable Amount reports |
type | string | credit — goodwill credit (reduces what the customer owes); debit — manual charge (increases it); payment — record a received payment (creates an allocatable payment that reduces what the customer owes); refund — record money returned to the customer (increases what they owe, and reverses payments they made) |
reason | string | Description for the transaction. At most 100 characters |
Use payment when recording money received: it is provisioned as an allocatable payment that can be applied against invoices, whereas credit records a non-allocatable balance credit. Use refund, not credit, for money you have returned: a credit moves the balance the other way and marks none of the customer's payments as returned.
Errors. An amount of zero or less, or with more than five decimal places, answers 422 naming the field, and nothing is recorded. A payment or refund with more decimal places than the customer's currency has answers 422 too, for example Only 2 decimal digits are allowed for fractional part of amount. A reason longer than 100 characters answers the same way. No credit limit caps a debit: it is recorded even when it takes the customer past their limit, or below zero for a customer who pays in advance. Reaching the limit stops the customer's paid services, so read service_status back after a large debit.
The response reports the new balance only. To show a prepaid customer what they can now spend, read the customer back and use available_funds rather than working it out from the balance yourself.
Get Refundable Amount
GET /api/v1/customers/{customer_id}/refundable
The most that can be refunded to this customer right now:
{
"amount": 125.00,
"currency": "USD",
"currency_decimals": 2
}
currency_decimals is how many decimal places a payment or refund takes in the customer's currency (two for USD, none for JPY), or null when it is unknown. A credit or debit takes five whatever the currency.
It is the payments the customer has made, less what has already been refunded. Money that never arrived as a payment — funds the customer was opened with, for instance — is never part of it, so a large balance does not imply a large refundable amount. An amount of zero means a refund is impossible until the customer pays again.
Read it live before offering a refund: it moves with every payment, so it is deliberately not a field on the customer record. It is a ceiling and not a guarantee — limits on the calling administrator's own account apply on top — so a refund at exactly this amount can still answer 422.
Errors. A refund larger than this amount answers 422 and records nothing.
Get Customer Statistics
GET /api/v1/customers/{customer_id}/stats
Returns statistics combining collected revenue, usage activity, and profitability (margin) for both a 30-day window and the customer's lifetime. For revenue, cost and margin, the 30-day window is today and the previous 29 days, in whole days in UTC. The event-count fields use a rolling 30 days from the moment of the request instead:
{
"invoice_total": 12450.00,
"invoice_total_computed_at": "2026-06-01T08:00:00+00:00",
"event_count_30d": 1284,
"event_count_prev_30d": 1146,
"failed_count_30d": 12,
"events_complete": true,
"period_usage_revenue": 540.00,
"period_subscription_revenue": 660.00,
"period_revenue": 1200.00,
"period_cost": 480.00,
"period_margin": 720.00,
"period_margin_pct": 60.0,
"lifetime_usage_revenue": 4300.00,
"lifetime_subscription_revenue": 8100.00,
"lifetime_revenue": 12400.00,
"lifetime_cost": 4100.00,
"lifetime_margin": 8300.00,
"lifetime_margin_pct": 66.9,
"usage_status": "complete",
"usage_aggregated_through": "2026-06-01T09:10:00+00:00",
"computed_at": "2026-06-01T09:15:00+00:00"
}
| Field | Description |
|---|---|
invoice_total | Lifetime Value — total amount actually collected across all non-voided invoices (outstanding amounts excluded). |
invoice_total_computed_at | When invoice_total was last recomputed from the billing backend. |
event_count_30d / event_count_prev_30d | Every usage event in the 30-day window / the 30 days before it, regardless of outcome. |
failed_count_30d | The 30-day window's failed usage events, a subset of event_count_30d. A copy that a repeated request writes counts as an event of its own, here and in event_count_30d. |
events_complete | False when the three counts above could not be fully read and may understate the true totals. Revenue, cost and margin are unaffected: they come from a separate read. |
period_usage_revenue | Usage revenue billed in the 30-day window (from charged usage events). |
period_subscription_revenue | Subscription (recurring) revenue charged in the 30-day window. |
period_revenue | period_usage_revenue + period_subscription_revenue. |
period_cost | Vendor cost of usage in the 30-day window (subscriptions have no vendor cost). |
period_margin | period_revenue − period_cost. |
period_margin_pct | period_margin / period_revenue × 100 (0 when revenue ≤ 0). |
lifetime_usage_revenue | All-time usage revenue from charged usage events. |
lifetime_subscription_revenue | All-time subscription (recurring) revenue. |
lifetime_revenue | lifetime_usage_revenue + lifetime_subscription_revenue. |
lifetime_cost | All-time vendor cost of usage. |
lifetime_margin | lifetime_revenue − lifetime_cost. |
lifetime_margin_pct | lifetime_margin / lifetime_revenue × 100 (0 when revenue ≤ 0). |
usage_status | Whether the usage figures are final. complete: every usage charge is counted. counting: past usage is still being counted, a step per request, so ask again in a few seconds; the figures grow until it reads complete. partial: counting could not continue, so the figures stop at usage_aggregated_through until a later request succeeds. |
usage_aggregated_through | Instant up to which usage is counted and kept. Usage after it is added on each request while the status is complete. Null before anything has been counted. |
computed_at | When this response was assembled. |
Note: Subscription revenue accrues as it is charged (it does not wait for an invoice to be issued), so margin stays consistent with the analytics dashboard even for new or mid-cycle customers.
Get Customer Quota
GET /api/v1/customers/{customer_id}/quota
Returns the customer's live quota consumption for the current usage period: how much of the plan's included quota is allowed vs. used per metered service (and item), and what happens once the quota is exhausted. The figures come live from the billing backend, so they reflect real consumption and the real reset cycle.
When the plan defines a quota but no live counter exists yet (for example, a
brand-new customer who hasn't generated any usage), the row is derived from the
plan and marked live: false. For those rows the included allowance is known
but used/remaining are placeholders (zero), not confirmed consumption — treat
them as "no usage recorded yet" rather than a confirmed empty counter.
{
"has_quota": true,
"reset_period": "monthly",
"period_from": "2026-06-01T00:00:00+00:00",
"period_to": "2026-07-01T00:00:00+00:00",
"currency": "USD",
"services": [
{
"service_id": "3",
"service_name": "AI Tokens",
"allowed": 1000000,
"used": 820000,
"remaining": 180000,
"unit": "1k tokens",
"utilization_pct": 82.0,
"status": "approaching",
"post_quota_behavior": "overage",
"overage_rate": 0.5,
"current_state": "approaching",
"live": true,
"items": [
{
"item_id": "gpt-4",
"item_name": "GPT-4",
"allowed": 200000,
"used": 195000,
"remaining": 5000,
"unit": "1k tokens",
"utilization_pct": 97.5,
"status": "approaching",
"post_quota_behavior": "overage",
"overage_rate": 0.6,
"current_state": "approaching"
}
]
}
],
"computed_at": "2026-06-22T09:15:00+00:00",
"cache_ttl": 60
}
| Field | Description |
|---|---|
has_quota | false (and services empty) when the plan defines no quota — e.g. a pure pay-as-you-go plan. |
reset_period | How often the quota resets: daily, weekly, semimonthly, monthly, or one_time. |
period_from | Start of the current quota bundle. Editing the plan rebuilds the bundle, which moves this to the moment of the edit while period_to stays put. That is not a renewal: the consumption carries over and no fresh allowance is granted. |
period_to | End of the current usage period, the next reset. A plan edit leaves it alone. |
currency | Account currency, for interpreting overage rates and currency-denominated quotas. |
services[] | One row per metered service that has a quota. |
services[].allowed / used / remaining | Included quota, amount consumed this period, and amount left, in the row's unit. Once the plan has been edited mid-period, used is a minimum rather than an exact figure: the billing engine caps what it carries across the edit, so a customer already past the allowance reports low. |
services[].utilization_pct | used / allowed × 100 (can exceed 100 in overage). |
services[].status | Utilization bucket: within (<80%), approaching (80–100%), or over (>100%). Same buckets as the analytics dashboard's quota chart. |
services[].post_quota_behavior | What happens past the quota: overage (usage continues, billed), block (service is cut off), or unlimited. |
services[].overage_rate | Price per unit charged after the quota (only when behavior is overage). |
services[].current_state | Live position now: within, approaching, exhausted_blocked, or in_overage. |
services[].live | true when allowed/used/remaining come from a live counter. false when the row is derived from the plan only (no live counter yet) — then used/remaining are placeholders, not confirmed consumption. Same field on items[]. |
services[].items[] | Per-item quota breakdown (same fields) when the plan defines item-level quotas. |
Quota states (current_state)
current_state is the live badge shown for each service (and item) on the Quota tab. It reflects the plan's post-quota behavior, so reaching 100% does not mean the same thing for every plan:
| State | When it appears | Meaning |
|---|---|---|
within | below 80% of the included quota | Normal usage, well inside the quota. |
approaching | 80% up to and including exactly 100% | Nearing (or exactly at) the included quota. A service stays here at exactly 100% when nothing has yet been used past the quota, because reaching the limit is not itself overage. This needs a counter that can tell the two apart, so it does not apply once the plan has been edited mid-period: see the note below. |
in_overage | usage past 100% on an overage (or metered postpaid) plan | The included quota is exceeded and further usage is billed at the overage rate. |
exhausted_blocked | at or past 100% on a block plan | The quota is used up and the service is cut off for the rest of the period — a hard cap, so further usage is declined rather than billed. |
So on a Free Quota + Block Excess service, exactly 100% shows exhausted_blocked (the service is now blocked); on a Free Quota + Overage service, exactly 100% shows approaching until real overage is actually incurred, then in_overage.
Note: that last rule needs a counter whose consumption is exact, so it does not survive a plan edit. Editing a plan rebuilds the quota bundle, and the billing engine caps the consumption it carries across, which leaves at-limit and far-over indistinguishable. For the rest of that period an overage service reads
in_overageat exactly 100% rather thanapproaching, andusedis a minimum. A plan edit that raises an allowance goes the other way: the carried figure sits on the smaller previous cap, so a customer who is genuinely over can report well below 100% and readwithin.
Note:
statusis the plain utilization bucket and can differ fromcurrent_state— e.g. a block service at 100% reportsstatus: "approaching"butcurrent_state: "exhausted_blocked".
Note: For overage plans, continued usage past the quota is still subject to the customer's credit limit / balance — once that is reached, even paid usage is declined. See the customer's balance and credit limit for that headroom.
Get Scheduled Status Change
GET /api/v1/customers/{customer_id}/scheduled-status-change
Returns the status change an unpaid invoice has scheduled for this customer: which change, the date it happens, and the number of the invoice that must be paid to prevent it. The billing backend works this out and PortaAIM reports it, so the date is the same day the backend will act on.
scheduled_status is suspended, or service_limited where the backend limits paid services
before suspending the customer. scheduled_on and delayed_till are plain calendar dates with no
time part. delayed_till is the day in the customer's own timezone.
{
"scheduled_status": "suspended",
"scheduled_on": "2026-09-01",
"invoice_number": "9",
"delayed_till": null
}
Every field is null when there is nothing to warn about, and that is the ordinary answer, not an error. Treat an all-null response as "nothing to show". It is what you get when:
- the invoice settings carry no number of days until suspension;
- the customer has nothing overdue, including an unpaid invoice still inside its payment term;
- the customer is suspended or terminated, so the change has already happened or no longer applies;
- the date has already passed.
Note: there is no termination date here. The billing backend schedules termination from the same settings but does not report a date for it, so only the suspension (or the earlier service limitation) is served.
Suspend Customer
POST /api/v1/customers/{customer_id}/suspend
Suspends the customer. Service access is blocked, and the billing engine credits back one day's subscription fee for each day that the suspension lasts.
Response: 204 No Content. Read the customer to see its new status.
Unsuspend Customer
POST /api/v1/customers/{customer_id}/unsuspend
Reactivates a suspended customer. Restores service access; days after the reactivation are charged in full.
Response: 204 No Content.
Terminate Customer
POST /api/v1/customers/{customer_id}/terminate
Irreversibly terminates the customer. All service access is permanently blocked and the customer record is closed.
Response: 204 No Content.