Skip to content

Diagnostic Twilio

a SIP trunk with no disaster recovery URL loses every call

The trunk works. It has worked for a year. Then the PBX reboots, or the firewall rule expires, or the datacentre link flaps for four minutes, and every inbound call in those four minutes is dropped at Twilio — not sent to voicemail, not answered by an apology, dropped. The field that would have caught them is empty, and it has always been empty, because nothing ever asked you to fill it in.

Read-only key Python and Node.js Tests included
Group of people standing in front of people
Photo by adrianna geo on Unsplash
The short answer

Read GET https://trunking.twilio.com/v1/Trunks?PageSize=1000 and flag every trunk whose disaster_recovery_url is null or empty. That URL is the TwiML endpoint Twilio calls when the trunk's origination URIs are unreachable; it is optional, it defaults to empty, and a trunk provisioned quickly ships without it.

Record disaster_recovery_method, secure and transfer_mode on the same pass. Then, if you want the second half of the picture, read GET /v1/Trunks/{TrunkSid}/OriginationUrls: a trunk with one enabled origination URI has a single point of failure that the disaster recovery URL is the only cover for.

The problem in plain words

There is no error code for this note, because there is no error. A trunk without a disaster recovery URL behaves identically to one with it, right up until the moment the origination URIs stop answering. At that moment the trunk with the URL fetches TwiML and does something — plays a message, forwards to a mobile, drops the call into a queue — and the trunk without it has nowhere to go, so the call ends.

What makes it durable is that the outage that reveals it is short and someone else's fault. The PBX came back, the calls that got through afterwards were fine, and the incident is written up as a PBX incident. The trunk is never looked at, because the trunk was not the thing that broke. It was the thing that had no answer for something else breaking, which is a harder failure to attribute and a much easier one to leave in place.

Trunk createdrecovery url leftemptyRuns for a yearoriginationanswers fineFirewall ruleexpiresevery URIunreachableNo recoveryTwiMLnowhere to sendthe callCallers droppedblamed on the PBX
Nothing here is logged as a call failure. The trunk did what it was configured to do, which was nothing.

Why it happens

The field is optional and empty is the default. Creating a trunk requires a friendly name. Everything else, disaster recovery included, is a later step, and later steps are the ones that get skipped when a trunk is stood up to unblock a migration.

Nothing exercises it. A disaster recovery URL is only fetched when origination fails. You can have one that returns 404 and never find out, and you can have none at all and never find out, and the two are indistinguishable from the outside during normal operation.

Trunk failover intuition comes from the PBX side. Teams configure redundant origination URIs and reasonably conclude they have failover. They have failover between the URIs. They do not have an answer for the case where every URI is unreachable at once, which is the common case: the shared firewall, the shared uplink, the shared power feed.

Trunks are few and long lived. An account has three or four of them, configured once, by someone who may have left. Nobody re-reads a trunk's configuration, so a mechanical check is the only kind that happens twice.

The fix, as a flow

The script keeps not checked and checked but empty apart, because a trunk audited without the origination fetch must never be reported as having no origination URIs. One reads as reassuring and is not.

GET Trunks and OriginationUrlsrecovery url, scheme, enabled URIsRecovery url on httpscovered, leave itRecovery url on httpcleartext when degradedOne enabled URIsingle host, no spareNo recovery urloutage drops every call
One enabled origination URI is not a finding on its own. Combined with an empty recovery URL it is a single host between you and a dropped call.

How to fix it

List every trunk and read the disaster recovery pair

GET https://trunking.twilio.com/v1/Trunks?PageSize=1000, following meta.next_page_url — the trunking API paginates with an absolute URL in meta, not with the relative next_page_uri the 2010-04-01 API uses. Read disaster_recovery_url and disaster_recovery_method together: a URL with no method is fetched with the default, which is fine, but a method with no URL is nothing at all.

Treat an http disaster recovery URL as a separate finding

A disaster_recovery_url on plain http is configured but is fetched in cleartext across the public internet at the exact moment your voice path is already degraded. It is not the same problem as an empty field and it should not be reported with the same words, but it belongs in the same run.

Read the origination URIs to size the risk

GET https://trunking.twilio.com/v1/Trunks/{TrunkSid}/OriginationUrls returns sip_url, enabled, priority and weight for each URI. Count the ones where enabled is true. Zero means inbound calls have nowhere to go even on a good day; one means the disaster recovery URL is the only thing standing between a single host and a dropped call.

Check secure and transfer_mode while you are in there

