Skip to content

Diagnostic Twilio

a phone number still points at Twilio's demo TwiML

The number rings. It answers. It plays a cheerful message about Twilio and hangs up. Nothing appears in the Debugger, nothing appears in your logs, and every call in the console is marked completed — because the webhook Twilio fetched answered perfectly. It just was not yours.

Read-only key Python and Node.js Tests included
An envelope button
Photo by Mariia Shalabaieva on Unsplash
The short answer

Read GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json and flag any number whose voice_url or sms_url still points at demo.twilio.com, or at an unedited TwiML Bin on handler.twilio.com/twiml/, or which has no handler and no application SID at all.

Newly purchased numbers arrive with voice_url set to https://demo.twilio.com/docs/voice.xml. That URL is healthy and returns valid TwiML, which is precisely why no error-based monitoring will ever mention it.

The problem in plain words

Every other misconfiguration in this section announces itself with an error code. This one does not, and that is the entire difficulty. Twilio requested a URL, got a 200, got well-formed TwiML, and executed it exactly as instructed. From the platform's point of view the call was a success. From the Alerts API's point of view nothing happened worth logging. From your application's point of view nothing happened at all, because your application was never contacted.

So it survives. It survives the deploy, the launch checklist, the monitoring review, and it is usually discovered by a customer or a salesperson dialling the number on the website footer. By then the number has been live for weeks and nobody can say how many callers heard the demo greeting, because there is no record of a failure to count.

Number boughtdemo voice_url bydefaultCaller dialscall startsnormallyTwilio fetchesdemo.twilio.comanswers 200Demo greetingyour app nevercalledCall completednothing logged
Every step here succeeds. The webhook returns 200 with valid TwiML, so there is no failure anywhere for monitoring to notice.

Why it happens

The demo URL is the factory default, not a mistake anyone made. Buying a number through the API or the console provisions it with Twilio's demo TwiML so that the number does something rather than erroring. Wiring it to your own application is a separate step, and it is the step that gets skipped when a number is bought in a hurry to test something.

TwiML Bins fail the same way, more convincingly. A Bin created during a quickstart is a real, permanent URL on handler.twilio.com. It answers, it is not a demo URL, and it looks configured in the console. If the application was supposed to take over and never did, a Bin is the leftover that hides it.

The console shows configuration, not intent. Both fields are populated with valid HTTPS URLs. There is nothing red, nothing empty, nothing that reads as wrong — you have to already know what the URL should be to see that it isn't.

Numbers outlive the projects that bought them. An account with forty numbers accumulated over three years has no one person who knows what each is for. The audit has to be mechanical, because memory is not going to cover it.

The fix, as a flow

The script matches on host and path rather than on the whole URL, because the demo endpoint appears over http and https, with and without a query string, and pointing at several different demo documents.

GET IncomingPhoneNumbers.jsonvoice_url, sms_url, application sidsPoints at your appconfigured, leave itUnedited TwiML Bincheck it is deliberatedemo.twilio.comnever wired up, fix nowNo handler at allbought, billed, silent
A number with no handler at all belongs in the same report: same cause, same fix, and it is billed every month for answering nothing.

How to fix it

List every number and read both handler fields

GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json?PageSize=1000, following next_page_uri to the end. Read voice_url, sms_url, voice_application_sid and sms_application_sid on each. A number can be misrouted on one channel and correct on the other.

Match on host and path, not on the whole string

The demo URL appears as http:// and https://, with and without a trailing query string, and sometimes with the .xml swapped for a different demo document. Comparing full strings misses all of those; comparing the host catches every variant.

Treat an empty number as the same finding

A number with no voice_url, no sms_url and no application SID is bought, billed monthly and answers nothing. It belongs in the same report as the demo ones because it has the same cause: provisioned and never wired up.

Check whether anyone is actually dialling it

GET /2010-04-01/Accounts/{AccountSid}/Calls.json?To={E164}&PageSize=1 answers the only question that sets priority. A demo-TwiML number with traffic is an incident; one with none is a tidying job, and possibly a number to release.

