Skip to content

Diagnostic Twilio

a number with an Application SID ignores its own voice_url

You changed voice_url on the number this morning. You changed it again in the console an hour later and watched the page save. Calls keep arriving at an endpoint you retired last spring. Nothing is broken and nothing is cached — voice_application_sid is set on that number, and while it is, the field you keep editing is not read at all.

Read-only key Python and Node.js Tests included
Camera studio set up
Photo by Alexander Dummer on Unsplash
The short answer

Read GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json?PageSize=1000 and flag any number where voice_application_sid is non-empty and voice_url is also non-empty and different. Resolve what actually answers with GET /2010-04-01/Accounts/{AccountSid}/Applications/{AppSid}.jsonvoice_url, voice_fallback_url and status_callback all live there once an app is attached.

Flag a second shape while you are in there: an Application whose voice_url is empty. A number pointed at it routes calls nowhere, and the number's own URL cannot rescue it. The same precedence applies to sms_application_sid over sms_url.

The problem in plain words

This is a configuration bug that presents as a caching bug. The edit succeeds, the API returns the new value, the console shows it, and behaviour does not change. So the next hypothesis is propagation delay, then a stale deploy, then DNS, and an afternoon disappears into a system that is doing exactly what it was told by a field nobody looked at.

Applications get attached by accident more often than by design. The Voice quickstarts create a TwiML App, client SDK setups require one, and a number bought during a spike gets pointed at whatever app was in the dropdown. Two years later the app is the effective handler for eleven numbers, its URL points at a host that no longer exists, and every number still carries a tidy-looking voice_url that has not served a call since it was set.

voice_urleditedthe write returns200App SID stillsetit wins outrightNumber urlignorednever requestedOld app answersretired endpointBlamed oncachingthen on DNS
Nothing fails. The write succeeds, the API returns the new value, and calls keep arriving at a host that was retired last spring.

Why it happens

Precedence is silent and absolute. When voice_application_sid is populated, Twilio requests the Application's URLs and ignores the number's entirely — not as a fallback, not as a merge. There is no warning on the write that sets a URL which will never be read, because the API has no opinion about which field you meant.

The ignored field stays visible everywhere. The API returns it, the console renders it in an editable box, and infrastructure code keeps setting it. Every surface a developer checks says the number points at the new endpoint, so the conclusion is that Twilio is wrong rather than that the value is inert.

One app fronts many numbers. That is the point of an Application, and it is why the repair has two shapes with very different blast radii. Editing the app moves every number attached to it; detaching the app moves one. Choosing without listing the other numbers on that SID is how a fix for one number takes out ten.

An empty app URL is a dead end, not an error. An Application with no voice_url gives Twilio nowhere to go for a call the number was configured to receive, and the number's own URL is still ignored. Nothing in the numbers list reveals it; the finding only exists once the Application resource has been read.

The fix, as a flow

The script resolves the Application before it judges the number, because the field everyone reads is the one field Twilio does not read. Matching URLs are deliberately not a finding: only a gap between them is.

Numbers joined to Applicationson voice and sms app sidNo app sidthe number's own url is readUrls agreeapp routed, nothing surprisingUrls differthe number's url is inertApp has no urlcalls route nowhere
An app with no url is a live outage; a shadowed url is a stale endpoint. One report, two very different mornings.

How to fix it

List every number and read both channels

GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json?PageSize=1000, following next_page_uri. Voice and SMS carry independent pairs of fields, and a number is routinely clean on one and shadowed on the other.

Fetch each referenced Application exactly once

Collect the distinct values of voice_application_sid and sms_application_sid, then GET /2010-04-01/Accounts/{AccountSid}/Applications/{AppSid}.json per SID and cache the result. A busy account points dozens of numbers at a handful of apps, so this is a few requests rather than one per number.

Compare the two URLs instead of checking that one is set

A number whose voice_url matches the app's voice_url is harmless noise: both point at the same place and no traffic goes anywhere surprising. The finding is a number whose own URL differs from the one that actually answers, because that gap is exactly the wrong mental model somebody is currently debugging.

Flag applications with no URL at all

An empty voice_url on the Application routes calls nowhere while the number looks fully configured. Read voice_fallback_url and status_callback from the same resource while you have it: those moved to the app too, and an audit that reads them off the number reports the wrong answer.

Pick the repair by blast radius, then re-run

