Skip to content

Diagnostic Twilio

US and Canadian numbers with no registered E911 address

Nothing about this number looks wrong. It answers, it dials out, the webhooks are healthy, and it has been in production for a year. Then somebody on the sales floor dials 911 from a softphone. The call connects to a national emergency call centre with no idea where the caller is, an operator asks for an address the caller may not be able to give, and a $75 pass-through charge turns up on a later invoice.

Read-only key Python and Node.js Tests included
Grayscale photo of people standing on stage
Photo by NATHAN MULLET on Unsplash
The short answer

Page GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json and, for every +1 number whose capabilities.voice is true, read both emergency_address_sid and emergency_address_status. A null SID is unregistered. A status of registration-failure or pending-registration is also unregistered, in the ways that matter on the day.

Read both fields, not one. A number can carry an emergency_address_sid whose registration was rejected by the address validation that happens afterwards, and it will look configured in the console for as long as nobody opens it.

The problem in plain words

Every other failure in this section shows up as a status, a code, or a message that did not arrive. This one produces nothing at all until the worst possible moment. emergency_address_sid is optional at purchase, so a number bought through the API arrives without one, works perfectly for voice, and stays that way. There is no alert, no degraded metric, no failed request. The number is fine in every sense except the one that matters once a year.

When the call does happen, the outcome is not a busy tone; it is worse. The call is routed to a national emergency call centre rather than the local PSAP, staffed by people who cannot see a location and have to ask for one. That is the cost. The pass-through fee on the invoice is the part that gets noticed afterwards, and it is the smaller half.

Number boughtaddress isoptionalVoice inproductiona year of cleancallsSomebody dials911softphone, salesfloorNo address tosendrouted to anational centreOperator askswhereand a fee ispassed on
There is no failure anywhere until the call is placed. The number works perfectly for every other kind of traffic.

Why it happens

It is optional at purchase and nothing prompts you. Buying a number through the API takes an area code and a friendly name. The emergency address is a separate call against a separate resource, made afterwards, and nothing in the purchase flow fails without it.

Registration is asynchronous, so submitting is not finishing. The address is validated against the MSAG after you attach it. It can be rejected days later for a suite number, an abbreviation or a rural address the database does not carry, and the number is left carrying an address SID with a registration-failure status. Auditing the SID alone reports that number as protected.

The console shows an address, which is what people check. Somebody opening the number in the console sees a street address in the emergency address field and moves on. The status is the field that says whether a dispatcher will ever receive it.

Softphones move and the address does not. The registered address is where the number is registered, not where the person is sitting. A remote team dialling out on a shared caller ID is exactly the case E911 handles badly, and it is worth knowing which numbers are exposed before deciding what to do about it.

The obligation is US and Canadian. Numbers outside +1, and SMS-only numbers with no voice capability, cannot dial 911 through this path at all. Reporting them alongside the real findings is how a list of forty exposed numbers gets ignored, so they belong in a separate state.

The fix, as a flow

The script judges on emergency_address_status rather than on emergency_address_sid, because the SID records a submission and the status records whether the validation accepted it.

Numbers in scope for E911plus one status fieldRegistered and activea dispatcher gets itNot plus one, or no voiceout of scope, not a findingNo address at allnational centre, per call feeRejected or pendinglooks done, is not
The rejected registration is the one that survives every check: the console shows a street address on a number that has none that works.

How to fix it

List every number with its capabilities

GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json?PageSize=1000, following next_page_uri. The response carries phone_number, capabilities, emergency_address_sid, emergency_address_status and emergency_status on every row, so this is one paginated read and no joins.

Narrow to the numbers that can actually dial 911

A +1 prefix and capabilities.voice true. Everything else is out of scope rather than compliant, and the difference matters: a report that mixes forty European numbers into the exposed list will not be read twice.

Judge on the status, not on the SID

emergency_address_status of registration-failure is the finding that looks configured everywhere else. pending-registration is submitted and not yet usable. Only a registered status with a SID means a dispatcher will receive an address.

Check whether the number is used for outbound calls

GET /2010-04-01/Accounts/{AccountSid}/Calls.json?From={E164}&PageSize=1 is one extra GET per finding and it sets the order of the work. A number that is a caller ID for a team of softphones is the one to fix this afternoon; a number nobody has ever dialled out on can wait for the batch.

Register an address, attach it, then read the status again

POST /2010-04-01/Accounts/{AccountSid}/Addresses.json with EmergencyEnabled=true, then POST …/IncomingPhoneNumbers/{PNSid}.json with EmergencyAddressSid=AD… and EmergencyStatus=Active. Re-run this script a day later rather than the same minute: validation is asynchronous, and the answer you want is the status after it has run, not the 200 from the update.