secure tells you whether the trunk requires TLS and SRTP. transfer_mode tells you whether SIP REFER is enabled. Neither is this note's failure, but both are settings that were left at their defaults by the same rushed provisioning that left the disaster recovery URL empty, and reading them costs nothing extra.

Point it at TwiML that does something useful, then re-run

POST https://trunking.twilio.com/v1/Trunks/{TrunkSid} with DisasterRecoveryUrl and DisasterRecoveryMethod. Host that TwiML somewhere that does not depend on the PBX — a disaster recovery endpoint behind the thing that just failed is not a disaster recovery endpoint. Then run the audit again, and keep running it, because the next trunk will be created the same way.

How to check it worked

Re-run the script. Every trunk should report covered, and the exposed count should be zero.

python3 twilio_trunk_dr_audit.py --check-origination
# 4 trunk(s), 0 without disaster recovery

The full code

One paginated GET over the trunks, plus one GET per trunk when you ask for the origination detail. An API Key with read access is enough and is what you should give it. The classification is a pure function taking a trunk and, optionally, its origination URIs, because the interesting judgement is what an empty field means in the presence of one enabled URI versus five — and that deserves to be readable rather than buried in a request loop.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 117 Twilio fixes, free and open source.
twilio_trunk_dr_audit.py
"""Report Twilio SIP Trunks with no disaster recovery URL.

Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The repair is printed, never performed,
because this script holds a credential to an account that can place calls and
spend money.
"""
import argparse
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_trunk_dr_audit")

TRUNKING = "https://trunking.twilio.com/v1"


def scheme_of(url):
    """Lowercase URL scheme, or an empty string when there is not one.

    Kept separate because a disaster recovery URL on plain http is a different
    finding from one that is missing, and the difference is one substring.
    """
    u = str(url or "").strip()
    if "://" not in u:
        return ""
    return u.split("://", 1)[0].lower()


def enabled_uris(origination):
    """The origination URIs Twilio would actually try.

    A disabled URI is still in the listing and still has a sip_url, so counting
    the list rather than the enabled subset overstates the redundancy exactly
    when it matters.
    """
    return [u for u in (origination or []) if u.get("enabled")]


def verdict(trunk, origination=None):
    """Classify one Trunk. Pure, so the rules can be tested without a network.

    origination is the trunk's OriginationUrl list, or None when it was not
    fetched. None and an empty list mean different things: the first is "not
    checked", the second is "checked, and there is nowhere for calls to go".

    Returns (state, detail).
    """
    dr = str(trunk.get("disaster_recovery_url") or "").strip()
    if not dr:
        return ("exposed",
                "no disaster_recovery_url: when the origination URIs stop "
                "answering, inbound calls to this trunk end at Twilio with no "
                "fallback, no voicemail and nothing logged as a call failure.")

    if scheme_of(dr) == "http":
        return ("dr-cleartext",
                "disaster_recovery_url is plain http, so the one TwiML fetch "
                "that happens while your voice path is already degraded crosses "
                "the public internet in cleartext.")

    if origination is not None:
        live = enabled_uris(origination)
        if not live:
            return ("no-origination",
                    "disaster recovery is set, but no origination URI is "
                    "enabled: inbound calls have nowhere to go on a good day, "
                    "not only during an outage.")
        if len(live) == 1:
            return ("single-uri",
                    "one enabled origination URI (%s), so the disaster recovery "
                    "URL is the only cover for that single host."
                    % (live[0].get("sip_url") or "?"))

    method = str(trunk.get("disaster_recovery_method") or "").strip().upper()
    return ("covered",
            "disaster_recovery_url is set and will be fetched with %s"
            % (method or "the default, which is a POST"))


def get(session, url, **params):
    r = session.get(url, params=params, timeout=30)
    if r.status_code in (401, 403):
        raise SystemExit("%d from Twilio: check TWILIO_ACCOUNT_SID and that the "
                         "API key belongs to that account with read access"
                         % r.status_code)
    r.raise_for_status()
    return r.json()


def list_trunks(session, limit):
    """Page the trunks. This API paginates with an absolute meta.next_page_url."""
    url = TRUNKING + "/Trunks"
    params = {"PageSize": 100}
    out = []
    while url and len(out) < limit:
        page = get(session, url, **params)
        out.extend(page.get("trunks", []))
        url = (page.get("meta") or {}).get("next_page_url")
        params = {}
    return out[:limit]


