Charging API
Reserve funds before you spend them, then bill what was actually used.
Three endpoints do everything:
| Endpoint | Does |
|---|---|
POST /api/v1/charging/authorize | Reserve units, or extend a reservation you already hold |
POST /api/v1/charging | Bill usage — settling a reservation, or standalone |
POST /api/v1/charging/unlock | Hand a reservation back without billing |
All three take an array, so one request can cover several elements at once: services on
authorize and charge, locks on unlock. A single-element call is the one-element case.
All three name the customer by customer_external_id: the external_id you gave them. The
customer's own id is not accepted here.
Authentication
A charging-role API key, sent as X-API-Key. Get one from Developers → Test Charges in the
dashboard.
curl -X POST https://your-tenant.portaaim.com/api/v1/charging \
-H "X-API-Key: sk_live_..." -H "Content-Type: application/json" -d '{...}'
Checking that charging is configured
GET /api/v1/charging/config
Reports whether this deployment is set up to accept charges. Any valid API key is accepted, and the answer is the same for every caller: it describes the deployment, not you.
{
"configured": true,
"charging_backend": "billing_engine"
}
configured is true only when charging_backend is billing_engine. To find out what your own
key can do, and which environment it bills in, call
GET /api/v1/api-keys/me.
Reading a reply
A refusal is a normal reply. Business outcomes come back as 200 with a status on each service.
You never have to catch an exception to find out a customer is out of funds. Only these are errors:
| Status | When |
|---|---|
422 | the body is malformed |
422 | your key was minted before charging credentials existed. Re-provision it from Developers → Test Charges |
401 | we do not recognize the key |
403 | the key carries no charging role |
503 | your sandbox environment is not bound yet |
A backend we cannot reach is not on that list. It comes back as 200, with engine_timeout or
engine_unavailable on the affected service, because the outcome is unknown rather than failed. See
When the outcome is unknown.
All three write endpoints answer in one envelope, so write one reader for them. Every 200
carries four things:
| What | Means |
|---|---|
interaction_id | this one call. Every element of it shares the id. Read the call's legs back at GET /api/v1/events?interaction_id=... |
session_id | the session the work was filed under. Read it at GET /api/v1/sessions/{session_id}?customer_external_id=... |
status | the roll-up over the elements: ok, mixed or failed |
| one list of verdicts | one entry per element you sent, in request order |
The list is named after what you sent: services on authorize and charge, locks on unlock.
Every entry carries its own event_id, the id of that element's record, readable at
GET /api/v1/events/{event_id}. The rest of an entry differs by operation, because a reservation,
a charge and a release report different things.
Name the customer on a session read. A session_id you chose yourself is a label, and reusing one
across customers gives each customer their own session under that name, so the id alone is half a
key.
Keep the ids even though your charging key cannot use them. The three reads named here take an admin credential, and a charging key is refused on them, so it is your dashboard or your admin integration that resolves an id, not the client doing the charging. Store them with your own records anyway: they are what turns a support question into one lookup.
Each reply carries a request-level status:
status | Means |
|---|---|
ok | every service succeeded |
mixed | some succeeded, some did not |
failed | none succeeded |
mixed is final. A service that succeeded is never rolled back because another failed.
A 422 refusal carries no services
It carries two fields and nothing else:
{ "error_code": "invalid_request",
"error": "services[0].units must be an integer greater than 0, got 0" }
There is no interaction_id, no session_id and no services[], because nothing reached the
billing backend and nothing was written. So check the HTTP status first, then read error_code
from the top level on a 422 and from each service on a 200.
A 422 carries one of three codes and no others: invalid_request for a request that breaks a
rule, and backdate_in_future or backdate_out_of_range for a bill_time you may not bill
against. Those two appear nowhere else, so read them here rather than on a service. Every other
code is a service verdict and reaches you inside a 200.
A list index in the message is always written in brackets, so services[0].units names the units
field of the first service you sent. Branch on error_code; the error text is for a person
reading a log.
Authorize
Reserve units so you can spend them safely.
POST /api/v1/charging/authorize
{
"customer_external_id": "acme-42",
"session_id": "conv-8f21",
"lock_period_seconds": 300,
"services": [
{ "service_code": "deepgram-stt", "item_code": "STT-MINUTE", "units": 120, "units_min": 10 }
]
}
{
"interaction_id": "ia_01H...",
"session_id": "conv-8f21",
"status": "ok",
"services": [{
"service_code": "deepgram-stt",
"event_id": "tx_01H...",
"lock_id": "9F2C1A0B 4E5D6071 8293A4B5 C6D7E8F9",
"status": "authorized",
"requested_units": 120,
"granted_units": 45,
"retained_units": 0,
"expires_at": "2026-08-11T16:05:00Z",
"partial": true,
"terminal": false,
"unlimited": false
}]
}
Getting less than you asked for is success, not failure
This is the single most important thing on this page.
granted_units is your ceiling — not units. The reply above authorized 45 of the 120
requested, and status is authorized. Spend 45.
| Field | Use it for |
|---|---|
granted_units | what you may actually spend |
partial | you got less than you asked for; carry on within the grant |
terminal | the grant fell below your units_min — stop, do not retry |
unlimited | no per-session cap; granted_units is null |
expires_at | when the reservation lapses — always the backend's figure, never an echo of your lock_period_seconds |
Set units_min to the smallest grant worth having. Below it the reservation is released for you and
terminal comes back true, which is your signal to wind down rather than retry.
Extending a reservation
Name the lock_id you already hold. Add used_units — the cumulative total consumed so far,
not the amount since last time — and the backend rates the usage as it goes.
{ "customer_external_id": "acme-42",
"services": [{ "service_code": "deepgram-stt", "item_code": "STT-MINUTE",
"units": 60, "lock_id": "9F2C1A0B ...", "used_units": 40 }] }
units on an extension is a ceiling on what must still be available, not an increment and not a
running total.
Extending twice inside one second is refused with invalid_request. Reservations are not meant to
be renewed in a tight loop. A retry under the key the first extension already used is the exception:
it is recognised as a repeat and answered with that extension's own result.
Charge
Bill usage. Name a lock_id to settle a reservation, or omit it to charge standalone.
POST /api/v1/charging
{
"customer_external_id": "acme-42",
"session_id": "conv-8f21",
"services": [
{ "service_code": "deepgram-stt", "item_code": "STT-MINUTE",
"units": 37, "lock_id": "9F2C1A0B ...", "comment": "turns 1-12" }
]
}
{
"status": "ok",
"services": [{
"service_code": "deepgram-stt",
"event_id": "tx_01H...",
"status": "charged",
"reported_units": 37,
"charged_quantity": 37,
"charged_amount": "74.00000",
"currency": "USD"
}]
}
Settling releases the unused remainder automatically — you do not need to unlock afterwards.
Read status, not the figures, to learn whether a charge landed
Two outcomes report an amount of zero and a third reports none. charged means the charge landed.
On failed, error_code also says whether the outcome is known, as described under
outcome unknown:
status | reported_units | charged_quantity | charged_amount | Meaning |
|---|---|---|---|---|
charged | 7 | 7 | "0.000000000" | genuinely free: a zero-priced rate, or covered by quota |
charged | 1 | 0 | "0.000000000" | too small to price: the usage is worth less than the smallest amount the plan can bill, so it is recorded at zero |
failed | 7 | null | null | nothing was applied: the backend computed a charge and then discarded it, so there is no amount and no cost to report. error_code names the cause |
charged_quantity comes back in the unit you report, even when the service bills in a larger
one: 100 reported against a 50:1 service answers a quantity of 100, and only the amount reflects
the ratio.
Amounts go to nine decimal places
charged_amount carries up to nine decimals, so a charge worth a fraction of a hundredth of a cent
is billed as itself rather than rounded away. Parse it as a decimal number, never as a fixed-width
string: "0.000000150" and "74.000000000" are both valid replies, and the number of decimals
follows the precision the plan is rated at.
Prices are separate and still take five decimals. To price below that, set a larger
units per billing unit on the service and keep the price at five: a price of 1.00 per million
units bills a single unit at 0.000001 exactly. Do not try to configure a finer price.
A plan created before your billing backend was switched to nine decimals keeps billing at five
until its rating precision is raised. On such a plan usage too small to bill at 0.00001 comes
back charged with an amount of zero and a charged_quantity of 0, the second row of the table
above.
Raising a plan's rating precision is an operator action, so ask whoever runs your billing backend
rather than reading the zero as a free charge.
Billing usage that already happened
Send bill_time and the backend prices it at the rate in force then, not now.
{ "customer_external_id": "acme-42",
"bill_time": "2026-08-10T23:58:00Z",
"services": [{ "service_code": "deepgram-stt", "item_code": "STT-MINUTE", "units": 12 }] }
Three bounds. It may not be in the future, and it may not be more than 90 days old: both are
refused with a 422 before anything is billed, carrying backdate_in_future or
backdate_out_of_range. Inside those bounds, nothing rates before the customer's own creation
instant, and that one comes back on the service as not_rated with no money moved, reported and
never silent.
Event times must increase within one reservation, counted to the whole second. A charge at or
before the last one already billed answers invalid_request on that service: report it again with
a later bill_time, or wait for the next second. Resending the identical call under its original
idempotency_key is the exception, and the one this rule must not catch: the charge that set that
event time is the very call being retried, so the repeat is answered with its figures instead of
being refused.
Unlock
Hand a reservation back without billing.
POST /api/v1/charging/unlock
{ "customer_external_id": "acme-42", "locks": [{ "lock_id": "9F2C1A0B ..." }] }
{
"interaction_id": "ia_01H...",
"session_id": "checkout-42",
"status": "ok",
"locks": [{
"lock_id": "9F2C1A0B 4E5D6071 8293A4B5 C6D7E8F9",
"event_id": "tx_01H...",
"session_id": "checkout-42",
"service_code": "deepgram-stt",
"status": "released",
"known": true,
"funds_available_after": "2026-08-11T16:00:05Z"
}]
}
Releasing is idempotent and forgiving: releasing twice, or releasing a lock the backend has never
heard of, both answer released. Send an idempotency_key and, after every release in the request
has succeeded, a repeat of that request is answered with the first reply, with replayed: true and
no second call to the billing backend. A request in which a release failed leaves the key free,
so resending it releases again: handing a reservation back must always be possible.
known tells you whether we held a record for the lock. funds_available_after is when the funds
are expected to be free again — a re-authorization before that instant may still see them locked. A lock we hold no record of reports service_code as
null, because there is no reservation on file to name one.
A release reports no quantity, because the backend returns none for it.
A release belongs to the session that opened the reservation, so it reads back beside the
authorization it ends at GET /api/v1/sessions/{session_id}. You do not send that session on the
release — the reservation already carries it. Each lock reports its own, and the top-level
session_id names it when every lock of the request shares one; it is null when a single request
released locks of several sessions. A lock we hold no record of has no session to join, so its
release is filed under an id minted for the request, which takes the same form as a lock id — never
send that back as one.
The four models
The API does not police which of these you follow — a client implements one and knows which, and a per-request check would cost latency on every call to catch a mistake you are not making. So the sequences below are the contract.
1. Reconcile — you don't know the size up front
Best for open-ended work. Reserve generously, bill what was used.
authorize(units: 1000) → granted 1000, lock_id L
... your work runs ...
charge(units: 137, lock_id: L) → charged 137, remainder released automatically
One authorize, one charge. No unlock needed.
2. Progressive — long sessions you must not lose
Reserve a little, top up as you go, and report usage along the way so an abandoned session still bills what it used.
authorize(units: 100) → granted 100, lock_id L
charge(units: 20, lock_id: L, interim: true) → session total so far: 20
authorize(units: 100, lock_id: L, used_units: 20) → extended
charge(units: 55, lock_id: L, interim: true) → session total so far: 55
charge(units: 80, lock_id: L) → final; session closes at 80
Both the units you send and the charged_amount you get back are running totals for the whole
session. At 2.00 per unit the three charges above are answered 40.00, then 110.00, then 160.00 — and
the customer is billed 160.00 once, not the sum of the three replies.
Two rules follow: a session that reports interim usage must keep doing so until the closing charge,
and every call needs its own idempotency_key — reusing one across the calls of a session is
what makes them look like retries of each other.
A session that reports no interim usage works the other way round: each charge bills its own units, so closing one in several charges bills every one of them. The over-grant tag is raised after their sum passes the reservation, and stays on every charge after that.
3. Single lock — you know the size exactly
authorize(units: 50) → granted 50, lock_id L
charge(units: 50, lock_id: L) → charged 50
Check granted_units even here: the customer may not have had funds for all 50.
4. Offline — usage you are told about afterwards
No reservation at all.
charge(units: 12, bill_time: "2026-08-10T23:58:00Z") → charged at that day's rate
The funds check happens when funds are reserved. A charge naming no lock_id reserves nothing,
so it is not refused for lack of funds — it bills, and the customer's balance goes further negative
than their credit limit allows. Nothing reclaims it afterwards.
That is the trade this model makes: you are reporting usage that already happened, so there was nothing to refuse at the time. If you need the customer's ability to pay enforced, check the balance yourself before charging, or use one of the three models above.
Several services at once
Up to 16 per request, each with its own result.
{ "customer_external_id": "acme-42", "session_id": "conv-8f21",
"services": [
{ "service_code": "deepgram-stt", "item_code": "STT-MINUTE", "units": 12 },
{ "service_code": "openai-gpt4", "item_code": "GPT4-1K", "units": 8 }
] }
Charges are processed simultaneously. Authorizations for one customer are processed one at a time, because reserving funds concurrently would make them compete for the same balance — so a wide authorize takes longer than a wide charge. If speed matters, authorize the dominant service only and charge the rest standalone.
Give each service its own lock_id when settling; two services may not share one.
Why a charge was refused
error_code on the failing service:
| Code | Meaning |
|---|---|
insufficient_funds | out of money, or the credit limit is reached |
quota_exhausted | a quota that blocks further usage is used up — funds are not the problem |
insufficient_funds_or_quota | one of the two; the backend refused without saying which |
credit_limit | the credit limit specifically |
funds_locked | another reservation for this customer is holding the funds |
customer_not_found | no customer with this customer_external_id in the environment you addressed. Check the id, and check the environment: a live customer charged against sandbox answers this too, and so does a customer that has been terminated. Answered on a charge as well as on a reservation |
service_not_available | the customer's plan does not include this service, or their plan blocks it (a free trial that grants nothing, or an item marked prohibited). Answered on a charge as well as on a reservation, so a service outside the plan reads the same way whichever call you made |
customer_blocked | the account is blocked, suspended, expired or closed |
account_in_use | another session for this customer is already running |
call_limit_exceeded | the customer has reached the number of simultaneous sessions their plan allows |
zero_grant | nothing could be reserved |
partial_lock_rejected | the grant fell below your units_min |
lock_not_found / lock_not_open | unknown reservation, or already settled or released |
not_rated | the backend discarded the charge because no rate matched this item at the billed time, or for a cause the backend did not name. charged_amount and charged_quantity are null |
invalid_request | the item_code is not available on the service_code you named, or this session was already charged at that event time. Report again with a later bill_time, or resend the original call under its own idempotency_key, which is recognised as a repeat instead. This is the one code that also appears at the top level of a 422, where it means the request itself was refused |
request_in_progress | an earlier call under this idempotency_key has no result to give yet. If it is still running, retry in a moment. If it never reported an outcome, no retry can settle whether it took effect: the message names the instant the key frees up, and a new key is the only way to bill that usage |
engine_busy | the billing backend was too busy to take this service and did not process it, so nothing was charged for it. Retry after a pause, and resend only the services that answered engine_busy: the others in the same request may have been charged. If you sent an idempotency_key, use a new one: the same key returns this answer for the rest of its window |
engine_timeout / engine_unavailable / billing_engine_error / no_charge_built | outcome unknown: see below |
An item is chargeable only on the service it is filed under, or on any service once it is marked
global. Name a pair that does not satisfy that and the service is refused with invalid_request
and nothing is billed, whichever call you made. An item filed under no service at all is
chargeable nowhere until you give it a service or make it global, which is the same rule and not
a separate one. A multi-service request refuses only the services
that break the rule; the rest still run. If you are building an integration, read an item's service
from GET /api/v1/items rather than assuming a code works everywhere: moving an item to another
service changes what may be charged against it.
quota_exhausted fires only for a customer who has a credit limit. With no limit the authorize
succeeds past a spent quota instead of being refused, and the usage costs nothing, because
free_quota_block sets the price to zero. Set a credit limit on any postpaid customer whose plan
relies on a quota to stop a service. A prepaid customer is always created on a limit of 0, and a
limit of 0 still stops the service.
backdate_in_future and backdate_out_of_range are not in this table, and never appear on a
service. A bill_time you may not bill against refuses the whole request, so both arrive at the top
level of a 422. See A 422 refusal carries no services.
This set is additive. Treat a code you do not recognise as a generic failure rather than as an error in itself: new causes get their own code as the billing backend learns to name them.
When the outcome is unknown
The last four do not mean "nothing happened". They mean we could not read the answer, and the charge may have landed. Do not assume it failed.
Retrying is safe when you send a key
Send idempotency_key on every call. It is what makes a retry recognisable, and without one a
repeat is always a second charge.
What a key covers: one whole request of one kind (a charge, an authorization or a release), in one environment. A later request of the same kind under the same key is a repeat of the first, whatever it contains: another customer, other services or items, other units, more or fewer services. It is not carried out, and it is answered with the first request's reply.
Protected: a charge or a reservation resent under the same key, for 24 hours by default. The charge bills once, no second hold is taken, and the repeat answers with the original reply, so send a new key when you want to extend a reservation. The window is set per deployment, and it survives a platform restart, because the hold is stored rather than kept in memory.
Not protected: a repeat sent without a key, or under a different key. Nothing then says the two calls are one request.
Refused rather than answered: a repeat that arrives while the original is still running, and a
repeat whose original never reported an outcome. Both come back request_in_progress, and both
messages name the instant the key frees up. Treat that instant as a deadline, not a hint: a
repeat sent after it is a new charge.
The first means retry in a moment and you get the original's result once it reports. If the platform lost the record of that outcome, nothing will ever finish it, and after a few minutes the refusal stops saying "still running" and switches to the second message below, which tells you no retry can settle it.
The second means nobody can tell whether the original took effect, so no retry will settle it: sending a new key is the only way to bill that usage, accepting that the earlier call may have taken effect too.
Use a different key for each logical call, including each interim report in a session and each customer. A key is what says "this is that same request again"; reusing one across genuinely different calls is how you get an answer meant for another call, and the call you meant is never made. Note that a refusal is an outcome too: a charge refused for insufficient funds answers that same refusal for the rest of the window, so top up and retry under a new key, not the old one.
Key format: 32 to 128 printable ASCII characters, counted after surrounding spaces, tabs and
line breaks are trimmed. A UUID v4 fits, and so do 32 hex characters; a ULID, at 26 characters, is
too short. A key outside that format refuses the whole request with 422 invalid_request and
nothing runs, so fix the key and send the request again. An empty key means no key.
A UUID v4 is enough in most cases, but longer is safer: generate keys as long as you can, up to 128
characters.
How a recognised retry answers
A retry the platform recognizes comes back with the original reply: the same interaction_id,
session_id, services, event_ids and figures, the original reported_units included. Only the
markers differ: replayed: true at the top level and, on each service that succeeded,
status: "replayed". A charge also carries replayed: true on each service. A service that failed
the first time replays as failed.
{"replayed": true, "status": "ok",
"services": [{"status": "replayed", "reported_units": 10, "charged_quantity": 10,
"charged_amount": "1.500000000", "replayed": true}]}
Read the flag before you add anything up. A replayed reply moved no money, and PortaAIM counts it
in no revenue, cost, margin or quantity total. The retry is still recorded: each event the original
call wrote gets a copy, marked replayed: true, with replay_of set to the original's event_id,
the retry's own time, and the original's session, so you can see how often your integration
resends. The event_ids in the reply are the original call's, so read those to see the events
that billed.
Limits
| Services per request | 16 |
| Reservation length | 1 second to 24 hours; default 1 hour |
| Between extensions of one reservation | at least 1 second |
| Backdating | not into the future, at most 90 days back, and not before the customer existed |
| Idempotency key | 32 to 128 printable ASCII characters |
Session grouping
session_id is your own label tying related events together: a conversation, a job, a call. It
is not policed: reuse one for the same customer and both sets of work appear under it. Two customers
may also reuse one, and their work still never merges, because a session is identified by the
customer and the label together. Reading such a session back therefore needs both halves. For more
information, see Get a single session.
Omit the label and one is minted, so every event is always groupable. session_label adds a
human-readable name for the dashboard.
Everything is visible under Events, filterable by session, customer, service and status.