How to check it worked

Re-run the script. Every US and Canadian voice number should report registered, and the exposed count should be zero.

python3 twilio_emergency_address_audit.py
# 18 number(s), 12 in scope for E911, 0 without a working registration

The full code

One paginated GET over the numbers, and with --check-traffic one extra GET per finding to see whether the number is used as a caller ID. Read access is all it needs. The classifier is a pure function over a single number resource, because the whole substance of this note is the difference between an address SID and a registration that completed, and that difference should be four lines you can read rather than a branch inside 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_emergency_address_audit.py
"""Report US and Canadian Twilio numbers with no working E911 registration.

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

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


def in_scope(number):
    """True when this number could carry a 911 call at all.

    E911 registration is a North American obligation and needs a voice
    capability. Everything else is out of scope rather than compliant, and
    mixing the two is how a list of exposed numbers stops being read.
    """
    e164 = str(number.get("phone_number") or "").strip()
    caps = number.get("capabilities") or {}
    return e164.startswith("+1") and bool(caps.get("voice"))


def verdict(number):
    """Classify one IncomingPhoneNumber's emergency registration.

    Pure, so the rule can be tested without a network. The rule that matters:
    an emergency_address_sid is a submission, and emergency_address_status is
    the outcome. Judging on the SID alone reports a rejected address as done.

    Returns (state, detail).
    """
    if not in_scope(number):
        e164 = str(number.get("phone_number") or "").strip()
        if not e164.startswith("+1"):
            return ("out-of-scope",
                    "not a +1 number: E911 address registration is a US and "
                    "Canadian requirement and does not apply here.")
        return ("out-of-scope",
                "no voice capability, so no call can be placed to 911 from it.")

    status = str(number.get("emergency_address_status") or "").strip().lower()
    sid = str(number.get("emergency_address_sid") or "").strip()

    if status == "registration-failure":
        return ("registration-failed",
                "an address was submitted and the validation rejected it. The "
                "console still shows a street address on this number, which is "
                "why it survives every visual check; no dispatcher will get it.")

    if status == "pending-registration":
        return ("pending",
                "submitted and not yet validated against the address database. "
                "Until it passes, a 911 call from here routes exactly as an "
                "unregistered number does.")

    if not sid or status == "unregistered":
        return ("unregistered",
                "no emergency address at all. A 911 call reaches a national "
                "emergency call centre that cannot see a location and has to ask "
                "for one, and the per-call fee is passed through to you.")

    if str(number.get("emergency_status") or "").strip().lower() == "inactive":
        return ("disabled",
                "address %s is registered but emergency calling is switched off "
                "on the number, so the registration buys nothing." % sid)

    return ("registered", "address %s, status %s" % (sid, status or "registered"))


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 dials_out(session, account, e164):
    """One outbound call is enough to know the number is somebody's caller ID,
    which is what decides whether this is today's job or this month's."""
    page = get(session, "%s/Accounts/%s/Calls.json" % (BASE, account),
               From=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 finding to see if the number is used "
                         "as an outbound caller ID")
    ap.add_argument("--show-out-of-scope", action="store_true",
                    help="also list the numbers E911 does not apply to")
    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

    scoped = 0
    bad = 0
    for n in numbers:
        state, detail = verdict(n)
        line = "%-20s %s  %s" % (state, n.get("phone_number", "?"), detail)
        if state == "out-of-scope":
            if args.show_out_of_scope:
                log.info(line)
            continue
        scoped += 1
        if state == "registered":
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        if args.check_traffic and dials_out(session, account, n.get("phone_number")):
            log.warning("  this number places outbound calls: somebody may dial "
                        "911 from it today")
        log.warning("  repair: create an Address on %s/Accounts/%s/Addresses.json "
                    "with EmergencyEnabled=true, then update "
                    "%s/Accounts/%s/IncomingPhoneNumbers/%s.json with "
                    "EmergencyAddressSid=AD... and EmergencyStatus=Active. "
                    "Read the status again a day later: validation is "
                    "asynchronous and the 200 is not the answer.",
                    BASE, account, BASE, account, n.get("sid"))

    log.info("%d number(s), %d in scope for E911, %d without a working "
             "registration", len(numbers), scoped, bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-emergency-address-audit.mjs
/**
 * Report US and Canadian Twilio numbers with no working E911 registration.
 *
 * 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`;

/**
 * True when this number could carry a 911 call at all. E911 registration is a
 * North American obligation and needs a voice capability; everything else is
 * out of scope rather than compliant.
 */
export function inScope(number) {
  const e164 = String(number.phone_number ?? '').trim();
  const caps = number.capabilities ?? {};
  return e164.startsWith('+1') && Boolean(caps.voice);
}

/**
 * Classify one IncomingPhoneNumber's emergency registration. Pure, so the rule
 * can be tested without a network: emergency_address_sid is a submission and
 * emergency_address_status is the outcome, and judging on the SID alone reports
 * a rejected address as done. Returns [state, detail].
 */
export function verdict(number) {
  const e164 = String(number.phone_number ?? '').trim();
  const caps = number.capabilities ?? {};
  if (!e164.startsWith('+1')) {
    return ['out-of-scope',
      'not a +1 number: E911 address registration is a US and Canadian ' +
      'requirement and does not apply here.'];
  }
  if (!caps.voice) {
    return ['out-of-scope',
      'no voice capability, so no call can be placed to 911 from it.'];
  }

  const status = String(number.emergency_address_status ?? '').trim().toLowerCase();
  const sid = String(number.emergency_address_sid ?? '').trim();

  if (status === 'registration-failure') {
    return ['registration-failed',
      'an address was submitted and the validation rejected it. The console ' +
      'still shows a street address on this number, which is why it survives ' +
      'every visual check; no dispatcher will get it.'];
  }

  if (status === 'pending-registration') {
    return ['pending',
      'submitted and not yet validated against the address database. Until it ' +
      'passes, a 911 call from here routes exactly as an unregistered number does.'];
  }

  if (!sid || status === 'unregistered') {
    return ['unregistered',
      'no emergency address at all. A 911 call reaches a national emergency ' +
      'call centre that cannot see a location and has to ask for one, and the ' +
      'per-call fee is passed through to you.'];
  }

  if (String(number.emergency_status ?? '').trim().toLowerCase() === 'inactive') {
    return ['disabled',
      `address ${sid} is registered but emergency calling is switched off on ` +
      'the number, so the registration buys nothing.'];
  }

  return ['registered', `address ${sid}, status ${status || 'registered'}`];
}

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 showOutOfScope = process.argv.includes('--show-out-of-scope');

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

  let scoped = 0;
  let bad = 0;
  for (const n of numbers) {
    const [state, detail] = verdict(n);
    const line = `${state.padEnd(20)} ${n.phone_number ?? '?'}  ${detail}`;
    if (state === 'out-of-scope') {
      if (showOutOfScope) console.log(line);
      continue;
    }
    scoped += 1;
    if (state === 'registered') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    if (checkTraffic) {
      const calls = await get(auth, `${BASE}/Accounts/${account}/Calls.json`,
                              { From: n.phone_number, PageSize: 1 });
      if ((calls.calls ?? []).length) {
        console.warn('  this number places outbound calls: somebody may dial 911 ' +
                     'from it today');
      }
    }
    console.warn(`  repair: create an Address on ${BASE}/Accounts/${account}/` +
                 'Addresses.json with EmergencyEnabled=true, then update ' +
                 `${BASE}/Accounts/${account}/IncomingPhoneNumbers/${n.sid}.json ` +
                 'with EmergencyAddressSid=AD... and EmergencyStatus=Active. Read ' +
                 'the status again a day later: validation is asynchronous and ' +
                 'the 200 is not the answer.');
  }

  console.log(`${numbers.length} number(s), ${scoped} in scope for E911, ${bad} ` +
              'without a working registration');
  process.exitCode = bad ? 1 : 0;
}

// Only run when invoked directly, so importing this module from the test file
// does not start an audit and fail on the missing credentials.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The case worth pinning hardest is the number that carries an emergency_address_sid and a registration-failure status, because every audit that checks the SID alone calls it compliant and every human opening the console sees a street address. The rest of the tests keep the out-of-scope numbers out of the finding list, which is what keeps the finding list short enough to act on.

test_twilio_emergency_address_audit.py
from twilio_emergency_address_audit import in_scope, verdict


def us_number(**over):
    n = {"phone_number": "+12025550123", "capabilities": {"voice": True, "sms": True},
         "emergency_address_sid": None, "emergency_address_status": "unregistered",
         "emergency_status": "Active", "sid": "PN1"}
    n.update(over)
    return n


def test_number_with_no_address_is_unregistered():
    state, detail = verdict(us_number())
    assert state == "unregistered"
    assert "national emergency call centre" in detail


def test_rejected_registration_is_not_the_same_as_no_address():
    # The SID is populated, so a check that reads only the SID calls this fixed.
    state, detail = verdict(us_number(emergency_address_sid="AD1",
                                      emergency_address_status="registration-failure"))
    assert state == "registration-failed"
    assert "visual check" in detail


def test_pending_registration_is_still_exposed():
    state, _ = verdict(us_number(emergency_address_sid="AD1",
                                 emergency_address_status="pending-registration"))
    assert state == "pending"


def test_registered_address_with_emergency_calling_switched_off():
    state, detail = verdict(us_number(emergency_address_sid="AD1",
                                      emergency_address_status="registered",
                                      emergency_status="Inactive"))
    assert state == "disabled"
    assert "buys nothing" in detail


def test_registered_number_passes():
    state, _ = verdict(us_number(emergency_address_sid="AD1",
                                 emergency_address_status="registered"))
    assert state == "registered"


def test_non_north_american_number_is_out_of_scope_not_a_finding():
    state, detail = verdict(us_number(phone_number="+441632960000"))
    assert state == "out-of-scope"
    assert "does not apply" in detail


def test_sms_only_number_cannot_dial_911():
    state, _ = verdict(us_number(capabilities={"voice": False, "sms": True}))
    assert state == "out-of-scope"
    assert not in_scope(us_number(capabilities={"sms": True}))
twilio-emergency-address-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { inScope, verdict } from './twilio-emergency-address-audit.mjs';

const usNumber = (over = {}) => ({
  phone_number: '+12025550123',
  capabilities: { voice: true, sms: true },
  emergency_address_sid: null,
  emergency_address_status: 'unregistered',
  emergency_status: 'Active',
  sid: 'PN1',
  ...over,
});

test('number with no address is unregistered', () => {
  const [state, detail] = verdict(usNumber());
  assert.equal(state, 'unregistered');
  assert.match(detail, /national emergency call centre/);
});

test('rejected registration is not the same as no address', () => {
  const [state, detail] = verdict(usNumber({
    emergency_address_sid: 'AD1', emergency_address_status: 'registration-failure',
  }));
  assert.equal(state, 'registration-failed');
  assert.match(detail, /visual check/);
});

test('pending registration is still exposed', () => {
  const [state] = verdict(usNumber({
    emergency_address_sid: 'AD1', emergency_address_status: 'pending-registration',
  }));
  assert.equal(state, 'pending');
});

test('registered address with emergency calling switched off', () => {
  const [state, detail] = verdict(usNumber({
    emergency_address_sid: 'AD1', emergency_address_status: 'registered',
    emergency_status: 'Inactive',
  }));
  assert.equal(state, 'disabled');
  assert.match(detail, /buys nothing/);
});

test('registered number passes', () => {
  const [state] = verdict(usNumber({
    emergency_address_sid: 'AD1', emergency_address_status: 'registered',
  }));
  assert.equal(state, 'registered');
});

test('non North American number is out of scope, not a finding', () => {
  const [state, detail] = verdict(usNumber({ phone_number: '+441632960000' }));
  assert.equal(state, 'out-of-scope');
  assert.match(detail, /does not apply/);
});

test('sms only number cannot dial 911', () => {
  const [state] = verdict(usNumber({ capabilities: { voice: false, sms: true } }));
  assert.equal(state, 'out-of-scope');
  assert.equal(inScope(usNumber({ capabilities: { sms: true } })), false);
});

FAQ

Which field actually decides whether this number is registered?

emergency_address_status. The SID records that an address was attached; the status records what the validation made of it. A number with a SID and a registration-failure status is exposed, and it is the one case where the console looks correct, because the console shows the street address you submitted.

Why is a rejected registration so common?

The address is checked against a dispatch database rather than a postal one. Suite and unit numbers, abbreviations, new-build streets and rural addresses are the usual rejections. None of that is visible when you submit, and the rejection lands days later on a resource nobody is watching.

Do numbers outside the US and Canada need this?

Not through this mechanism. Emergency calling elsewhere is a different regime with different obligations, so this script puts non-+1 numbers and SMS-only numbers in an out-of-scope state rather than in the findings. A report where thirty of the forty rows do not need action gets read once.

What does an unregistered 911 call actually do?

It is routed to a national emergency call centre instead of the local dispatch centre. An operator who cannot see a location has to ask for one and then relay it, on a call where the caller may not be able to speak. The per-call charge that Twilio passes through is the part that shows up in writing later.

Can the script register the address for me?

No. Creating an address and attaching it to a live number are writes, and nothing in this section writes. It prints both calls with the phone number SID already filled in, and it tells you to read the status again the following day, because the 200 from the update is not the outcome.

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.