Appearance
Channels
| Method | Path | Body | Response |
|---|---|---|---|
PUT | /v1/tenants/{t}/channels/{channelRef} | see below | { channel } |
GET | /v1/tenants/{t}/channels | ?type&owner_agent_ref&unowned&address&limit&after_id | { channels, nextAfterId } |
DELETE | /v1/tenants/{t}/channels/{channelRef} | — | 204 — see Deleting |
Listing lines, oldest first. Filters: type (WHATSAPP / SIP), owner_agent_ref (the agent's personal lines; an unknown agent is a 404), unowned=true (lines with no owner, e.g. numbers not yet given to anyone; not together with owner_agent_ref), address (the number, with or without +). Without limit every match comes back and nextAfterId is null. With limit (1–500) you get a page; pass nextAfterId back as after_id for the next one, until it is null.
WhatsApp line:
json
{
"type": "WHATSAPP",
"display_name": "Support line",
"address": "+442079460000",
"provider_account_id": "<Meta phone_number_id>",
"credentials": { "access_token": "<Meta system-user token>" },
"inbound_queue_ref": "support",
"recording_enabled": true
}SIP line (a DID on a carrier trunk):
json
{
"type": "SIP",
"display_name": "Beirut office",
"address": "+442079460123",
"sip_trunk_id": 1,
"inbound_queue_ref": "support"
}Personal line (a DID that belongs to one agent — e.g. a product's end user):
json
{
"type": "SIP",
"address": "+442079460456",
"sip_trunk_id": 1,
"owner_agent_ref": "user-7",
"ring_timeout_seconds": 30
}channel: { id, ref, type, displayName, address, providerAccountId, sipTrunkId, phoneNumberId, inboundQueueId, ownerAgentId, ringTimeoutSeconds, recordingEnabled, recording, status } — phoneNumberId: a SIP line's number; recordingEnabled: the line's own choice (null: it follows its tenant); recording: what applies to it (Recording policy).
| Field | Values | Omitted |
|---|---|---|
type | WHATSAPP, SIP (required) | — |
address | required, ≤50 chars | — |
display_name | ≤255 chars | null |
provider_account_id | required for WHATSAPP (Meta phone_number_id); a string ≤191 chars, or an integer | null |
sip_trunk_id | required for SIP; an integer | null |
credentials | object; null clears them | kept |
inbound_queue_ref | a queue of this tenant — a shared line | null |
owner_agent_ref | an agent of this tenant — a personal line; not together with inbound_queue_ref | null |
ring_timeout_seconds | 5–60: how long a personal line rings its owner | null (30) |
recording_enabled | true, false, or null: the tenant's (Recording policy) | null |
status | ACTIVE, DISABLED | ACTIVE |
For a SIP channel, address is the DID. It may be given as +44…, 44… or 0044…, and is stored and returned in E.164 (+44…) — inbound calls to that number reach this channel, and outbound calls from it show it as the caller. sip_trunk_id is one of the trunks GET /v1/sip-trunks lists: a platform trunk or one of this consumer's own (400 for any other). Outbound customers are E.164 numbers or sip: URIs.
Shared and personal lines. A line with inbound_queue_ref is shared: inbound calls go to its IVR flow (if one takes the call) or wait in the queue, and any agent of the tenant may call out from it. A line with owner_agent_ref is personal:
- An inbound call rings the owner on all their devices (
call:incoming,assignmentType: DIRECT), whatever their availability — the shift only matters to queues. - The owner on another call: the caller gets busy (SIP
486) and the call endsBUSY. The owner with no connected device and no push token: it endsNO_ANSWERat once. Unanswered afterring_timeout_seconds:NO_ANSWER. The owner declines:REJECTED. Each of these names the owner (agentRef) and the line (channelRef) incall.ended. - Only the owner may place outbound calls from it (
403 line_not_owned).
A line with neither has no route: its inbound calls are rejected (REJECTED, terminatedBy: SYSTEM) rather than ringing anyone.
Credentials are encrypted at rest and never returned. A provider_account_id can belong to only one channel, and an address to one channel of its type — for SIP, one per trunk: the same number may be a line on two trunks (two carriers each delivering it), not twice on one (400 otherwise, with details.field address or provider_account_id).
SIP trunks
GET /v1/sip-trunks
GET /v1/sip-trunks/{id}
POST /v1/sip-trunks
PUT /v1/sip-trunks/{id}
POST /v1/sip-trunks/{id}/submit
POST /v1/sip-trunks/{id}/status
DELETE /v1/sip-trunks/{id}
GET /v1/sip-trunks/{id}/diagnostics
POST /v1/sip-trunks/{id}/test
PUT /v1/sip-trunks/{id}/rate-decks
GET /v1/carrier-templatesThe carrier trunks your SIP lines can use: platform trunks, shared by every consumer and run by the Calloop operator, and your own carrier connections, which only your lines can use. You can put lines of any of your tenants on any of them, and different lines on different trunks.
json
{
"selfService": "APPROVAL",
"trunks": [
{ "id": 7, "name": "acme-carrier", "owner": "CONSUMER", "status": "ACTIVE",
"host": "198.51.100.20", "port": 5060, "transport": "UDP",
"inboundSourceCidrs": ["198.51.100.0/24"], "ingressHost": "k3v9q0d2xa.sip.calloop.example",
"numberRules": null, "techPrefixes": null, "codecs": null, "hasCredentials": true, "reviewNote": null,
"submittedAt": "2026-10-12T09:00:00.000Z", "reviewedAt": "2026-10-12T09:20:00.000Z", "createdAt": "2026-10-12T08:55:00.000Z",
"buyDeckId": 12, "inboundBuyDeckId": 13 },
{ "id": 1, "name": "platform-carrier", "owner": "PLATFORM", "status": "ACTIVE" }
]
}Your own trunks show their settings (never the credentials): host/port/ transport are where outbound calls go; inboundSourceCidrs the addresses inbound calls are taken from; ingressHost where to tell your carrier to send the trunk's calls — sip:<number>@<ingressHost>, which names the trunk, so its calls are known as this trunk's whatever other trunk has the number (null when the platform has no ingress domain: then the source address decides); numberRules ({ country_code, national_prefix? }) how national numbers from the carrier are read; techPrefixes the codes your carrier puts in front of numbers (below; null: none); codecs the carrier leg's (null = G.711); buyDeckId / inboundBuyDeckId its carrier sheets, what your carrier charges you (Your trunks' carrier sheets; null: none). A platform trunk shows only its name and status.
Adding your own (selfService, set by the operator per account: OFF — the operator adds your trunks, these return 403 self_service_off; APPROVAL — you add them and the operator approves each, the default; AUTO — live once you submit them):
GET /v1/sip-trunks/{id} | { trunk } — one of yours, or a platform trunk your lines may use |
POST /v1/sip-trunks | { name, host, port?, transport?, inbound_source_cidrs?, country_code?, national_prefix?, tech_prefixes?, codecs?, username?, password?, template_id?, follow_template?, clear_credentials? } → 201 { trunk }, a DRAFT. name once per account (409 exists, details.field: "name"); at most the platform's number of trunks per account (409 too_many_trunks) |
PUT /v1/sip-trunks/{id} | The whole trunk as in POST (no name) → { trunk }; credentials kept unless new ones are given or clear_credentials: true, tech_prefixes kept when left out (null removes them). Not while SUSPENDED (409 suspended); a submitted (PENDING or ACTIVE) trunk left with no address is a 400 on inbound_source_cidrs |
POST /v1/sip-trunks/{id}/submit | DRAFT → PENDING (waiting for the operator) or, under AUTO, ACTIVE → { trunk }. Needs at least one address (409 no_addresses) |
POST /v1/sip-trunks/{id}/status | { status: "ACTIVE" | "DISABLED" }: turn a live trunk off and on → { trunk } |
DELETE /v1/sip-trunks/{id} | 204; refused while lines use it (409 in_use) or while it's SUSPENDED (409 suspended) |
A step the trunk's status doesn't allow (submitting a trunk that isn't a DRAFT, enabling one that isn't DISABLED, disabling one that isn't ACTIVE) is 409 invalid_state.
| Field | Values | Omitted |
|---|---|---|
name | ≤64 chars: letters, digits, ., -, _, starting with a letter or digit | required |
host | ≤255 chars, a public hostname or address | required (or the template's) |
port | 1–65535 | 5060 |
transport | UDP, TCP, TLS | UDP |
inbound_source_cidrs | at most 20 addresses or CIDRs, a list or one string split on commas or spaces | none |
country_code, national_prefix | 1–3 digits; 1–4 digits | none |
username, password | ≤128 chars each; a password without username is ignored | none (PUT: kept) |
Rules you can't get round: inbound_source_cidrs are public addresses (no private, loopback, link-local, shared, documentation or multicast ranges), none wider than a /24 (IPv6: /48); host is a public hostname or address (a hostname that resolves to a private address is refused). List every signalling address your carrier sends from, not just host.
Tech prefixes
tech_prefixes: codes agreed with your carrier, in front of the number, so one trunk can carry calls meant for different things. They are not the national prefix (national_prefix, the 0 of a national number).
json
{ "inbound": [{ "prefix": "11#", "purpose": "PROCESS" },
{ "prefix": "22#", "purpose": "FORWARD" },
{ "prefix": "33#", "purpose": "FILTER" }],
"outbound": "99",
"redirect": "44#" }inbound: a call whose number starts with one of these (the longest that fits) goes only where its purpose says:PROCESSto your line with that number,FORWARDto a forwarding rule,FILTERto a call filter (forwarding rules and call filters are set up for you by the platform operator). Nothing else takes it; with nothing there it is refused (404). The prefix is taken off before the number is read. A call without one of your prefixes goes as it always does.outbound: put in front of every number your lines dial through the trunk.redirect: put in front of the number when a call filter sends a call back to your carrier (as a new call to it), in place of the prefix the call came with.
A prefix is 1-16 digits, # or * (in a SIP URI # is written %23), each inbound one listed once, at most 20 of them. A bad one is a 400 naming tech_prefixes.inbound, .outbound or .redirect.
Status: DRAFT (not live), PENDING (submitted, waiting for the operator), ACTIVE (takes and makes calls), DISABLED (off: no calls), SUSPENDED (by the operator: no calls, and you can't change, enable or delete it). The operator may send a submitted trunk back to DRAFT with what to change (reviewNote); a lifted suspension comes back DISABLED. Each of the operator's decisions is a sip_trunk.status_changed event. A line can be put on a trunk before it's live; its calls arrive once it is. In the dashboard, adding and changing trunks needs the provision role (owners and admins); everyone else reads them. A session launched for one tenant sees each trunk's id, name, owner and status only, as for a platform trunk, and can't read diagnostics or carrier templates (403).
Carrier templates: GET /v1/carrier-templates → { templates: [{ id, name, host, port, transport, inboundSourceCidrs, numberRules, codecs, notes }] }, the carriers the platform knows. POST /v1/sip-trunks with template_id takes the template's settings for whatever the body leaves out; without addresses of its own the trunk follows the template's (followTemplate: true), which the operator keeps up to date for every trunk that follows them. Giving the trunk addresses of its own (PUT) stops it following; follow_template: true follows them again. Your own trunks show templateId and followTemplate.
Diagnostics (your own trunks): GET /v1/sip-trunks/{id}/diagnostics → { diagnostics: { target, ping, lastInvite, refusals, pingSeconds } }. Calloop sends each live trunk's carrier a SIP OPTIONS every pingSeconds: ping is the last one's { up, status, reason, rttMs, error, at, target } (up: any final answer but a timeout or a 5xx). lastInvite { at, source, number } is the last call the carrier sent; refusals the calls Calloop refused on this trunk in the last 7 days, newest first, at most 50, { at, status, source, number, reason }, the reason in words — 403: 203.0.113.9 isn't one of this trunk's addresses, 404: +442079460555 isn't a line on this trunk. POST /v1/sip-trunks/{id}/test pings the carrier now and returns { ping } (once every 10 seconds: 429 too_soon; 503 sip_unavailable while the SIP gateway is down).
Numbers
GET /v1/numbers?status&tenant_refYour numbers: each SIP line's number on its trunk, and numbers that were on a line and were released (its line deleted, or given another number).
json
{
"numbers": [
{ "id": 12, "number": "+442079460555", "sipTrunkId": 7, "trunkName": "acme-carrier",
"origin": "BYOC", "status": "ASSIGNED", "tenantRef": "acme-sales", "channelRef": "main",
"verifiedAt": null, "createdAt": "2026-10-12T09:00:00.000Z", "releasedAt": null }
]
}origin: BYOC (your own carrier's trunk) or PLATFORM (a platform trunk); status: ASSIGNED (on a line), RELEASED, and later AVAILABLE / PORTING. Saving a SIP line assigns its number; you don't create numbers yourself. A session for one tenant sees that tenant's numbers only.