Billing & Credits
Billing on the platform comes down to one model. Hold onto it and every endpoint in this chapter is just a window onto it:
Your account has a single pool of credits. 1 credit = 1 second of assistant time. Your plan adds credits to the pool every month. Everything your assistants do — calls, chats, automations — draws from that pool. You can optionally ring-fence part of the pool for one app (a cap), so that app can never spend more than you've set aside. Auto top-up keeps the pool from running dry. The ledger shows every movement, in and out.
That's the whole system. In detail:
- The pool. One balance per account, shared by all your apps. Buy credits or receive your monthly plan allowance and they land here; usage drains from here.
- Caps (optional). Give one app its own ring-fenced allowance, moved out of the pool. A capped app spends only its cap — useful for bounding spend per app or per customer. Uncapped apps draw straight from the pool.
- Auto top-up (optional). When a balance falls below your threshold, the platform charges your saved card automatically, within limits you set.
- The ledger. Every debit and credit — each call, each top-up, each cap movement — is one row, queryable per app and per account.
A small number of apps are billed individually rather than from a
pool (billingMode: "app"); for those, read "the app's own
balance" wherever this chapter says "the pool".
Requires view_billing. Returns your account's pool
snapshot (balance, ownerEmail, billingMode) plus a per-app summary. Apps
are scoped to your account — you never see another account's apps
here.
Requires view_billing. Returns every visible app with
its billing snapshot (balance, tier, cap state, ownerEmail) — the data
behind the dashboard's apps grid.
curl 'https://api.everycallhandled.com/api/v1/billing/merchant' \
-H 'Authorization: Bearer YOUR_TOKEN'curl 'https://api.everycallhandled.com/api/v1/billing/bots' \
-H 'Authorization: Bearer YOUR_TOKEN'Requires view_billing.
Query params: month=YYYY-MM OR
from/to, category,
limit (default 50, max 200)
Response (200):
The summary form returns
{ "totals": { byCategory, byDirection }, "balanceStart", "balanceEnd", "rowCount" }.
curl 'https://api.everycallhandled.com/api/v1/apps/{appId}/ledger' \
-H 'Authorization: Bearer YOUR_TOKEN'curl 'https://api.everycallhandled.com/api/v1/apps/{appId}/ledger/summary' \
-H 'Authorization: Bearer YOUR_TOKEN'{
"items": [
{
"id": "...",
"occurredAt": "2026-05-10T11:32:30Z",
"category": "voice-phone",
"direction": "debit",
"amountCredits": 145,
"balanceAfter": 13157,
"note": "Call from +44...",
"ref": "call-_+447941190142_abc123",
"billingSource": "merchant"
}
],
"nextCursor": "..."
}Requires view_billing. Same shape as the app variants,
aggregated across all apps under an account. The caller's account must
match or be a parent of the queried account. Use the summary form for
dashboards showing customer-by-customer usage; use the full form for
line-by-line investigation.
curl 'https://api.everycallhandled.com/api/v1/merchants/{merchantId}/ledger' \
-H 'Authorization: Bearer YOUR_TOKEN'curl 'https://api.everycallhandled.com/api/v1/merchants/{merchantId}/ledger/summary' \
-H 'Authorization: Bearer YOUR_TOKEN'Requires edit_billing. Creates a Stripe Checkout link
for a 100-minute credit top-up landing in your account pool, priced at
your plan's tier rate. The price and pool target are resolved
server-side from your account — the request body is empty. Nothing is
charged until the checkout is completed at the link.
Response:
curl -X POST 'https://api.everycallhandled.com/api/v1/billing/topup-session' \
-H 'Authorization: Bearer YOUR_TOKEN'{
"checkoutUrl": "https://checkout.stripe.com/c/pay/cs_...",
"sessionId": "cs_...",
"amountPence": 6500,
"minutes": 100,
"tier": "pro"
}Your plan sets your monthly credit allowance and your per-minute rate. Plan management is fully self-serve.
Requires view_billing.
Response (200):
Accounts without a self-serve subscription get
{ "eligible": false, "plans": [], "current": null } — plan
changes for those accounts go through support. Accounts on a
legacy (grandfathered) price get
legacyPlan: true and an empty plans array:
switching would irreversibly forfeit the legacy deal, so no targets are
offered.
curl 'https://api.everycallhandled.com/api/v1/dashboard/billing/plans' \
-H 'Authorization: Bearer YOUR_TOKEN'{
"eligible": true,
"currency": "gbp",
"legacyPlan": false,
"current": { "priceId": "price_...", "tier": "pro", "interval": "month", "label": "Pro (monthly)", "amountPence": 19900 },
"cancelAtPeriodEnd": false,
"cancelAt": null,
"currentPeriodEnd": 1753968000,
"plans": [
{ "priceId": "price_...", "tier": "starter", "isCurrent": false, "direction": "downgrade" },
{ "priceId": "price_...", "tier": "pro", "isCurrent": true, "direction": "same" }
]
}Requires edit_billing (the one grantable "manage
billing" permission covers all three).
Change plan — body { "tier": "pro" }
(or "priceId", optionally
"interval": "month"). The new price is applied with
proration; the platform then rewrites the account tier and grants any
upgrade credits shortly after — the response confirms the switch was
submitted, not that credits have landed yet.
Response (200):
{ "changed": true, "targetPriceId": "price_...", "proration_behavior": "create_prorations", "direction": "upgrade" }
— or { "changed": false, "reason": "already-on-plan" }.
Errors: 422 no active subscription;
400 unknown/non-switchable plan;
409 legacy-plan when the current price is grandfathered —
resend with "confirmForfeitLegacy": true only after the
account holder explicitly accepts losing the legacy deal (it cannot be
restored).
Cancel — end-of-period cancel with an empty body: renewals stop but everything keeps working until the period ends, and prepaid credits are kept (no clawback).
Response (200):
{ "scheduledCancel": true, "cancelAt": 1753968000, "currentPeriodEnd": 1753968000 }
Resume — undoes a scheduled cancel any time before the period end. Empty body.
Response (200):
{ "resumed": true, "currentPeriodEnd": 1753968000 }
curl -X POST 'https://api.everycallhandled.com/api/v1/dashboard/billing/change-plan' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"tier":"pro"}'curl -X POST 'https://api.everycallhandled.com/api/v1/dashboard/billing/cancel' \
-H 'Authorization: Bearer YOUR_TOKEN'curl -X POST 'https://api.everycallhandled.com/api/v1/dashboard/billing/resume' \
-H 'Authorization: Bearer YOUR_TOKEN'For apps funded by the pool (billingMode="merchant"),
you can ring-fence part of the pool as a per-app cap. A
capped app spends only its cap; the pool only moves when you allocate
more credits or when the monthly refill runs. Use caps to bound spend
per app — or, as a partner, per customer.
How cap state reads:
GET /api/v1/apps/{appId}returns the cap attributes inline:merchantCapCredits(current cap balance),merchantCapAllocated(lifetime allocated),merchantCapMonthlyAllowance,merchantCapCarryForward,merchantCapLastReset.- If
merchantCapAllocated > 0, the app is capped — usage drains the cap, not the pool. merchantCapMonthlyAllowance > 0turns on automatic monthly refill (cycle anchored to first allocation).
Monthly refill semantics. The refill runs on each app's monthly anchor date. Important — expired credits evaporate; they do not return to the pool:
Behavioural notes:
- Unused allowance carries forward exactly one month. Anything still unused a month later evaporates. There is no second grace period.
- The pool funds the full allowance on every refill — including when expired credits would have "covered" the cost. If you build reconciliation logic that assumes expired credits return to the pool, your numbers will diverge from ours.
- Pool-insufficient blocks the entire refill. If the pool can't cover the allowance, the cap stays as-is for that cycle (no partial refill) and the account is alerted.
- Top-up bundles bought separately stay on the pool until either consumed by direct usage or pulled into a per-app cap by the next refill.
Let A = merchantCapMonthlyAllowance
B = current merchantCapCredits (cap balance)
C = current merchantCapCarryForward (last cycle's leftover)
P = current account pool balance
On refill:
expiring = min(C, B) // last cycle's leftover, still unspent → evaporates
newCap = (B - expiring) + A
newCarry = B - expiring // this cycle's contribution becomes next cycle's carry-forward
poolDelta = -A // pool ALWAYS funds A; expired credits VANISH (not returned)
If P < A:
block + alert; no writeRequires edit_billing.
Body:
credits(int, required) — credits to move from pool to app cap (1 credit = 1 second).idempotencyKey(string, recommended) — UUID to safely retry. Without it, an accidental double-click double-allocates.setLastReset(bool, default false) — when togglingmerchantCapMonthlyAllowanceon for the first time, set true so the scheduler doesn't immediately refill again.
Response (200):
Errors:
400 INSUFFICIENT_POOL_BALANCE— the pool can't cover the request.
curl -X POST https://api.everycallhandled.com/api/v1/apps/$APP_ID/cap-topup \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"credits": 6000,
"idempotencyKey": "'$(uuidgen)'",
"setLastReset": false
}'{
"success": true,
"appId": "...",
"creditsAllocated": 6000,
"capBalance": 9600,
"capAllocatedTotal": 24000,
"poolBalance": 53000
}The inverse of cap top-up: returns the app's remaining cap balance to the pool and zeroes every cap attribute (balance, lifetime allocated, monthly allowance, carry-forward, last reset), so the app goes back to uncapped and draws directly from the pool again.
Requires edit_billing. Only valid for apps with
billingMode=merchant.
Body:
idempotencyKey(string, recommended) — UUID to safely retry. A replayed request returns the same result withidempotent: trueinstead of moving credits twice.
Response (200):
If the app is already uncapped this is an honest no-op:
{ "removed": false, "reason": "..." } with a 200.
Notes:
- The transfer is ordered pool-first (credit the pool, then zero the cap), so a mid-flight failure can only ever leave extra credits, never lost ones, and is rolled back automatically.
- Two ledger rows record the move (
cap-remove-debiton the app,cap-remove-crediton the pool) — you'll see them in the ledger endpoints above. - If the app had a per-app auto top-up configured while capped, removing the cap means future auto top-up reads follow the pool again (see below).
curl -X POST https://api.everycallhandled.com/api/v1/apps/$APP_ID/cap-remove \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "idempotencyKey": "'$(uuidgen)'" }'{
"removed": true,
"appId": "...",
"returnedToPool": 3600,
"poolBalance": 56600,
"merchantId": "acme"
}Auto top-up charges the saved card automatically when a credit balance falls below a threshold, so a busy line never goes dark mid-month. Nothing is charged when you call these endpoints — they only read and write the configuration; the platform performs the actual off-session charge when the threshold is crossed.
There are two scopes, matching the pooled billing model:
- Account (pool) level — the natural home for most customers: one setting that keeps the shared pool topped up, funding every uncapped app.
- App level — for individually billed apps and apps with a per-app cap, where the app has its own balance to protect.
Both scopes share the same configuration shape:
enabled(bool) — master switch.thresholdSeconds(int) — top up when the balance drops below this many credit-seconds. Maximum 36,000 (600 minutes) for customer saves.amountPence(int) — how much to charge per top-up: minimum 500 (£5), maximum 100,000 (£1,000).monthlyCapPence(int) — spending ceiling per calendar month;0means no cap. If set, it must be at least one top-up amount.
Reads also return hasSavedCard (whether a card is on
file from a plan purchase) and source
("merchant" when the config lives on the pool,
"app" when it lives on the app).
Safety rails on execution, regardless of configuration: at least 1 hour between charges, at most 3 charges per day, and the monthly cap is always honoured. Credits are purchased at your plan's per-minute rate (the same rate as a manual top-up).
{
"enabled": true,
"thresholdSeconds": 600,
"amountPence": 2000,
"monthlyCapPence": 10000
}Requires view_billing. You can read your own account's
pool setting; a parent account can read a child's.
curl 'https://api.everycallhandled.com/api/v1/accounts/{merchantId}/auto-topup' \
-H 'Authorization: Bearer YOUR_TOKEN'curl 'https://api.everycallhandled.com/api/v1/apps/{appId}/auto-topup' \
-H 'Authorization: Bearer YOUR_TOKEN'Requires edit_billing.
The app-level endpoint is scope-aware: it follows
the app's billing source. For an individually billed app or a capped
app, the config protects the app's own balance. For an uncapped
pool-funded app, reads and writes fall through to the shared pool config
— the same setting the account-level endpoint manages — and the response
says so with "source": "merchant". If two uncapped apps
both "have" auto top-up configured, it's one pool setting seen from two
places, not two settings.
Errors:
409— enabling without a saved card. Purchase a plan first (that saves the card), then enable.403— the account is outside your scope.
curl -X PATCH 'https://api.everycallhandled.com/api/v1/apps/{appId}/auto-topup' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"enabled":true,"thresholdSeconds":1200,"amountPence":2000,"monthlyCapPence":10000}'curl -X PATCH https://api.everycallhandled.com/api/v1/accounts/$ACCOUNT_ID/auto-topup \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "enabled": true, "thresholdSeconds": 1200, "amountPence": 2000, "monthlyCapPence": 10000 }'