Two options. Update the app — POST /2010-04-01/Accounts/{AccountSid}/Applications/{AppSid}.json with VoiceUrl — which moves every number attached to it. Or detach it, POST /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers/{PNSid}.json with an empty VoiceApplicationSid, so the number's own voice_url starts being read. List the other numbers on that SID before choosing.

How to check it worked

Re-run the script. Every number should report direct or app-routed, and no number should be carrying a URL that nothing reads.

python3 twilio_number_app_precedence_audit.py
# 14 number(s), 0 with a shadowed handler

The full code

One paginated GET over the numbers, one GET per distinct Application SID, cached — an API Key with read access covers all of it. The precedence rule is the entire pure function, because the whole failure is a mental model, and a mental model belongs somewhere you can read it next to its tests.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 40 Twilio fixes, free and open source.
twilio_number_app_precedence_audit.py
"""Report Twilio numbers whose webhook URLs are shadowed by an Application SID.

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 send messages 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_number_app_precedence_audit")

HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"

# (channel, url field, application sid field). The Application resource happens
# to name its URLs identically, which is what lets one comparison serve both.
CHANNELS = (
    ("voice", "voice_url", "voice_application_sid"),
    ("sms", "sms_url", "sms_application_sid"),
)


def verdict(number, apps=None):
    """Classify one IncomingPhoneNumber against the apps it references.

    Pure, so the precedence rule is testable without a network. `apps` maps an
    Application SID to that Application resource. When a channel carries an
    application sid, the Application is the effective handler and the number's
    own url is never requested.

    Returns (state, detail).
    """
    apps = apps or {}
    unresolved, dead, shadowed, routed, direct = [], [], [], [], []

    for channel, url_field, app_field in CHANNELS:
        app_sid = str(number.get(app_field) or "").strip()
        own = str(number.get(url_field) or "").strip()

        if not app_sid:
            if own:
                direct.append("%s serves %s" % (channel, own))
            continue

        app = apps.get(app_sid)
        if app is None:
            unresolved.append("%s (%s)" % (channel, app_sid))
            continue

        live = str(app.get(url_field) or "").strip()
        if not live:
            dead.append("%s: app %s has no %s" % (channel, app_sid, url_field))
            continue
        if own and own != live:
            shadowed.append("%s: %s on the number is ignored, app %s serves %s"
                            % (channel, own, app_sid, live))
            continue
        routed.append("%s via app %s" % (channel, app_sid))

    if unresolved:
        return ("unresolved",
                "an application sid is set but that application was not read: %s"
                % ", ".join(unresolved))
    if dead:
        return ("routes-nowhere",
                "%s. The number's own url cannot rescue this: the app wins while "
                "it is attached." % "; ".join(dead))
    if shadowed:
        return ("shadowed",
                "%s. Editing the number changes nothing." % "; ".join(shadowed))
    if routed:
        return ("app-routed", "handled by its application: " + ", ".join(routed))
    if direct:
        return ("direct", "no application sid, so the number's own url is read: "
                + ", ".join(direct))
    return ("idle", "no voice or sms handler and no application sid")


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_numbers(session, account, limit):
    url = "%s/Accounts/%s/IncomingPhoneNumbers.json" % (BASE, account)
    params = {"PageSize": 100}
    out = []
    while url and len(out) < limit:
        page = get(session, url, **params)
        out.extend(page.get("incoming_phone_numbers", []))
        nxt = page.get("next_page_uri")
        url, params = (HOST + nxt) if nxt else None, {}
    return out[:limit]


def load_apps(session, account, numbers):
    """Fetch each referenced Application once and cache it by SID."""
    sids = set()
    for n in numbers:
        for _channel, _url_field, app_field in CHANNELS:
            sid = str(n.get(app_field) or "").strip()
            if sid:
                sids.add(sid)
    return {sid: get(session, "%s/Accounts/%s/Applications/%s.json"
                     % (BASE, account, sid))
            for sid in sorted(sids)}


def sharing(numbers, app_sid):
    """Every number attached to one app. Pure, and the reason it exists is that
    editing an app moves all of them at once."""
    out = []
    for n in numbers:
        for _channel, _url_field, app_field in CHANNELS:
            if str(n.get(app_field) or "").strip() == app_sid:
                out.append(n.get("phone_number") or n.get("sid"))
                break
    return out


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-numbers", type=int, default=1000,
                    help="stop paging after this many numbers")
    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)

    numbers = list_numbers(session, account, args.max_numbers)
    if not numbers:
        log.info("no phone numbers on this account")
        return 0
    apps = load_apps(session, account, numbers)

    bad = 0
    for n in numbers:
        state, detail = verdict(n, apps)
        line = "%-14s %s  %s" % (state, n.get("phone_number", "?"), detail)
        if state in ("direct", "app-routed", "idle"):
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        for _channel, _url_field, app_field in CHANNELS:
            sid = str(n.get(app_field) or "").strip()
            if not sid:
                continue
            peers = sharing(numbers, sid)
            log.warning("  app %s also fronts %d number(s): %s",
                        sid, len(peers), ", ".join(str(p) for p in peers[:5]))
        log.warning("  repair: either update the app, POST %s/Accounts/%s/"
                    "Applications/{AppSid}.json VoiceUrl=https://.../voice, which "
                    "moves every number above; or detach it, POST %s/Accounts/%s/"
                    "IncomingPhoneNumbers/%s.json VoiceApplicationSid= (empty), "
                    "so the number's own voice_url is read again.",
                    BASE, account, BASE, account, n.get("sid"))

    log.info("%d number(s), %d with a shadowed handler", len(numbers), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-number-app-precedence-audit.mjs
/**
 * Report Twilio numbers whose webhook URLs are shadowed by an Application SID.
 *
 * 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 HOST = 'https://api.twilio.com';
const BASE = `${HOST}/2010-04-01`;

// [channel, url field, application sid field]. The Application resource names
// its URLs identically, which is what lets one comparison serve both.
const CHANNELS = [
  ['voice', 'voice_url', 'voice_application_sid'],
  ['sms', 'sms_url', 'sms_application_sid'],
];

/**
 * Classify one IncomingPhoneNumber against the apps it references. Pure, so the
 * precedence rule is testable without a network. `apps` maps an Application SID
 * to that Application: when a channel carries one, the Application is the
 * effective handler and the number's own url is never requested.
 * Returns [state, detail].
 */
export function verdict(number, apps = {}) {
  const unresolved = [];
  const dead = [];
  const shadowed = [];
  const routed = [];
  const direct = [];

  for (const [channel, urlField, appField] of CHANNELS) {
    const appSid = String(number[appField] ?? '').trim();
    const own = String(number[urlField] ?? '').trim();

    if (!appSid) {
      if (own) direct.push(`${channel} serves ${own}`);
      continue;
    }

    const app = apps[appSid];
    if (app === undefined) { unresolved.push(`${channel} (${appSid})`); continue; }

    const live = String(app[urlField] ?? '').trim();
    if (!live) { dead.push(`${channel}: app ${appSid} has no ${urlField}`); continue; }
    if (own && own !== live) {
      shadowed.push(`${channel}: ${own} on the number is ignored, app ${appSid} serves ${live}`);
      continue;
    }
    routed.push(`${channel} via app ${appSid}`);
  }

  if (unresolved.length) {
    return ['unresolved',
      `an application sid is set but that application was not read: ${unresolved.join(', ')}`];
  }
  if (dead.length) {
    return ['routes-nowhere',
      `${dead.join('; ')}. The number's own url cannot rescue this: the app wins ` +
      'while it is attached.'];
  }
  if (shadowed.length) {
    return ['shadowed', `${shadowed.join('; ')}. Editing the number changes nothing.`];
  }
  if (routed.length) return ['app-routed', `handled by its application: ${routed.join(', ')}`];
  if (direct.length) {
    return ['direct', `no application sid, so the number's own url is read: ${direct.join(', ')}`];
  }
  return ['idle', 'no voice or sms handler and no application sid'];
}

/** Every number attached to one app. Pure: editing an app moves all of them. */
export function sharing(numbers, appSid) {
  const out = [];
  for (const n of numbers) {
    for (const [, , appField] of CHANNELS) {
      if (String(n[appField] ?? '').trim() === appSid) {
        out.push(n.phone_number ?? n.sid);
        break;
      }
    }
  }
  return out;
}

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();
}

export async function listNumbers(auth, account, limit = 1000) {
  let url = `${BASE}/Accounts/${account}/IncomingPhoneNumbers.json`;
  let params = { PageSize: 100 };
  const out = [];
  while (url && out.length < limit) {
    const page = await get(auth, url, params);
    out.push(...(page.incoming_phone_numbers ?? []));
    url = page.next_page_uri ? HOST + page.next_page_uri : null;
    params = {};
  }
  return out.slice(0, limit);
}

async function loadApps(auth, account, numbers) {
  const sids = new Set();
  for (const n of numbers) {
    for (const [, , appField] of CHANNELS) {
      const sid = String(n[appField] ?? '').trim();
      if (sid) sids.add(sid);
    }
  }
  const apps = {};
  for (const sid of [...sids].sort()) {
    apps[sid] = await get(auth, `${BASE}/Accounts/${account}/Applications/${sid}.json`);
  }
  return apps;
}

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 numbers = await listNumbers(auth, account);
  if (numbers.length === 0) {
    console.log('no phone numbers on this account');
    return;
  }
  const apps = await loadApps(auth, account, numbers);

  let bad = 0;
  for (const n of numbers) {
    const [state, detail] = verdict(n, apps);
    const line = `${state.padEnd(14)} ${n.phone_number ?? '?'}  ${detail}`;
    if (state === 'direct' || state === 'app-routed' || state === 'idle') {
      console.log(line);
      continue;
    }
    bad += 1;
    console.warn(line);
    for (const [, , appField] of CHANNELS) {
      const sid = String(n[appField] ?? '').trim();
      if (!sid) continue;
      const peers = sharing(numbers, sid);
      console.warn(`  app ${sid} also fronts ${peers.length} number(s): ` +
                   `${peers.slice(0, 5).join(', ')}`);
    }
    console.warn(`  repair: either update the app, POST ${BASE}/Accounts/${account}` +
                 '/Applications/{AppSid}.json VoiceUrl=https://.../voice, which moves ' +
                 `every number above; or detach it, POST ${BASE}/Accounts/${account}` +
                 `/IncomingPhoneNumbers/${n.sid}.json VoiceApplicationSid= (empty), ` +
                 "so the number's own voice_url is read again.");
  }

  console.log(`${numbers.length} number(s), ${bad} with a shadowed handler`);
  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

Three rules carry the note. A number whose own URL matches the app's is not a finding, because nothing surprising happens there and a report full of those gets ignored. A number whose URL differs is the finding, even though both fields look set and healthy. And an Application with no URL is worse than a shadowed one, so it gets its own state rather than being folded into the same bucket.

test_twilio_number_app_precedence_audit.py
from twilio_number_app_precedence_audit import sharing, verdict

APP = "AP11111111111111111111111111111111"
OTHER = "AP22222222222222222222222222222222"


def test_a_different_url_on_the_number_is_shadowed():
    # The whole note: this number looks configured and the field is inert.
    state, detail = verdict(
        {"voice_application_sid": APP, "voice_url": "https://new.example.com/voice"},
        {APP: {"voice_url": "https://retired.example.com/voice"}})
    assert state == "shadowed"
    assert "retired.example.com" in detail
    assert "Editing the number changes nothing" in detail


def test_the_same_url_on_both_is_not_a_finding():
    state, _ = verdict(
        {"voice_application_sid": APP, "voice_url": "https://app.example.com/voice"},
        {APP: {"voice_url": "https://app.example.com/voice"}})
    assert state == "app-routed"


def test_an_application_with_no_url_routes_nowhere():
    state, detail = verdict(
        {"voice_application_sid": APP, "voice_url": "https://app.example.com/voice"},
        {APP: {"voice_url": ""}})
    assert state == "routes-nowhere"
    assert "has no voice_url" in detail


def test_sms_precedence_is_checked_independently():
    state, detail = verdict(
        {"voice_url": "https://app.example.com/voice",
         "sms_application_sid": APP, "sms_url": "https://new.example.com/sms"},
        {APP: {"sms_url": "https://retired.example.com/sms"}})
    assert state == "shadowed"
    assert "sms:" in detail


def test_no_application_sid_means_the_number_is_read():
    state, detail = verdict({"voice_url": "https://app.example.com/voice"})
    assert state == "direct"
    assert "app.example.com" in detail


def test_an_unread_application_is_never_guessed_at():
    state, _ = verdict({"voice_application_sid": APP}, {})
    assert state == "unresolved"


def test_a_number_with_nothing_configured_is_idle():
    assert verdict({"voice_url": "", "sms_url": None})[0] == "idle"


def test_sharing_lists_every_number_on_one_app_once():
    numbers = [
        {"phone_number": "+15550001111", "voice_application_sid": APP,
         "sms_application_sid": APP},
        {"phone_number": "+15550002222", "sms_application_sid": APP},
        {"phone_number": "+15550003333", "voice_application_sid": OTHER},
    ]
    assert sharing(numbers, APP) == ["+15550001111", "+15550002222"]
    assert sharing(numbers, OTHER) == ["+15550003333"]
twilio-number-app-precedence-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sharing, verdict } from './twilio-number-app-precedence-audit.mjs';

const APP = 'AP11111111111111111111111111111111';
const OTHER = 'AP22222222222222222222222222222222';

test('a different url on the number is shadowed', () => {
  const [state, detail] = verdict(
    { voice_application_sid: APP, voice_url: 'https://new.example.com/voice' },
    { [APP]: { voice_url: 'https://retired.example.com/voice' } });
  assert.equal(state, 'shadowed');
  assert.match(detail, /retired\.example\.com/);
  assert.match(detail, /Editing the number changes nothing/);
});

test('the same url on both is not a finding', () => {
  const [state] = verdict(
    { voice_application_sid: APP, voice_url: 'https://app.example.com/voice' },
    { [APP]: { voice_url: 'https://app.example.com/voice' } });
  assert.equal(state, 'app-routed');
});

test('an application with no url routes nowhere', () => {
  const [state, detail] = verdict(
    { voice_application_sid: APP, voice_url: 'https://app.example.com/voice' },
    { [APP]: { voice_url: '' } });
  assert.equal(state, 'routes-nowhere');
  assert.match(detail, /has no voice_url/);
});

test('sms precedence is checked independently', () => {
  const [state, detail] = verdict(
    { voice_url: 'https://app.example.com/voice',
      sms_application_sid: APP, sms_url: 'https://new.example.com/sms' },
    { [APP]: { sms_url: 'https://retired.example.com/sms' } });
  assert.equal(state, 'shadowed');
  assert.match(detail, /sms:/);
});

test('no application sid means the number is read', () => {
  const [state, detail] = verdict({ voice_url: 'https://app.example.com/voice' });
  assert.equal(state, 'direct');
  assert.match(detail, /app\.example\.com/);
});

test('an unread application is never guessed at', () => {
  assert.equal(verdict({ voice_application_sid: APP }, {})[0], 'unresolved');
});

test('a number with nothing configured is idle', () => {
  assert.equal(verdict({ voice_url: '', sms_url: null })[0], 'idle');
});

test('sharing lists every number on one app once', () => {
  const numbers = [
    { phone_number: '+15550001111', voice_application_sid: APP, sms_application_sid: APP },
    { phone_number: '+15550002222', sms_application_sid: APP },
    { phone_number: '+15550003333', voice_application_sid: OTHER },
  ];
  assert.deepEqual(sharing(numbers, APP), ['+15550001111', '+15550002222']);
  assert.deepEqual(sharing(numbers, OTHER), ['+15550003333']);
});

FAQ

Which one wins, voice_url or voice_application_sid?

The Application SID, outright. While voice_application_sid is populated Twilio requests the Application's voice_url, voice_fallback_url and status_callback, and the number's own copies of those fields are never read. The same holds for sms_application_sid over sms_url.

Then why does the console still let me edit voice_url?

Because the field is still a real, writable property of the number; it just is not consulted while an app is attached. That is the trap: the write succeeds, the read shows your value, and behaviour is governed by a different resource entirely.

Should I fix the Application or detach it?

Depends how many numbers share the app. Updating the app moves all of them at once, which is right when the app is the intended routing layer and wrong when one number needs to diverge. Detaching restores that single number to its own voice_url and leaves the others alone, which is why the script prints the peer list first.

What happens if the Application has no voice_url?

Calls to any number attached to it have nowhere to go, and the number's own URL does not step in. This is the state worth fixing first, because it is a live outage rather than a stale endpoint, and it is invisible until you fetch the Application resource itself.

Does the script change anything?

No. It issues GETs against IncomingPhoneNumbers and Applications and prints both repair options with the SIDs filled in. Everything in this section runs on an API Key with read access, so it cannot rewrite a number even if the key leaked.

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.