def list_origination(session, trunk_sid):
    """Origination URIs for one trunk. Not paginated in practice, but read the
    meta anyway rather than assuming."""
    url = "%s/Trunks/%s/OriginationUrls" % (TRUNKING, trunk_sid)
    params = {"PageSize": 100}
    out = []
    while url:
        page = get(session, url, **params)
        out.extend(page.get("origination_urls", []))
        url = (page.get("meta") or {}).get("next_page_url")
        params = {}
    return out


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-trunks", type=int, default=200,
                    help="stop after this many trunks")
    ap.add_argument("--check-origination", action="store_true",
                    help="one extra GET per trunk to count enabled origination URIs")
    args = ap.parse_args()

    account = os.environ.get("TWILIO_ACCOUNT_SID")
    key = os.environ.get("TWILIO_API_KEY")
    secret = os.environ.get("TWILIO_API_SECRET")
    if not (account and key and secret):
        log.error("set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET "
                  "(an API Key with read access, not the auth token)")
        return 2

    session = requests.Session()
    session.auth = (key, secret)

    trunks = list_trunks(session, args.max_trunks)
    if not trunks:
        log.info("no SIP trunks on this account")
        return 0

    bad = 0
    for t in trunks:
        origination = None
        if args.check_origination:
            origination = list_origination(session, t.get("sid"))
        state, detail = verdict(t, origination)
        name = t.get("friendly_name") or t.get("domain_name") or t.get("sid")
        line = "%-14s %s  %s" % (state, name, detail)
        if state == "covered":
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        log.warning("  secure=%s transfer_mode=%s",
                    t.get("secure"), t.get("transfer_mode"))
        log.warning("  repair: POST %s/Trunks/%s "
                    "DisasterRecoveryUrl=https://your-app.example.com/dr-twiml "
                    "DisasterRecoveryMethod=POST", TRUNKING, t.get("sid"))
        log.warning("  host that TwiML somewhere that does not depend on the PBX")

    log.info("%d trunk(s), %d without disaster recovery", len(trunks), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-trunk-dr-audit.mjs
/**
 * Report Twilio SIP Trunks with no disaster recovery URL.
 *
 * Read only. GET requests and nothing else: give this an API Key with read
 * access rather than the account auth token. The repair is printed, never
 * performed.
 */
const TRUNKING = 'https://trunking.twilio.com/v1';

/**
 * Lowercase URL scheme, or an empty string when there is not one. A disaster
 * recovery URL on plain http is a different finding from a missing one.
 */
export function schemeOf(url) {
  const u = String(url ?? '').trim();
  if (!u.includes('://')) return '';
  return u.split('://')[0].toLowerCase();
}

/**
 * The origination URIs Twilio would actually try. A disabled URI is still in
 * the listing, so counting the list overstates the redundancy.
 */
export function enabledUris(origination) {
  return (origination ?? []).filter((u) => u.enabled);
}

/**
 * Classify one Trunk. Pure, so the rules can be tested without a network.
 * `origination` is the trunk's OriginationUrl list, or null when it was not
 * fetched: null means "not checked", an empty array means "checked, and there
 * is nowhere for calls to go". Returns [state, detail].
 */
export function verdict(trunk, origination = null) {
  const dr = String(trunk.disaster_recovery_url ?? '').trim();
  if (!dr) {
    return ['exposed',
      'no disaster_recovery_url: when the origination URIs stop answering, ' +
      'inbound calls to this trunk end at Twilio with no fallback, no ' +
      'voicemail and nothing logged as a call failure.'];
  }

  if (schemeOf(dr) === 'http') {
    return ['dr-cleartext',
      'disaster_recovery_url is plain http, so the one TwiML fetch that ' +
      'happens while your voice path is already degraded crosses the public ' +
      'internet in cleartext.'];
  }

  if (origination !== null) {
    const live = enabledUris(origination);
    if (live.length === 0) {
      return ['no-origination',
        'disaster recovery is set, but no origination URI is enabled: inbound ' +
        'calls have nowhere to go on a good day, not only during an outage.'];
    }
    if (live.length === 1) {
      return ['single-uri',
        `one enabled origination URI (${live[0].sip_url ?? '?'}), so the ` +
        'disaster recovery URL is the only cover for that single host.'];
    }
  }

  const method = String(trunk.disaster_recovery_method ?? '').trim().toUpperCase();
  return ['covered',
    `disaster_recovery_url is set and will be fetched with ${method || 'the default, which is a POST'}`];
}

function authHeader(key, secret) {
  return `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`;
}

async function get(auth, url, params = {}) {
  const u = new URL(url);
  for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
  const res = await fetch(u, { headers: { Authorization: auth } });
  if (res.status === 401 || res.status === 403) {
    throw new Error(`${res.status} from Twilio: check TWILIO_ACCOUNT_SID and ` +
                    'that the API key belongs to that account with read access');
  }
  if (!res.ok) throw new Error(`${res.status} from ${u.pathname}`);
  return res.json();
}

/** Page the trunks. This API paginates with an absolute meta.next_page_url. */
export async function listTrunks(auth, limit = 200) {
  let url = `${TRUNKING}/Trunks`;
  let params = { PageSize: 100 };
  const out = [];
  while (url && out.length < limit) {
    const page = await get(auth, url, params);
    out.push(...(page.trunks ?? []));
    url = page.meta?.next_page_url ?? null;
    params = {};
  }
  return out.slice(0, limit);
}

export async function listOrigination(auth, trunkSid) {
  let url = `${TRUNKING}/Trunks/${trunkSid}/OriginationUrls`;
  let params = { PageSize: 100 };
  const out = [];
  while (url) {
    const page = await get(auth, url, params);
    out.push(...(page.origination_urls ?? []));
    url = page.meta?.next_page_url ?? null;
    params = {};
  }
  return out;
}

async function main() {
  const account = process.env.TWILIO_ACCOUNT_SID;
  const key = process.env.TWILIO_API_KEY;
  const secret = process.env.TWILIO_API_SECRET;
  if (!account || !key || !secret) {
    console.error('set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET ' +
                  '(an API Key with read access, not the auth token)');
    process.exitCode = 2;
    return;
  }
  const auth = authHeader(key, secret);
  const checkOrigination = process.argv.includes('--check-origination');

  const trunks = await listTrunks(auth);
  if (trunks.length === 0) {
    console.log('no SIP trunks on this account');
    return;
  }

  let bad = 0;
  for (const t of trunks) {
    const origination = checkOrigination ? await listOrigination(auth, t.sid) : null;
    const [state, detail] = verdict(t, origination);
    const name = t.friendly_name || t.domain_name || t.sid;
    const line = `${state.padEnd(14)} ${name}  ${detail}`;
    if (state === 'covered') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    console.warn(`  secure=${t.secure} transfer_mode=${t.transfer_mode}`);
    console.warn(`  repair: POST ${TRUNKING}/Trunks/${t.sid} ` +
                 'DisasterRecoveryUrl=https://your-app.example.com/dr-twiml ' +
                 'DisasterRecoveryMethod=POST');
    console.warn('  host that TwiML somewhere that does not depend on the PBX');
  }

  console.log(`${trunks.length} trunk(s), ${bad} without disaster recovery`);
  process.exitCode = bad ? 1 : 0;
}

// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing credentials and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The cases worth pinning are the ones that separate not checked from checked and empty. A trunk audited without the origination fetch must not be reported as having no origination URIs, and a trunk with three URIs of which two are disabled must be reported as having one. Both are off-by-one mistakes that read as reassuring, which is the worst way for a check to be wrong.

test_twilio_trunk_dr_audit.py
from twilio_trunk_dr_audit import enabled_uris, scheme_of, verdict


def test_empty_disaster_recovery_url_is_exposed():
    state, detail = verdict({"disaster_recovery_url": ""})
    assert state == "exposed"
    assert "no fallback" in detail


def test_missing_field_reads_the_same_as_an_empty_one():
    assert verdict({})[0] == "exposed"
    assert verdict({"disaster_recovery_url": None})[0] == "exposed"


def test_method_without_a_url_is_still_exposed():
    # A method is not a destination. Reading the pair as configured because one
    # half is populated is the mistake this case exists to prevent.
    assert verdict({"disaster_recovery_method": "POST"})[0] == "exposed"


def test_cleartext_disaster_recovery_url_is_its_own_state():
    state, _ = verdict({"disaster_recovery_url": "http://dr.example.com/twiml"})
    assert state == "dr-cleartext"


def test_https_url_with_no_origination_check_is_covered():
    # origination=None means not checked, and must not be read as "no URIs".
    state, detail = verdict({"disaster_recovery_url": "https://dr.example.com/twiml"})
    assert state == "covered"
    assert "the default" in detail


def test_checked_and_empty_origination_is_not_the_same_as_unchecked():
    state, _ = verdict({"disaster_recovery_url": "https://dr.example.com/twiml"}, [])
    assert state == "no-origination"


def test_disabled_uris_do_not_count_towards_redundancy():
    origination = [
        {"sip_url": "sip:a.example.com", "enabled": True},
        {"sip_url": "sip:b.example.com", "enabled": False},
        {"sip_url": "sip:c.example.com", "enabled": False},
    ]
    state, detail = verdict(
        {"disaster_recovery_url": "https://dr.example.com/twiml"}, origination)
    assert state == "single-uri"
    assert "a.example.com" in detail
    assert len(enabled_uris(origination)) == 1


def test_two_live_uris_and_a_recovery_url_is_covered():
    origination = [{"sip_url": "sip:a", "enabled": True},
                   {"sip_url": "sip:b", "enabled": True}]
    assert verdict({"disaster_recovery_url": "https://dr.example.com/twiml",
                    "disaster_recovery_method": "post"}, origination)[0] == "covered"


def test_scheme_of_handles_a_bare_host():
    assert scheme_of("HTTPS://dr.example.com/x") == "https"
    assert scheme_of("dr.example.com/x") == ""
twilio-trunk-dr-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { enabledUris, schemeOf, verdict } from './twilio-trunk-dr-audit.mjs';

test('empty disaster recovery url is exposed', () => {
  const [state, detail] = verdict({ disaster_recovery_url: '' });
  assert.equal(state, 'exposed');
  assert.match(detail, /no fallback/);
});

test('missing field reads the same as an empty one', () => {
  assert.equal(verdict({})[0], 'exposed');
  assert.equal(verdict({ disaster_recovery_url: null })[0], 'exposed');
});

test('method without a url is still exposed', () => {
  assert.equal(verdict({ disaster_recovery_method: 'POST' })[0], 'exposed');
});

test('cleartext disaster recovery url is its own state', () => {
  assert.equal(verdict({ disaster_recovery_url: 'http://dr.example.com/twiml' })[0],
               'dr-cleartext');
});

test('https url with no origination check is covered', () => {
  const [state, detail] = verdict({ disaster_recovery_url: 'https://dr.example.com/twiml' });
  assert.equal(state, 'covered');
  assert.match(detail, /the default/);
});

test('checked and empty origination is not the same as unchecked', () => {
  assert.equal(
    verdict({ disaster_recovery_url: 'https://dr.example.com/twiml' }, [])[0],
    'no-origination');
});

test('disabled uris do not count towards redundancy', () => {
  const origination = [
    { sip_url: 'sip:a.example.com', enabled: true },
    { sip_url: 'sip:b.example.com', enabled: false },
    { sip_url: 'sip:c.example.com', enabled: false },
  ];
  const [state, detail] = verdict(
    { disaster_recovery_url: 'https://dr.example.com/twiml' }, origination);
  assert.equal(state, 'single-uri');
  assert.match(detail, /a\.example\.com/);
  assert.equal(enabledUris(origination).length, 1);
});

test('two live uris and a recovery url is covered', () => {
  const origination = [{ sip_url: 'sip:a', enabled: true },
                       { sip_url: 'sip:b', enabled: true }];
  assert.equal(verdict({ disaster_recovery_url: 'https://dr.example.com/twiml',
                         disaster_recovery_method: 'post' }, origination)[0], 'covered');
});

test('schemeOf handles a bare host', () => {
  assert.equal(schemeOf('HTTPS://dr.example.com/x'), 'https');
  assert.equal(schemeOf('dr.example.com/x'), '');
});

FAQ

Why does this never show up in the Debugger?

Because nothing failed while you were watching. The disaster recovery URL is only fetched when the origination URIs are unreachable, so an empty field generates no request, no alert and no error code during normal operation. It is a gap in coverage rather than an event, and event-based monitoring has nothing to report.

Is one origination URI actually a problem?

Not on its own, and that is why it is a separate state rather than a failure. It becomes the finding when it is combined with an empty disaster recovery URL, because then a single unreachable host drops every inbound call with no second path and no fallback TwiML. The script reports the combination rather than either half.

Where should the disaster recovery TwiML be hosted?

Somewhere that does not share a failure domain with the PBX. A recovery endpoint on the same rack, behind the same firewall, or on the same uplink as the thing that just failed is not recovery, it is a second copy of the outage. A TwiML Bin or a small serverless function is often the right answer precisely because it depends on nothing of yours.

Does the script check whether the recovery URL actually works?

No, and deliberately. Fetching your disaster recovery endpoint from a monitoring script tells you it answered that request, from that network, at that moment, which is weaker evidence than it looks. What the script asserts is the thing it can assert with certainty from the API: the field is populated, and with what scheme.

Can the script set the URL for the trunks it finds?

It will not. This section's scripts hold a credential to an account that can place calls and spend money, so they read and print. Writing a disaster recovery URL also means deciding what that TwiML says, which is a product decision rather than a repair a cron job should be making.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.