Skip to content

Channels ​

MethodPathBodyResponse
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).

FieldValuesOmitted
typeWHATSAPP, SIP (required)—
addressrequired, ≤50 chars—
display_name≤255 charsnull
provider_account_idrequired for WHATSAPP (Meta phone_number_id); a string ≤191 chars, or an integernull
sip_trunk_idrequired for SIP; an integernull
credentialsobject; null clears themkept
inbound_queue_refa queue of this tenant — a shared linenull
owner_agent_refan agent of this tenant — a personal line; not together with inbound_queue_refnull
ring_timeout_seconds5–60: how long a personal line rings its ownernull (30)
recording_enabledtrue, false, or null: the tenant's (Recording policy)null
statusACTIVE, DISABLEDACTIVE

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 ends BUSY. The owner with no connected device and no push token: it ends NO_ANSWER at once. Unanswered after ring_timeout_seconds: NO_ANSWER. The owner declines: REJECTED. Each of these names the owner (agentRef) and the line (channelRef) in call.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-templates

The 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}/submitDRAFT → 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.

FieldValuesOmitted
name≤64 chars: letters, digits, ., -, _, starting with a letter or digitrequired
host≤255 chars, a public hostname or addressrequired (or the template's)
port1–655355060
transportUDP, TCP, TLSUDP
inbound_source_cidrsat most 20 addresses or CIDRs, a list or one string split on commas or spacesnone
country_code, national_prefix1–3 digits; 1–4 digitsnone
username, password≤128 chars each; a password without username is ignorednone (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: PROCESS to your line with that number, FORWARD to a forwarding rule, FILTER to 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_ref

Your 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.

Calloop — the call engine behind your product.