Skip to content

Carriers (SIP trunks) ​

Generated from DialerDigital/core commit 0fc18008b3c8 — spec openapi/dialer-v1.yaml v0.134.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.

Español

SIP trunk registry (BYOC/house) + SBC provisioning.

Endpoints ​

MethodPathSummary
GET/v1/carriersList carriers (trunks)
POST/v1/carriersRegister a SIP trunk
GET/v1/carriers/{id}Fetch one carrier
PATCH/v1/carriers/{id}Replace the destination allowlist and the contract limits of a BYOC trunk
GET/v1/carriers/{id}/healthHealth of a BYOC trunk (up | down | unknown)
GET/v1/carriers/{id}/health/eventsHistory of a BYOC trunk's health transitions
POST/v1/carriers/{id}/provisionPush the trunk to the SIP edge (Kamailio)
POST/v1/carriers/{id}/revokeRemove the trunk from the SIP edge

List carriers (trunks) ​

http
GET /v1/carriers

Responses

200 ​

Own trunks + inherited empresa-wide trunks.

FieldTypeRequiredDescription
carriersarray of Carrieryes
carriers[].idstring (uuid)no
carriers[].account_idstring (uuid)no
carriers[].tenant_idstring (uuid) | nullnonull = empresa-wide (shared) trunk inherited by every sede.
carriers[].scopeempresa · sedenoDerived from tenant_id.
carriers[].kindhouse · byocno
carriers[].namestringno
carriers[].sip_proxystring | nullno
carriers[].source_ipsarray of stringno
carriers[].allowed_destinationsarray of stringnoThe destination allowlist of a BYOC trunk (core#1281, ADR #172): the exact E.164 numbers it may dial. [] = no restriction. Stored encrypted under the trunk's scope key.
carriers[].max_concurrent_callsinteger | nullnoContract limit of a BYOC trunk (core#2176): simultaneous calls it admits. A dial that finds this many live calls on the trunk is refused with egress.carrier_channels_full.v1. null = no limit.
carriers[].max_daily_attemptsinteger | nullnoContract limit of a BYOC trunk (core#2176): dial attempts per UTC day (00:00 to 24:00 UTC). A dial that finds the day spent is refused with egress.daily_attempts_exhausted.v1. null = no limit.
carriers[].allowed_dial_modesarray of manual · preview · progressive · predictive | nullnoContract limit of a BYOC trunk (core#2176): the dial modes it permits. A dial in another mode — agentless mass dial included — is refused with egress.dial_mode_not_allowed.v1. null = no restriction; an empty list is a 400.
carriers[].srtpoff · offer · requirenoSRTP policy of the trunk's egress leg (core#2333, SBC kamailio#189). off (default) is plain RTP, exactly as before. offer offers SRTP and accepts plain RTP in the answer; require refuses an answer that is not SRTP. offer and require need a TLS sip_proxy (sips: or ;transport=tls) and a sip_proxy without an srtp parameter of its own: anything else is a 400. Core writes ;srtp=<mode> to the edge for offer/require only.
carriers[].statusactive · disabled · retiredno
carriers[].created_atstring (date-time)no
carriers[].updated_atstring (date-time)no

401 — Unauthorized ​

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Register a SIP trunk ​

http
POST /v1/carriers

scope: "empresa" creates a shared trunk (tenant_id null) inherited by every sede of the account; scope: "sede" (default) creates a per-sede trunk. account_id/tenant_id are NEVER taken from the body — the router injects them from the session (a sede cannot forge another account's carrier).

scope: "empresa" additionally requires an account_admin user (see User.role) — it overrides the routing of every sibling sede, so a member or a machine key gets 403. The RLS policy enforces it, not only this route.

Since MT-SEC-49 MT.3b an unrecognized key is a 400 naming it. account_id is the one exception and is documented below: it is accepted and ignored. tenant_id is NOT — sending it is a cross-tenant forgery attempt and gets the 400.

source_ips is accepted with both scopes. With scope: "empresa" the trunk carries INBOUND too (ADR #203): the IPs identify the account at the SIP edge (byoctenant[<ip>] = acct:<account>), and the destination is decided by the dialed number — a DID of any sede of the account — not by the IP. An address belongs to ONE trunk platform-wide: one already assigned to another trunk (of any sede or empresa) is a 400 (already assigned to another trunk). An empty list is accepted: the empresa-wide trunk then carries OUTBOUND only.

allowed_destinations (core#1281, ADR #172) is the destination allowlist of a BYOC trunk: exact E.164 numbers, at most 100, no duplicates. While any active BYOC trunk a sede egresses through declares one, the platform places no call of that sede — campaign, manual dial, callback or external transfer leg — towards a number outside every declared list; each refusal is a tenant_policy row in the blocked stats with rule egress.destination_not_allowed.v1. A list that cannot be read refuses too (egress.destination_allowlist_unavailable.v1). Absent or [] = no restriction. On a house trunk a non-empty list is a 400.

max_concurrent_calls, max_daily_attempts and allowed_dial_modes (core#2176) are the contract limits of a BYOC trunk — the terms the client has with the carrier. All three are optional and null/absent is no limit, which is how a trunk behaved before they existed. The platform enforces them on every dial that leaves through the trunk, fail-closed: a dial whose mode is outside allowed_dial_modes, or that finds the trunk at max_concurrent_calls live calls or at max_daily_attempts attempts of the UTC day, is not placed and is filed as a tenant_policy row in the blocked stats with rule egress.dial_mode_not_allowed.v1, egress.carrier_channels_full.v1 or egress.daily_attempts_exhausted.v1. A limit that cannot be read or counted refuses too (egress.trunk_limits_unavailable.v1). Both counts are derived from the call records, and an empresa-wide trunk counts the calls of every sede of the account. On a house trunk a limit is a 400.

The destination of a BYOC trunk (sip_proxy, core#2345) is the SIP edge's next hop as written, so it has to be a public sip:/sips: endpoint. It is a 422 when it is malformed or points to the platform's own network: a loopback, private (RFC 1918), link-local, CGNAT (100.64/10), multicast or unspecified address, a name internal to the cell (localhost, a single label, .local, .internal, .svc, ...) or a range the cell declares as its own. It is a 409 with code trunk_endpoint_shared when its host and port are already the endpoint of a platform carrier or of an active BYOC trunk of another account: two accounts cannot share one destination. Two sedes of the SAME account can. A house trunk and a BYOC trunk with no sip_proxy are not checked, and nothing is written when the answer is an error.

Request body (JSON, required)

FieldTypeRequiredDescription
kindhouse · byocyes
namestringyes
sip_proxystringno
source_ipsarray of stringnoBare IPv4/IPv6 addresses of the trunk's peers (inbound ACL), stored canonicalized and unique across the platform, with either scope. With scope: "empresa" they identify the account, not a sede: the dialed number decides which sede takes the call (ADR #203).
statusactive · disabled · retiredno
allowed_destinationsarray of stringnoThe destination allowlist of a BYOC trunk (core#1281, ADR #172): the exact E.164 numbers it may dial. [] = no restriction. Stored encrypted under the trunk's scope key.
max_concurrent_callsinteger | nullnoContract limit of a BYOC trunk (core#2176): simultaneous calls it admits. A dial that finds this many live calls on the trunk is refused with egress.carrier_channels_full.v1. null = no limit.
max_daily_attemptsinteger | nullnoContract limit of a BYOC trunk (core#2176): dial attempts per UTC day (00:00 to 24:00 UTC). A dial that finds the day spent is refused with egress.daily_attempts_exhausted.v1. null = no limit.
allowed_dial_modesarray of manual · preview · progressive · predictive | nullnoContract limit of a BYOC trunk (core#2176): the dial modes it permits. A dial in another mode — agentless mass dial included — is refused with egress.dial_mode_not_allowed.v1. null = no restriction; an empty list is a 400.
srtpoff · offer · requirenoSRTP policy of the trunk's egress leg (core#2333, SBC kamailio#189). off (default) is plain RTP, exactly as before. offer offers SRTP and accepts plain RTP in the answer; require refuses an answer that is not SRTP. offer and require need a TLS sip_proxy (sips: or ;transport=tls) and a sip_proxy without an srtp parameter of its own: anything else is a 400. Core writes ;srtp=<mode> to the edge for offer/require only.
scopesede · empresanoRequest-only field; responses derive it from tenant_id.
account_idstring (uuid)noAccepted and IGNORED ON PURPOSE: the account always comes from the session, so a forged value has no effect. Declared so that THIS key does not become a 400 now that unknown keys are. It does NOT make the whole rendered trunk round-trip: id, created_at and updated_at are a 400, and tenant_id is a 400 DELIBERATELY — sending it is a cross-tenant forgery attempt and gets named, not dropped in silence.
json
{
  "kind": "byoc",
  "name": "postman-trunk-1751600000",
  "sip_proxy": "sip:pstn.example.test:5060",
  "source_ips": [
    "203.0.113.9"
  ]
}

Responses

201 ​

Registered trunk.

FieldTypeRequiredDescription
carrierobject (Carrier)yes
carrier.idstring (uuid)no
carrier.account_idstring (uuid)no
carrier.tenant_idstring (uuid) | nullnonull = empresa-wide (shared) trunk inherited by every sede.
carrier.scopeempresa · sedenoDerived from tenant_id.
carrier.kindhouse · byocno
carrier.namestringno
carrier.sip_proxystring | nullno
carrier.source_ipsarray of stringno
carrier.allowed_destinationsarray of stringnoThe destination allowlist of a BYOC trunk (core#1281, ADR #172): the exact E.164 numbers it may dial. [] = no restriction. Stored encrypted under the trunk's scope key.
carrier.max_concurrent_callsinteger | nullnoContract limit of a BYOC trunk (core#2176): simultaneous calls it admits. A dial that finds this many live calls on the trunk is refused with egress.carrier_channels_full.v1. null = no limit.
carrier.max_daily_attemptsinteger | nullnoContract limit of a BYOC trunk (core#2176): dial attempts per UTC day (00:00 to 24:00 UTC). A dial that finds the day spent is refused with egress.daily_attempts_exhausted.v1. null = no limit.
carrier.allowed_dial_modesarray of manual · preview · progressive · predictive | nullnoContract limit of a BYOC trunk (core#2176): the dial modes it permits. A dial in another mode — agentless mass dial included — is refused with egress.dial_mode_not_allowed.v1. null = no restriction; an empty list is a 400.
carrier.srtpoff · offer · requirenoSRTP policy of the trunk's egress leg (core#2333, SBC kamailio#189). off (default) is plain RTP, exactly as before. offer offers SRTP and accepts plain RTP in the answer; require refuses an answer that is not SRTP. offer and require need a TLS sip_proxy (sips: or ;transport=tls) and a sip_proxy without an srtp parameter of its own: anything else is a 400. Core writes ;srtp=<mode> to the edge for offer/require only.
carrier.statusactive · disabled · retiredno
carrier.created_atstring (date-time)no
carrier.updated_atstring (date-time)no

400 — BadRequest ​

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "error": {
    "code": "bad_request",
    "message": "from must be RFC-3339"
  }
}

401 — Unauthorized ​

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

json
{
  "error": {
    "code": "forbidden",
    "message": "tenant is suspended"
  }
}

409 ​

trunk_endpoint_shared: the sip_proxy host and port are already the endpoint of a platform carrier or of an active BYOC trunk of another account. The answer does not say whose.

json
{
  "error": {
    "code": "trunk_endpoint_shared",
    "message": "sip_proxy is already the endpoint of another trunk and cannot be used for this one"
  }
}

422 ​

The sip_proxy of a BYOC trunk is not a public SIP endpoint (core#2345): malformed, or a loopback, private, link-local, CGNAT, multicast or unspecified address, an internal name, or a range the cell declares as its own. The message names the closed reason.

json
{
  "error": {
    "code": "unprocessable",
    "message": "sip_proxy is not an allowed destination for a BYOC trunk (private): it must be a public sip:/sips: endpoint"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Fetch one carrier ​

http
GET /v1/carriers/{id}

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

The carrier.

FieldTypeRequiredDescription
carrierobject (Carrier)yes
carrier.idstring (uuid)no
carrier.account_idstring (uuid)no
carrier.tenant_idstring (uuid) | nullnonull = empresa-wide (shared) trunk inherited by every sede.
carrier.scopeempresa · sedenoDerived from tenant_id.
carrier.kindhouse · byocno
carrier.namestringno
carrier.sip_proxystring | nullno
carrier.source_ipsarray of stringno
carrier.allowed_destinationsarray of stringnoThe destination allowlist of a BYOC trunk (core#1281, ADR #172): the exact E.164 numbers it may dial. [] = no restriction. Stored encrypted under the trunk's scope key.
carrier.max_concurrent_callsinteger | nullnoContract limit of a BYOC trunk (core#2176): simultaneous calls it admits. A dial that finds this many live calls on the trunk is refused with egress.carrier_channels_full.v1. null = no limit.
carrier.max_daily_attemptsinteger | nullnoContract limit of a BYOC trunk (core#2176): dial attempts per UTC day (00:00 to 24:00 UTC). A dial that finds the day spent is refused with egress.daily_attempts_exhausted.v1. null = no limit.
carrier.allowed_dial_modesarray of manual · preview · progressive · predictive | nullnoContract limit of a BYOC trunk (core#2176): the dial modes it permits. A dial in another mode — agentless mass dial included — is refused with egress.dial_mode_not_allowed.v1. null = no restriction; an empty list is a 400.
carrier.srtpoff · offer · requirenoSRTP policy of the trunk's egress leg (core#2333, SBC kamailio#189). off (default) is plain RTP, exactly as before. offer offers SRTP and accepts plain RTP in the answer; require refuses an answer that is not SRTP. offer and require need a TLS sip_proxy (sips: or ;transport=tls) and a sip_proxy without an srtp parameter of its own: anything else is a 400. Core writes ;srtp=<mode> to the edge for offer/require only.
carrier.statusactive · disabled · retiredno
carrier.created_atstring (date-time)no
carrier.updated_atstring (date-time)no

401 — Unauthorized ​

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

404 — NotFound ​

Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Replace the destination allowlist and the contract limits of a BYOC trunk ​

http
PATCH /v1/carriers/{id}

core#1281 (ADR #172) and core#2176. What a trunk changes after its alta: the destination allowlist (allowed_destinations, the whole list is replaced; [] or null lifts the restriction) and its contract limits (max_concurrent_calls, max_daily_attempts, allowed_dial_modes; null lifts each — see POST /v1/carriers). At least one of the four keys is required; a key that is present replaces the stored value and one that is absent is left as it was. Any other key is a 400 naming it — sip_proxy, source_ips and the scope are provisioned into the SIP edge and are not edited in place.

An empresa-wide trunk restricts every sede that inherits it, so changing its list takes an account_admin user (403 otherwise), like creating it. Another empresa's trunk, or the platform house trunk, is a 404; a retired trunk is a 409. On a house trunk a non-empty list is a 400.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Request body (JSON, required)

FieldTypeRequiredDescription
allowed_destinationsCarrierAllowedDestinations | nullno
max_concurrent_callsinteger | nullnoContract limit of a BYOC trunk (core#2176): simultaneous calls it admits. A dial that finds this many live calls on the trunk is refused with egress.carrier_channels_full.v1. null = no limit.
max_daily_attemptsinteger | nullnoContract limit of a BYOC trunk (core#2176): dial attempts per UTC day (00:00 to 24:00 UTC). A dial that finds the day spent is refused with egress.daily_attempts_exhausted.v1. null = no limit.
allowed_dial_modesarray of manual · preview · progressive · predictive | nullnoContract limit of a BYOC trunk (core#2176): the dial modes it permits. A dial in another mode — agentless mass dial included — is refused with egress.dial_mode_not_allowed.v1. null = no restriction; an empty list is a 400.
json
{
  "allowed_destinations": [
    "+13125550100",
    "+13125550101"
  ],
  "max_concurrent_calls": 1,
  "max_daily_attempts": 20,
  "allowed_dial_modes": [
    "manual",
    "preview"
  ]
}

Responses

200 ​

The trunk with its new list and limits.

FieldTypeRequiredDescription
carrierobject (Carrier)yes
carrier.idstring (uuid)no
carrier.account_idstring (uuid)no
carrier.tenant_idstring (uuid) | nullnonull = empresa-wide (shared) trunk inherited by every sede.
carrier.scopeempresa · sedenoDerived from tenant_id.
carrier.kindhouse · byocno
carrier.namestringno
carrier.sip_proxystring | nullno
carrier.source_ipsarray of stringno
carrier.allowed_destinationsarray of stringnoThe destination allowlist of a BYOC trunk (core#1281, ADR #172): the exact E.164 numbers it may dial. [] = no restriction. Stored encrypted under the trunk's scope key.
carrier.max_concurrent_callsinteger | nullnoContract limit of a BYOC trunk (core#2176): simultaneous calls it admits. A dial that finds this many live calls on the trunk is refused with egress.carrier_channels_full.v1. null = no limit.
carrier.max_daily_attemptsinteger | nullnoContract limit of a BYOC trunk (core#2176): dial attempts per UTC day (00:00 to 24:00 UTC). A dial that finds the day spent is refused with egress.daily_attempts_exhausted.v1. null = no limit.
carrier.allowed_dial_modesarray of manual · preview · progressive · predictive | nullnoContract limit of a BYOC trunk (core#2176): the dial modes it permits. A dial in another mode — agentless mass dial included — is refused with egress.dial_mode_not_allowed.v1. null = no restriction; an empty list is a 400.
carrier.srtpoff · offer · requirenoSRTP policy of the trunk's egress leg (core#2333, SBC kamailio#189). off (default) is plain RTP, exactly as before. offer offers SRTP and accepts plain RTP in the answer; require refuses an answer that is not SRTP. offer and require need a TLS sip_proxy (sips: or ;transport=tls) and a sip_proxy without an srtp parameter of its own: anything else is a 400. Core writes ;srtp=<mode> to the edge for offer/require only.
carrier.statusactive · disabled · retiredno
carrier.created_atstring (date-time)no
carrier.updated_atstring (date-time)no

400 — BadRequest ​

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "error": {
    "code": "bad_request",
    "message": "from must be RFC-3339"
  }
}

401 — Unauthorized ​

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

json
{
  "error": {
    "code": "forbidden",
    "message": "tenant is suspended"
  }
}

404 — NotFound ​

Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

409 — Conflict ​

Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.

json
{
  "error": {
    "code": "conflict",
    "message": "invalid state transition"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Health of a BYOC trunk (up | down | unknown) ​

http
GET /v1/carriers/{id}/health

core#1522 (ADR #196). The state NOW of a BYOC trunk: the latest event of an append-only stream, fed by the SIP edge's OPTIONS probe (reachability, works with no traffic) and corroborated by the CDR (quality: an answer ratio over the last 10 minutes, ignored below 30 attempts).

unknown is a first-class state, never a polite up: a trunk with no measurement yet, one the evaluator could not read for two ticks, and one whose last event is older than 15 minutes (fresh: false, reason: stale — nobody could write) all read unknown. A disabled or retired trunk reads unknown with reason: carrier_inactive and signals: null. The health is informational: routing is not rewritten by it.

Another sede's or account's trunk, a made-up id, a malformed id and the platform house trunk are all a 404. The response never carries the trunk's sip_proxy.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

The trunk's health now.

FieldTypeRequiredDescription
carrier_idstring (uuid)yes
stateup · down · unknownyes
reasonstringyesWhy the trunk is in state: probe_active, probe_inactive, cdr_failure_ratio, cdr_success_ratio, no_fresh_samples, inconclusive (from the last transition), or — computed by the read — no_data (no event yet), stale (last event older than 15 minutes) and carrier_inactive (disabled/retired).
sincestring (date-time) | nullyesWhen the trunk entered state; null with no data or when inactive.
observed_atstring (date-time) | nullyesWhen the last event (confirmations included) was observed.
freshbooleanyesfalse when the last event is older than 15 minutes, there is none, or the trunk is inactive.
signalsTrunkHealthSignals | nullyesWhat the last event saw; null with no event or when inactive.
json
{
  "carrier_id": "6f1c2d4e-8a3b-4c5d-9e7f-0a1b2c3d4e5f",
  "state": "up",
  "reason": "probe_active",
  "since": "2026-10-05T00:41:00Z",
  "observed_at": "2026-10-05T00:51:00Z",
  "fresh": true,
  "signals": {
    "probe": {
      "replicas": {
        "up": 2,
        "down": 0,
        "fresh": 2
      },
      "latency_ms": 38.5
    },
    "cdr": {
      "window_s": 600,
      "attempts": 120,
      "failures": 3
    }
  }
}

401 — Unauthorized ​

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

404 — NotFound ​

Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

History of a BYOC trunk's health transitions ​

http
GET /v1/carriers/{id}/health/events

core#1522 (ADR #196). The rows of the trunk's health stream, newest first, keyset-paginated like /v1/call_attempts. By default only the TRANSITIONS: the confirm rows (same state, one every 5 minutes, they only prove freshness) are left out unless include_confirm=true. Same 404 rule as GET /v1/carriers/{id}/health; a retired trunk keeps its history.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
include_confirmquerybooleannoAlso list the 5-minute confirm rows (default false).
limitqueryintegernoPage size (default 100, max 1000).
cursorquerystringnoOpaque keyset cursor from the previous page's next_cursor.

Responses

200 ​

One page of health events.

FieldTypeRequiredDescription
eventsarray of TrunkHealthEventyes
events[].idstring (uuid)yes
events[].carrier_idstring (uuid)yes
events[].stateup · down · unknownyes
events[].prev_stateup · down · unknown | nullyesnull on the first event; equal to state on a confirm row.
events[].reasonstringyesThe evaluator's reason, or confirm for a freshness row.
events[].evidenceobjectyesWhat the evaluator saw (probe, cdr, consecutive).
events[].observed_atstring (date-time)yes
next_cursorstring | nullyesCursor of the next page; null on the last one.

400 — BadRequest ​

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "error": {
    "code": "bad_request",
    "message": "from must be RFC-3339"
  }
}

401 — Unauthorized ​

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

404 — NotFound ​

Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Push the trunk to the SIP edge (Kamailio) ​

http
POST /v1/carriers/{id}/provision

A BYOC carrier pushes its routing to the edge. A per-sede trunk writes its outbound egress and its inbound ACL (source_ips). An empresa-wide trunk writes the outbound egress of every sede of the account that has no active BYOC trunk of its own — a sede's own trunk wins — and its inbound ACL (source_ips) ONCE for the whole account, not per sede.

A HOUSE carrier is a no-op 200 {"status": "provisioned"}: house calls egress over the platform's default routing, so there is nothing to push.

A revoked BYOC carrier is 409: revoking retires it for good, and only an active trunk can be the one a sede egresses through.

The stored sip_proxy is checked again before anything is pushed (core#2345), with the same rule as the alta: a destination that is not a public SIP endpoint is 422, and one that is already the endpoint of a platform carrier or of an older active BYOC trunk of another account is 409 with code trunk_endpoint_shared. Nothing is pushed in either case.

With no KAMAILIO_RPC_URL configured (dev without the telecom harness) the answer is 200 {"status": "sbc_disabled"} — the row's intent is recorded, it just is not pushed to a live edge.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

Provisioned (house included, as a no-op), or SBC disabled in this environment.

FieldTypeRequiredDescription
statusprovisioned · sbc_disabledyessbc_disabled = no KAMAILIO_RPC_URL in this environment; the row's intent is recorded, not pushed.

provisioned

json
{
  "status": "provisioned"
}

sbc_disabled

json
{
  "status": "sbc_disabled"
}

401 — Unauthorized ​

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

404 — NotFound ​

Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

409 — Conflict ​

Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.

json
{
  "error": {
    "code": "conflict",
    "message": "invalid state transition"
  }
}

422 — Unprocessable ​

Shape is fine, semantics are not (broken domain rule).

json
{
  "error": {
    "code": "unprocessable",
    "message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

502 — SbcError ​

The SIP edge rejected the provisioning request.

json
{
  "error": {
    "code": "sbc_error",
    "message": "the SIP edge rejected the provisioning request"
  }
}

Remove the trunk from the SIP edge ​

http
POST /v1/carriers/{id}/revoke

Only a BYOC carrier can be revoked: a HOUSE carrier is 422. Revoking retires the carrier for good (it can no longer be provisioned) and frees its source_ips for another trunk. Idempotent: revoking it again only re-syncs the edge.

Each affected sede then egresses through the trunk it is left with, the same one its calls are attributed to: its own most recent active trunk, else the account's most recent empresa-wide one, else the house route. Revoking a trunk a sede was not egressing through leaves that sede's egress unchanged. The affected sedes are the trunk's own sede, or, for an empresa-wide trunk, the same sedes provisioning writes (those without an active BYOC trunk of their own).

Revoking an empresa-wide trunk changes the routing of every sibling sede, so it takes an account_admin (403 otherwise), as creating one does.

The inbound ACL of an empresa-wide trunk (source_ips, written as acct:<account> entries) is removed first, once for the whole account, and only then is the egress of the sedes re-synced.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

Revoked (or SBC disabled in this environment).

FieldTypeRequiredDescription
statusprovisioned · sbc_disabledyessbc_disabled = no KAMAILIO_RPC_URL in this environment; the row's intent is recorded, not pushed.

401 — Unauthorized ​

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

json
{
  "error": {
    "code": "forbidden",
    "message": "tenant is suspended"
  }
}

404 — NotFound ​

Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

422 — Unprocessable ​

Shape is fine, semantics are not (broken domain rule).

json
{
  "error": {
    "code": "unprocessable",
    "message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

502 — SbcError ​

The SIP edge rejected the provisioning request.

json
{
  "error": {
    "code": "sbc_error",
    "message": "the SIP edge rejected the provisioning request"
  }
}

Nothing in these docs is legal advice — always confirm compliance posture with your own counsel.