Appearance
Pricing your tenants
Layer 2: you price and bill your own tenants, with plans and sheets of your own (never the platform's), and a tenant's billing account held with you — you take its money with your own payment provider and record it here.
| Method | Path | |
|---|---|---|
GET POST | /v1/price-plans | Your plans: GET → { plans }, each with tenants (how many are on it); POST { name, currency, description, items } (below) → 201 { plan }. A markup without a sheet (method: DECK_MARKUP, no deck_id) is a markup on what the usage costs you: on a call over your own trunk with a carrier sheet, that sheet's row (your carrier's price; the platform's fee for the trunk isn't in it, so set your markup to cover it); otherwise what you pay the platform for it, for any kind |
PUT DELETE | /v1/price-plans/{id} | Replace a plan ({ plan }); delete one no tenant is on (409 in_use) |
GET POST | /v1/rate-decks | Your sheets: GET → { decks }; POST { name, currency?, kind?, description? } → 201 { deck }. kind: SELL (the default) — your prices, for your plans; BUY — a carrier sheet, what your carrier charges you, for your own trunks (below) and your plans. name ≤128, description ≤500, currency default USD |
GET | /v1/rate-decks/{id} | { deck, stats, usage, imports }: the sheet; stats — its rows in force at a glance (rowsInForce, destinations, minRate, maxRate, avgRate, scheduled, nextChange); usage — the trunks it is on (outbound, inbound) and the plans that price from it; its last 50 imports |
PUT | /v1/rate-decks/{id} | { name, currency?, description? } |
DELETE | /v1/rate-decks/{id} | A sheet no plan and no trunk uses (409 in_use) |
GET | /v1/rate-decks/{id}/entries | Its rows, a page at a time: ?q&status&sort&min_rate&max_rate&page&limit (below) |
GET | /v1/rate-decks/{id}/lookup | ?number&seconds (seconds 0–86400, default 60) → { number, entry, call: { seconds, billedSeconds, amount } }: the row pricing a number now (its longest prefix in force) and what a call that long costs; when none does, { number, entry: null } with no call |
GET | /v1/rate-decks/{id}/export | The rows the same filters as /entries find (no paging), in the import template's headings — Destination,Prefix,Rate,Effective Date,Initial Period,Recurring Period,Connection Fee — so an export imports as it is. By destination (A → Z, then prefix) unless sort says otherwise. format: csv (the default) or xlsx (an Excel workbook: a styled header row with filters, prefixes as text, dates as dates) |
POST | /v1/rate-decks/{id}/import | { csv, columns?, date_format?, default_initial_seconds?, default_increment_seconds?, mode: FULL|CHANGES, file_name, dry_run } → { import } (with its reading) or, for a dry run, { reading, preview }; /import/preview is the dry run too, and only dry_run: true (JSON true) is one. Everything but csv is optional: what you leave out is found (below), so a carrier's file usually needs nothing else. columns: { prefix, rate, destination?, effective_from?, initial_seconds?, increment_seconds?, connection_fee? }, each the file's heading. date_format DD/MM/YYYY, MM/DD/YYYY or YYYY-MM-DD; a YYYY-MM-DD date is read whatever the format. default_initial_seconds / default_increment_seconds 1–3600: the billing blocks of rows the file gives none. mode default FULL. A dry run whose prefix or rate column isn't found answers preview: null with reading.missing; importing it is a 400 on columns, as is a column named that the file hasn't (the message lists its headings). A file with no readable row is a 400 on csv. Up to 20 MB of CSV. The dashboard also takes an Excel file (.xlsx), converted to CSV in the browser |
POST | /v1/rate-decks/{id}/entries | { prefix, destination?, rate, initial_seconds?, increment_seconds?, connection_fee? } → 201 { entry }: a price for a prefix the sheet has none for, in force from now. prefix digits (a leading + is dropped); rate per minute, 0 or more; the blocks 1–3600, default 60; connection_fee default 0. A prefix that already has a price (in force or scheduled) is a 409 exists: change that row instead |
PUT | /v1/rate-decks/{id}/entries/{entryId} | The same fields but prefix (a row's prefix doesn't change) → { entry }. A price in force ends now and the new one starts now, until the old one would have ended (a change scheduled after it stays); calls already priced keep the old price, which stays in the history (?status=ENDED). A price scheduled for later is changed in place. The same values again is a 400 unchanged; an ended price a 409 ended |
DELETE | /v1/rate-decks/{id}/entries/{entryId} | → { entry }. A price in force ends now (history kept); one scheduled for later is removed, and the price before it runs on. An ended price is a 409 ended. Each change by hand is in the sheet's imports with kind: "EDIT" and a note saying what changed (a file's are kind: "FILE") |
PUT | /v1/tenants/{t}/price-plan | { price_plan_id } (one of yours, or null) → { tenantRef, pricePlanId } |
GET | /v1/tenants/{t}/billing | { tenantRef, pricePlanId, account } (null until you open one); /transactions, /invoices (?page&limit, limit 1–200, default 50) as for Billing, but 404 not_found while the tenant has no account |
PUT | /v1/tenants/{t}/billing-account | { mode: PREPAID|POSTPAID, currency?, credit_limit?, invoice_due_days? } → { account }. currency default USD, and can't change while the account has a balance (400 on currency); credit_limit is used only for POSTPAID; invoice_due_days 0–120, default 15 |
POST | /v1/tenants/{t}/billing/top-ups | { amount, reference? } — a payment you took: credited at once (201 { balance, account }). amount at least 0.01, reference ≤191 chars; prepaid accounts only (409 not_prepaid) |
POST | /v1/tenants/{t}/billing/adjustments | { amount, reason } → 201 { balance, account }. amount non-zero (negative = a debit), reason required, ≤255 chars |
POST | /v1/tenants/{t}/billing/invoices/{id}/mark-paid | A postpaid tenant's invoice you were paid → { invoice }; 409 not_open unless it is OPEN |
PUT | /v1/sip-trunks/{id}/rate-decks | { buy_deck_id, inbound_buy_deck_id } → { trunkId, buyDeckId, inboundBuyDeckId }: your own trunk's carrier sheets (below). Both are set every time: one left out is cleared |
POST | /v1/usage/reprice | { from, to } → { from, to, cleared, ratings }: your usage in the window priced again (below). The window as in Usage: default the last 30 days, at most 400. Not from a session for one tenant (403 forbidden) |
- A tenant on your plan is priced on each record (
tenantChargesin Usage, with how: your markup, your sheet's row). Its account works as yours does: prepaid (outbound stops at zero), postpaid (a monthly invoice — issued by you, numberedCL-…, no payment provider — under a credit limit; overdue it blocks and then suspends); a call is held to both accounts and endsCREDIT_EXHAUSTEDwhen either runs out. - In the dashboard these need the owner role (
billing); the tenant's own staff (a launched session) can readGET /v1/tenants/{t}/billing…— their account — and nothing else here. - The platform's operator can see and change your sheets too (create one for you, import into it, set your carrier sheets on your trunk, rename, delete) — for support, or to set you up. Each such change is in your dashboard's audit log as done by the operator.
A plan's items
A plan is { name, currency?, description?, items }: name ≤128 chars, description ≤500, currency default USD, items a list of at most 50 prices. Each item:
| Field | Values | Omitted |
|---|---|---|
kind | CALL, RECORDING, STORAGE, MONTHLY_FEE (a tenant's day), SEAT, NUMBER | required |
method | FLAT, DECK (a sheet's rows; CALL only), DECK_MARKUP | required |
direction, channel, trunk_owner | INBOUND/OUTBOUND; WHATSAPP/SIP; PLATFORM/CONSUMER — what the item applies to | any |
initial_seconds, increment_seconds | 1–3600 | 60 |
flat_rate | the price, for FLAT | — |
deck_id | one of your sheets, in the plan's currency, for DECK (and optionally DECK_MARKUP); CALL only | — |
markup_percent | -100 or more, for DECK_MARKUP | — |
A bad item is a 400 naming it (details.field: "items[2].deck_id").
A sheet's rows
GET /v1/rate-decks/{id}/entries → { total, page, limit, entries, match? }; each entry { id, prefix, destination, rate, initialSeconds, incrementSeconds, connectionFee, effectiveFrom, effectiveTo } (the rate per minute, the blocks in seconds).
| Parameter | |
|---|---|
q | A number (digits, with +, spaces, dashes or brackets): the rows that price it — its prefixes, 44 and 447 for +44 7700 900123 — and the longer prefixes under it; the answer then has match, the row in force that prices the number (its longest prefix), or null when none does. Any other text: rows whose destination contains it |
status | CURRENT (in force now), SCHEDULED (starting later), ENDED (replaced or removed: the price history). None: in force and scheduled |
sort | prefix (the default), destination, rate (cheapest first), -rate (dearest first) |
min_rate, max_rate | Bounds on the rate per minute (400 when not a number) |
page, limit | limit up to 500, 50 by default |
How a file is read
POST /v1/rate-decks/{id}/import takes a carrier's file as it comes. What the request doesn't say is found, each part in this order:
| Part | Found from |
|---|---|
| Columns | columns if you send it (then only those). Else, per field, the heading this sheet's last import used, when the file has it; else a heading the field is known by, any case: the template's (Prefix, Destination, Rate, Effective Date, Initial Period, Recurring Period, Connection Fee) or a carrier's usual name (Code, Dial Code, Zone, Recurring Charge, Price, Increment, Setup Fee…) |
| Date order | date_format; else what the dates show (a first part over 12 is a day, a second one is; all YYYY-MM-DD); else the last import's; else DD/MM/YYYY, marked not sure |
| Billing blocks | The file's own Initial / Recurring Period columns (only a recurring one: every block that size); else default_initial_seconds / default_increment_seconds; else the last import's; else 60/60 |
Each response carries reading: how the file was read, so you can check it before you apply.
json
{
"header": ["Zone", "Code", "Effective Date", "Recurring Charge", "Recurring Period"],
"columns": { "destination": "Zone", "prefix": "Code", "effective_from": "Effective Date", "rate": "Recurring Charge", "increment_seconds": "Recurring Period" },
"found": { "prefix": "heading", "rate": "heading", "...": "given | last | heading" },
"missing": [],
"dates": { "format": "DD/MM/YYYY", "found": "default", "sure": false, "sample": "05/08/2025" },
"periods": { "inFile": true, "initialSeconds": 60, "incrementSeconds": 60, "found": "file" }
}missing:prefixand/orratewhen no column was found for them. Sendcolumnsnaming them (pick fromheader).dates.sure: false: no date in the file shows its order and nothing said which, andsampleis a date the two orders read differently. Senddate_formatto be sure. When every date reads the same either way (01/01), the order doesn't matter andsureistrue.periods.found: "default": the file has no billing blocks and none were given, so 60/60 is used. Senddefault_initial_seconds/default_increment_secondsif the carrier bills otherwise (1/1per second,60/1a minute's minimum then per second).
An applied import's columns, date order and chosen blocks become the sheet's, for its next file: the next file from the same carrier needs nothing but csv.
Your trunks' carrier sheets
On your own trunks (SIP trunks) your carrier bills you, not the platform. Give Calloop your carrier's price lists and it prices those calls for you — the whole setup is yours, with no step by the operator:
- Create a sheet of kind
BUYfor each list (POST /v1/rate-decks, e.g. one for outbound, one for inbound) and import the carrier's CSV into it (POST /v1/rate-decks/{id}/import;default_initial_seconds/default_increment_secondsset the billing blocks for rows that don't carry them —1/1for per-second billing). - Set them on the trunk:
PUT /v1/sip-trunks/{id}/rate-deckswithbuy_deck_id(outbound calls, priced by the number dialled) andinbound_buy_deck_id(inbound calls, priced by your line's own number);nullclears one. Only your ownBUYsheets (400otherwise), only on your own trunks (404otherwise); allowed whatever your trunk self-service policy. The trunk shows them asbuyDeckId/inboundBuyDeckId. - From then on each call on the trunk gets a
carrierCostin Usage (orNO_RATEwith the reason, e.g. no row for the number). A tenant plan's markup without a sheet is on that cost, so your tenants' prices follow your carrier's.
Calls from before the sheets were set (or with no row for their number) are priced by POST /v1/usage/reprice over their window: what has no price from your carrier sheets or your tenants' plans is priced with what you have now; what was priced keeps its price. What you pay the platform (charges) is the operator's and isn't touched. Your carrier's rates are yours: the operator's carrier cost and margin never include them.