Point it at your application, then re-run

POST /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers/{PNSid}.json with VoiceUrl and VoiceMethod. Run the audit again afterwards; it is one paginated GET and it is worth having on a schedule, because the next number someone buys will arrive on the demo URL too.

How to check it worked

Re-run the script. Every number should report configured, and the demo count should be zero.

python3 twilio_demo_twiml_audit.py
# 12 number(s), 0 on demo or placeholder TwiML

The full code

The script does one paginated GET over the numbers and, with --check-traffic, one extra GET per flagged number — an API Key with read access is enough, and is what you should give it. The classification is a pure function, because the interesting part is the URL matching rules, and those deserve to be visible and testable 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 12 Twilio fixes, free and open source.
twilio_demo_twiml_audit.py
"""Report Twilio phone numbers still answering with demo or placeholder TwiML.

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_demo_twiml_audit")

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

DEMO_HOST = "demo.twilio.com"
BIN_PREFIX = "handler.twilio.com/twiml/"


def host_and_path(url):
    """Reduce a URL to lowercase host plus path.

    The demo endpoint turns up as http and https, with and without a query
    string, and pointing at several different demo documents. Matching the whole
    string misses most of those; matching host and path catches all of them.
    """
    u = str(url or "").strip()
    for scheme in ("https://", "http://"):
        if u.lower().startswith(scheme):
            u = u[len(scheme):]
            break
    u = u.split("?", 1)[0].split("#", 1)[0]
    head = u.split("/", 1)[0]
    if "@" in head:
        u = u.split("@", 1)[1]
    return u.lower()


def verdict(number):
    """Classify one IncomingPhoneNumber. Pure, so the rules can be tested
    without a network.

    Returns (state, detail).
    """
    handlers = [("voice", number.get("voice_url")), ("sms", number.get("sms_url"))]

    demo = [c for c, u in handlers if host_and_path(u).startswith(DEMO_HOST)]
    if demo:
        return ("demo",
                "%s handler is Twilio's demo TwiML. It answers 200 with valid "
                "TwiML, so nothing is logged and every call reads as completed."
                % "/".join(demo))

    bins = [c for c, u in handlers if host_and_path(u).startswith(BIN_PREFIX)]
    if bins:
        return ("twiml-bin",
                "%s handler is a TwiML Bin. Bins are legitimate, but one left "
                "over from a quickstart fails exactly like the demo URL."
                % "/".join(bins))

    routed = [c for c, u in handlers if str(u or "").strip()]
    if str(number.get("voice_application_sid") or "").strip():
        routed.append("voice app")
    if str(number.get("sms_application_sid") or "").strip():
        routed.append("sms app")
    if not routed:
        return ("unrouted",
                "no voice_url, no sms_url and no application sid: the number is "
                "bought, billed monthly and answers nothing.")

    return ("configured", "handled by " + ", ".join(routed))


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):
    """Page IncomingPhoneNumbers. next_page_uri is a path, not an absolute URL."""
    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 has_traffic(session, account, e164):
    """One call record is enough to know the number is in use."""
    page = get(session, "%s/Accounts/%s/Calls.json" % (BASE, account),
               To=e164, PageSize=1)
    return bool(page.get("calls"))


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-numbers", type=int, default=1000,
                    help="stop after this many numbers")
    ap.add_argument("--check-traffic", action="store_true",
                    help="one extra GET per flagged number to see if it is dialled")
    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

    bad = 0
    for n in numbers:
        state, detail = verdict(n)
        line = "%-11s %s  %s" % (state, n.get("phone_number", "?"), detail)
        if state == "configured":
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        if args.check_traffic and has_traffic(session, account, n.get("phone_number")):
            log.warning("  this number has inbound calls: fix it before the rest")
        log.warning("  repair: POST %s/Accounts/%s/IncomingPhoneNumbers/%s.json "
                    "VoiceUrl=https://your-app.example.com/voice VoiceMethod=POST",
                    BASE, account, n.get("sid"))

    log.info("%d number(s), %d on demo or placeholder TwiML", len(numbers), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-demo-twiml-audit.mjs
/**
 * Report Twilio phone numbers still answering with demo or placeholder TwiML.
 *
 * 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`;

const DEMO_HOST = 'demo.twilio.com';
const BIN_PREFIX = 'handler.twilio.com/twiml/';

/**
 * Reduce a URL to lowercase host plus path, so http/https, query strings and
 * different demo documents all match the same rule.
 */
export function hostAndPath(url) {
  let u = String(url ?? '').trim();
  for (const scheme of ['https://', 'http://']) {
    if (u.toLowerCase().startsWith(scheme)) { u = u.slice(scheme.length); break; }
  }
  u = u.split('?')[0].split('#')[0];
  if (u.split('/')[0].includes('@')) u = u.slice(u.indexOf('@') + 1);
  return u.toLowerCase();
}

/**
 * Classify one IncomingPhoneNumber. Pure, so the rules can be tested without a
 * network. Returns [state, detail].
 */
export function verdict(number) {
  const handlers = [['voice', number.voice_url], ['sms', number.sms_url]];

  const demo = handlers.filter(([, u]) => hostAndPath(u).startsWith(DEMO_HOST));
  if (demo.length) {
    return ['demo',
      `${demo.map(([c]) => c).join('/')} handler is Twilio's demo TwiML. It ` +
      'answers 200 with valid TwiML, so nothing is logged and every call reads ' +
      'as completed.'];
  }

  const bins = handlers.filter(([, u]) => hostAndPath(u).startsWith(BIN_PREFIX));
  if (bins.length) {
    return ['twiml-bin',
      `${bins.map(([c]) => c).join('/')} handler is a TwiML Bin. Bins are ` +
      'legitimate, but one left over from a quickstart fails exactly like the ' +
      'demo URL.'];
  }

  const routed = handlers.filter(([, u]) => String(u ?? '').trim()).map(([c]) => c);
  if (String(number.voice_application_sid ?? '').trim()) routed.push('voice app');
  if (String(number.sms_application_sid ?? '').trim()) routed.push('sms app');
  if (routed.length === 0) {
    return ['unrouted',
      'no voice_url, no sms_url and no application sid: the number is bought, ' +
      'billed monthly and answers nothing.'];
  }

  return ['configured', `handled by ${routed.join(', ')}`];
}

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 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 checkTraffic = process.argv.includes('--check-traffic');

  const numbers = await listNumbers(auth, account);
  if (numbers.length === 0) {
    console.log('no phone numbers on this account');
    return;
  }

  let bad = 0;
  for (const n of numbers) {
    const [state, detail] = verdict(n);
    const line = `${state.padEnd(11)} ${n.phone_number ?? '?'}  ${detail}`;
    if (state === 'configured') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    if (checkTraffic) {
      const calls = await get(auth, `${BASE}/Accounts/${account}/Calls.json`,
                              { To: n.phone_number, PageSize: 1 });
      if ((calls.calls ?? []).length) {
        console.warn('  this number has inbound calls: fix it before the rest');
      }
    }
    console.warn(`  repair: POST ${BASE}/Accounts/${account}/IncomingPhoneNumbers/` +
                 `${n.sid}.json VoiceUrl=https://your-app.example.com/voice ` +
                 'VoiceMethod=POST');
  }

  console.log(`${numbers.length} number(s), ${bad} on demo or placeholder TwiML`);
  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 a string comparison gets wrong: the demo URL over plain http, the demo URL with a query string, and a number whose voice handler is fine while its SMS handler is not. The last one matters because a per-number verdict that only ever looks at voice_url reports an SMS black hole as healthy.

test_twilio_demo_twiml_audit.py
from twilio_demo_twiml_audit import host_and_path, verdict


def test_default_demo_voice_url_is_flagged():
    state, detail = verdict({"voice_url": "https://demo.twilio.com/docs/voice.xml"})
    assert state == "demo"
    assert "completed" in detail


def test_demo_url_over_http_and_with_a_query_string_is_still_demo():
    # The reason matching is on host and path rather than the whole string.
    state, _ = verdict({"voice_url": "http://demo.twilio.com/docs/voice.xml?x=1"})
    assert state == "demo"


def test_demo_on_the_sms_handler_is_found_when_voice_is_fine():
    state, detail = verdict({"voice_url": "https://app.example.com/voice",
                             "sms_url": "https://demo.twilio.com/welcome/sms/reply"})
    assert state == "demo"
    assert "sms" in detail


def test_unedited_twiml_bin_is_its_own_state():
    state, _ = verdict({"voice_url": "https://handler.twilio.com/twiml/EH0123456789"})
    assert state == "twiml-bin"


def test_number_with_no_handler_at_all_is_unrouted():
    state, detail = verdict({"voice_url": "", "sms_url": None})
    assert state == "unrouted"
    assert "billed" in detail


def test_application_sid_counts_as_routed():
    state, _ = verdict({"voice_application_sid": "AP0123456789"})
    assert state == "configured"


def test_host_and_path_drops_scheme_credentials_and_query():
    assert host_and_path("https://user@Demo.Twilio.com/docs/voice.xml?a=b") == \
        "demo.twilio.com/docs/voice.xml"
twilio-demo-twiml-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hostAndPath, verdict } from './twilio-demo-twiml-audit.mjs';

test('default demo voice url is flagged', () => {
  const [state, detail] = verdict({ voice_url: 'https://demo.twilio.com/docs/voice.xml' });
  assert.equal(state, 'demo');
  assert.match(detail, /completed/);
});

test('demo url over http and with a query string is still demo', () => {
  assert.equal(verdict({ voice_url: 'http://demo.twilio.com/docs/voice.xml?x=1' })[0], 'demo');
});

test('demo on the sms handler is found when voice is fine', () => {
  const [state, detail] = verdict({
    voice_url: 'https://app.example.com/voice',
    sms_url: 'https://demo.twilio.com/welcome/sms/reply',
  });
  assert.equal(state, 'demo');
  assert.match(detail, /sms/);
});

test('unedited twiml bin is its own state', () => {
  assert.equal(
    verdict({ voice_url: 'https://handler.twilio.com/twiml/EH0123456789' })[0],
    'twiml-bin');
});

test('number with no handler at all is unrouted', () => {
  const [state, detail] = verdict({ voice_url: '', sms_url: null });
  assert.equal(state, 'unrouted');
  assert.match(detail, /billed/);
});

test('application sid counts as routed', () => {
  assert.equal(verdict({ voice_application_sid: 'AP0123456789' })[0], 'configured');
});

test('hostAndPath drops scheme, credentials and query', () => {
  assert.equal(hostAndPath('https://user@Demo.Twilio.com/docs/voice.xml?a=b'),
               'demo.twilio.com/docs/voice.xml');
});

FAQ

Why is there no error code for this?

Because nothing failed. Twilio fetched a URL, received 200 with well-formed TwiML, and executed it. The demo endpoint is a healthy web server, so there is no 11200, no Debugger alert and no failed call to count. Error-based monitoring cannot see this class of problem at all.

Where does the demo URL come from if nobody set it?

Twilio provisions newly purchased numbers with voice_url pointing at https://demo.twilio.com/docs/voice.xml so the number does something rather than erroring. Pointing it at your own application is a separate step, and it is the one that gets skipped.

Is a TwiML Bin a real problem, or a false positive?

It depends on intent, which is why it gets its own state rather than being folded into the demo one. A Bin serving a deliberate static greeting is fine. A Bin created during a quickstart, on a number your application was supposed to answer, is the same failure wearing a different URL.

Should a number with no webhook at all be in this report?

Yes. Same cause, same fix, and it is billed every month for answering nothing. Keeping it in the same run is how you find out that three of the forty numbers on the account have never been wired to anything.

Can the script fix the numbers it finds?

It will not. Rewriting a live number's voice_url from a cron job is how a working phone line goes down at 3am. It prints the exact POST, with the resource SID and the field, for a human to run and watch.

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.