Skip to content

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.

MethodPath
GET POST/v1/price-plansYour 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-decksYour 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}/entriesIts 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}/exportThe 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-paidA 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 (tenantCharges in 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, numbered CL-…, no payment provider — under a credit limit; overdue it blocks and then suspends); a call is held to both accounts and ends CREDIT_EXHAUSTED when either runs out.
  • In the dashboard these need the owner role (billing); the tenant's own staff (a launched session) can read GET /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:

FieldValuesOmitted
kindCALL, RECORDING, STORAGE, MONTHLY_FEE (a tenant's day), SEAT, NUMBERrequired
methodFLAT, DECK (a sheet's rows; CALL only), DECK_MARKUPrequired
direction, channel, trunk_ownerINBOUND/OUTBOUND; WHATSAPP/SIP; PLATFORM/CONSUMER — what the item applies toany
initial_seconds, increment_seconds1–360060
flat_ratethe price, for FLAT—
deck_idone 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
qA 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
statusCURRENT (in force now), SCHEDULED (starting later), ENDED (replaced or removed: the price history). None: in force and scheduled
sortprefix (the default), destination, rate (cheapest first), -rate (dearest first)
min_rate, max_rateBounds on the rate per minute (400 when not a number)
page, limitlimit 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:

PartFound from
Columnscolumns 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 orderdate_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 blocksThe 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: prefix and/or rate when no column was found for them. Send columns naming them (pick from header).
  • dates.sure: false: no date in the file shows its order and nothing said which, and sample is a date the two orders read differently. Send date_format to be sure. When every date reads the same either way (01/01), the order doesn't matter and sure is true.
  • periods.found: "default": the file has no billing blocks and none were given, so 60/60 is used. Send default_initial_seconds / default_increment_seconds if the carrier bills otherwise (1/1 per second, 60/1 a 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:

  1. Create a sheet of kind BUY for 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_seconds set the billing blocks for rows that don't carry them — 1/1 for per-second billing).
  2. Set them on the trunk: PUT /v1/sip-trunks/{id}/rate-decks with buy_deck_id (outbound calls, priced by the number dialled) and inbound_buy_deck_id (inbound calls, priced by your line's own number); null clears one. Only your own BUY sheets (400 otherwise), only on your own trunks (404 otherwise); allowed whatever your trunk self-service policy. The trunk shows them as buyDeckId / inboundBuyDeckId.
  3. From then on each call on the trunk gets a carrierCost in Usage (or NO_RATE with 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.

Calloop — the call engine behind your product.