Skip to main content

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"
}
FieldTypeRequiredDescription
company_namestringNoCustomer'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_limitnumberNoThe 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.
emailstringNoPrimary contact email
external_idstringYesThe 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_fundsnumberNoMoney 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_idstringYesPlan 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_trialbooleanNoStart 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_daysnumberNoTrial 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_modelstringNoHow 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_termstringNoRenewal 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.
timezonestringNoIANA 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_id names 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 the product_id you sent, is_trial is true, trial_end_date carries the end of the trial (null for a trial with no end date), and main_product_id / main_product_name name the parent plan it converts to.
  • timezone names the zone in effect, including the environment default you inherit by omitting it — except on the degraded path below, where an omitted timezone comes back null.
  • billing_model names the arrangement the customer was created on, and available_funds how 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_id and product_name as empty strings, not null, so when you read customers, test for an empty value rather than for null.

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:

ParameterTypeDescription
limitintItems per page (default: 20)
offsetintItems to skip
searchstringSearch by name, email, ID
statusstringFilter by status
with_terminatedboolInclude terminated customers, which are left out by default
with_lifecycle_datesboolFill 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 identifier

Every /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:

FieldTypeDescription
billing_modelstringpostpaid, 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_limitnumberThe 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_fundsnumberHow 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:

FieldTypeDescription
billing_period_startstringFirst 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:

FieldTypeDescription
statusstringLifecycle status: active, suspended, or terminated.
service_statusstringOperational 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"
}
FieldTypeRequiredDescription
external_idstringNoNew identifier in your SaaS platform. Also renames the underlying billing account.
company_namestringNoNew company name (omit to leave unchanged). Same for first_name, last_name, email and address.
credit_limitnumberNoNew 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_fundsnumbern/aNot 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_modelstringn/aNot 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"
}
FieldTypeRequiredDescription
product_idstringYesPlan 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_termstringNoRenewal 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:

ParameterTypeDescription
from_datestringEarliest record to include (YYYY-MM-DD). Defaults to 30 days ago.
to_datestringLatest record to include (YYYY-MM-DD). Defaults to today.
typestringFilter 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.
servicestringFilter to one service, using an id from the services facet.
searchstringSearch 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.
limitintegerPage size, 1–500 (default 50)
offsetintegerPagination 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:

ParameterTypeDescription
from_datestringOnly invoices issued on or after this date (YYYY-MM-DD). Omit for no lower bound.
to_datestringOnly invoices issued on or before this date (YYYY-MM-DD). Omit for no upper bound.
limitintegerPage size, 1–500 (default 50)
offsetintegerPagination offset
include_summarybooleanAlso 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.

note

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"
}
FieldTypeDescription
amountnumberAdjustment 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
typestringcredit — 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)
reasonstringDescription 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"
}
FieldDescription
invoice_totalLifetime Value — total amount actually collected across all non-voided invoices (outstanding amounts excluded).
invoice_total_computed_atWhen invoice_total was last recomputed from the billing backend.
event_count_30d / event_count_prev_30dEvery usage event in the 30-day window / the 30 days before it, regardless of outcome.
failed_count_30dThe 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_completeFalse 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_revenueUsage revenue billed in the 30-day window (from charged usage events).
period_subscription_revenueSubscription (recurring) revenue charged in the 30-day window.
period_revenueperiod_usage_revenue + period_subscription_revenue.
period_costVendor cost of usage in the 30-day window (subscriptions have no vendor cost).
period_marginperiod_revenue − period_cost.
period_margin_pctperiod_margin / period_revenue × 100 (0 when revenue ≤ 0).
lifetime_usage_revenueAll-time usage revenue from charged usage events.
lifetime_subscription_revenueAll-time subscription (recurring) revenue.
lifetime_revenuelifetime_usage_revenue + lifetime_subscription_revenue.
lifetime_costAll-time vendor cost of usage.
lifetime_marginlifetime_revenue − lifetime_cost.
lifetime_margin_pctlifetime_margin / lifetime_revenue × 100 (0 when revenue ≤ 0).
usage_statusWhether 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_throughInstant 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_atWhen 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
}
FieldDescription
has_quotafalse (and services empty) when the plan defines no quota — e.g. a pure pay-as-you-go plan.
reset_periodHow often the quota resets: daily, weekly, semimonthly, monthly, or one_time.
period_fromStart 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_toEnd of the current usage period, the next reset. A plan edit leaves it alone.
currencyAccount currency, for interpreting overage rates and currency-denominated quotas.
services[]One row per metered service that has a quota.
services[].allowed / used / remainingIncluded 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_pctused / allowed × 100 (can exceed 100 in overage).
services[].statusUtilization bucket: within (<80%), approaching (80–100%), or over (>100%). Same buckets as the analytics dashboard's quota chart.
services[].post_quota_behaviorWhat happens past the quota: overage (usage continues, billed), block (service is cut off), or unlimited.
services[].overage_ratePrice per unit charged after the quota (only when behavior is overage).
services[].current_stateLive position now: within, approaching, exhausted_blocked, or in_overage.
services[].livetrue 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:

StateWhen it appearsMeaning
withinbelow 80% of the included quotaNormal usage, well inside the quota.
approaching80% 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_overageusage past 100% on an overage (or metered postpaid) planThe included quota is exceeded and further usage is billed at the overage rate.
exhausted_blockedat or past 100% on a block planThe 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_overage at exactly 100% rather than approaching, and used is 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 read within.

Note: status is the plain utilization bucket and can differ from current_state — e.g. a block service at 100% reports status: "approaching" but current_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.