Skip to content

Diagnostic Twilio

numbers still pinned to the 2008-08-01 API version

The delivery-failure dashboard has a bucket called unknown and it is a third of the chart. The code reads error_code off each message, the documentation says the field is there, and for messages from one particular number it is simply absent. That number was bought in 2014. It still carries api_version of 2008-08-01, and the 2008 schema does not have that field.

Read-only key Python and Node.js Tests included
Close-up of computer server rack components
Photo by Đào Hiếu on Unsplash
The short answer

Read GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json and flag any entry whose api_version is not 2010-04-01. Then read the account default from GET /2010-04-01/Accounts/{AccountSid}.json, because that field decides what the next number you buy arrives pinned to.

The pin is per number and set at purchase time. It does not expire, nothing migrates it, and Twilio will keep serving the version each number asks for indefinitely, which is why nothing has ever forced this to your attention.

The problem in plain words

This is a version mismatch that presents as an empty field. Your code asks for error_code, gets nothing, and does the sensible thing with nothing: treats the message as having no error. The 2008 schema is not broken and Twilio is not failing to honour it — the number asked for that version, and that version does not carry that field. There is no 400, no deprecation header, no line in the Debugger, and no difference visible in the console, which shows the same tidy row for a 2008 number as for a 2010 one.

What makes it expensive is the sampling. Most accounts have one or two legacy numbers among dozens, so every test passes, every integration works, and the gap only opens on the traffic that happens to use the old number. A team can spend a week on a reporting bug that is really one field on one resource, set eleven years ago by whoever clicked Buy.

Number bought2014api_version set atpurchaseMessage sentfrom that onenumberWebhook built2008 schema, fewerfieldserror_codeabsentread as no errorat allDashboard saysunknowna third of thechart
Nothing fails anywhere along here. The number asked for the 2008 schema and Twilio served it, exactly as it has since the day it was bought.

Why it happens

The pin belongs to the number, not to your client library. Your SDK talks to /2010-04-01/ and gets modern responses; the webhook Twilio sends for that number is built from the number's own api_version. Those are two different decisions and only one of them is under your code's control.

There is no end-of-life date to force the issue. Unlike a product being retired, the 2008 version is not scheduled to be switched off. Nothing counts down, nothing warns, and an account can carry a 2008 pin for another decade. Deprecation with a date eventually fixes itself; deprecation without one never does.

The account default is a separate field with its own failure. Repairing every number and leaving the account default on the old version is a treadmill: the next number bought arrives pinned again, and the audit that passed last quarter fails next quarter for a reason nobody wrote down.

Absence is the worst possible symptom. A wrong value gets noticed. A missing one is indistinguishable from a legitimately empty field, so it flows through validation, through the ORM, into a chart, and comes out the other side as a category of message that apparently had no error.

Old numbers are the ones that matter most. The number pinned to 2008 is by definition the oldest one on the account, which usually means it is the main line, printed on things, carrying the most traffic. The finding is inversely correlated with how safe it is to ignore.

The fix, as a flow

The script reads the account default as well as the numbers, because that one field decides what the next number bought on the account arrives pinned to, and repairing the numbers without it is a treadmill.

GET account and numbersapi_version on eachOn 2010-04-01current, leave itPinned, no handlera landmine, not a firePinned and wired upold schema on every webhookAccount default is oldnext number arrives pinned
A pinned number with no handler is a real finding and not a live one, and a version nobody can read must never be counted as fine.

How to fix it

Read the account default first

GET /2010-04-01/Accounts/{AccountSid}.json and look at api_version. This is the value new numbers inherit. If it is 2008-08-01, the per-number findings below are a symptom and this field is the cause, so fix them in that order.

List every number and read api_version on each

GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json?PageSize=1000, following next_page_uri to the end. The field sits alongside voice_url and sms_url on each row; there is no filter for it, so this is a full sweep and a client-side comparison.

Separate the pinned numbers that are wired up

A 2008 pin only reaches your application through a webhook, so a pinned number with a voice_url, an sms_url or an application SID is actively serving the old schema, and one with none of those is not yet. Both need fixing; only one of them is sending you short webhooks today.

Do not treat an unexpected value as current

Anything that is neither 2010-04-01 nor 2008-08-01 — including a blank field — should be reported as unread rather than folded into either bucket. A check that quietly treats unknown as fine is how the one number that matters gets skipped.

Repin the numbers, then the account, then re-run

POST /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers/{PNSid}.json with ApiVersion=2010-04-01 for each, and set the account default in Console → Account → API version. Re-run afterwards, and again after the next number purchase, because that is the moment this comes back.

How to check it worked

Re-run the script. The account default and every number should report current.

python3 twilio_api_version_audit.py
# current  account default is 2010-04-01
# 12 number(s), 0 pinned to an older API version

The full code

One GET for the account and one paginated GET over the numbers, with an API Key that has read access and nothing more. Both classifiers are pure functions and both are exported, because the account default and the per-number pin are genuinely two findings with two repairs, and a script that reports only the second leaves you fixing the same thing every quarter.

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_api_version_audit.py
"""Report Twilio phone numbers pinned to an older API version.

The pin is per number and set at purchase time. It does not expire and nothing
migrates it, so a number bought in 2014 is still served the 2008 schema today:
webhooks with fewer parameters, and resource fields the current documentation
promises simply absent.

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

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

CURRENT = "2010-04-01"
LEGACY = "2008-08-01"

ROUTING_FIELDS = ("voice_url", "sms_url", "voice_fallback_url", "sms_fallback_url",
                  "status_callback", "voice_application_sid", "sms_application_sid")


def is_routed(number):
    """True when something on this number would actually be fetched.

    The version pin only reaches your application through a webhook, so a pinned
    number with no handler on it is a different sentence from a pinned number
    that is answering calls today. Both are findings; only one is current.
    """
    return any(str(number.get(f) or "").strip() for f in ROUTING_FIELDS)


def verdict(number):
    """Classify one IncomingPhoneNumber by the API version it is pinned to.

    Pure, so the rules can be tested without a network. Returns (state, detail).
    """
    version = str(number.get("api_version") or "").strip()

    if not version:
        return ("unread",
                "no api_version on this resource: report it rather than assuming "
                "it is current, because an unknown quietly counted as fine is how "
                "the one number that matters gets skipped.")

    if version == CURRENT:
        return ("current", "on %s, the version the documentation describes." % CURRENT)

    if version == LEGACY:
        if is_routed(number):
            return ("legacy-live",
                    "pinned to %s and wired to a handler: every webhook Twilio "
                    "sends for this number is built from the %s schema, so "
                    "parameters the docs promise arrive absent rather than wrong."
                    % (LEGACY, LEGACY))
        return ("legacy-idle",
                "pinned to %s with no handler on it: nothing is receiving the old "
                "schema today, and something will on the day this number is used."
                % LEGACY)

    return ("unread",
            "api_version is %s, which is neither %s nor %s: read it before "
            "assuming anything about what the webhooks carry."
            % (version, CURRENT, LEGACY))


def account_verdict(account):
    """Classify the account's default API version. Pure.

    Separate from the per-number check because this field decides what the next
    number bought on this account arrives pinned to. Repairing the numbers and
    leaving this one is a treadmill: the audit passes this quarter and fails the
    next, for a reason nobody wrote down.

    Returns (state, detail).
    """
    version = str(account.get("api_version") or "").strip()
    if not version:
        return ("unread",
                "no api_version on the account resource: the default that new "
                "numbers inherit could not be read.")
    if version == CURRENT:
        return ("current", "account default is %s." % CURRENT)
    return ("legacy-default",
            "account default is %s: every number bought from here on arrives "
            "pinned to it, so repairing the numbers alone fixes nothing that "
            "stays fixed." % version)


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

    state, detail = account_verdict(get(session, "%s/Accounts/%s.json" % (BASE, account)))
    bad = 0
    if state == "current":
        log.info("%-14s %s", state, detail)
    else:
        bad += 1
        log.warning("%-14s %s", state, detail)
        log.warning("  repair: Console > Account > API version, set it to %s", CURRENT)

    numbers = list_numbers(session, account, args.max_numbers)
    pinned = 0
    for n in numbers:
        state, detail = verdict(n)
        line = "%-14s %s  %s" % (state, n.get("phone_number", "?"), detail)
        if state == "current":
            log.info(line)
            continue
        pinned += 1
        log.warning(line)
        log.warning("  repair: POST %s/Accounts/%s/IncomingPhoneNumbers/%s.json "
                    "ApiVersion=%s", BASE, account, n.get("sid"), CURRENT)

    log.info("%d number(s), %d pinned to an older API version", len(numbers), pinned)
    return 1 if (pinned or bad) else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-api-version-audit.mjs
/**
 * Report Twilio phone numbers pinned to an older API version.
 *
 * The pin is per number and set at purchase time. It does not expire and nothing
 * migrates it, so a number bought in 2014 is still served the 2008 schema today.
 *
 * 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 CURRENT = '2010-04-01';
const LEGACY = '2008-08-01';

const ROUTING_FIELDS = ['voice_url', 'sms_url', 'voice_fallback_url',
                        'sms_fallback_url', 'status_callback',
                        'voice_application_sid', 'sms_application_sid'];

/**
 * True when something on this number would actually be fetched. The version pin
 * only reaches your application through a webhook.
 */
export function isRouted(number) {
  return ROUTING_FIELDS.some((f) => String(number[f] ?? '').trim());
}

/**
 * Classify one IncomingPhoneNumber by the API version it is pinned to. Pure, so
 * the rules can be tested without a network. Returns [state, detail].
 */
export function verdict(number) {
  const version = String(number.api_version ?? '').trim();

  if (!version) {
    return ['unread',
      'no api_version on this resource: report it rather than assuming it is ' +
      'current, because an unknown quietly counted as fine is how the one ' +
      'number that matters gets skipped.'];
  }

  if (version === CURRENT) {
    return ['current', `on ${CURRENT}, the version the documentation describes.`];
  }

  if (version === LEGACY) {
    if (isRouted(number)) {
      return ['legacy-live',
        `pinned to ${LEGACY} and wired to a handler: every webhook Twilio sends ` +
        `for this number is built from the ${LEGACY} schema, so parameters the ` +
        'docs promise arrive absent rather than wrong.'];
    }
    return ['legacy-idle',
      `pinned to ${LEGACY} with no handler on it: nothing is receiving the old ` +
      'schema today, and something will on the day this number is used.'];
  }

  return ['unread',
    `api_version is ${version}, which is neither ${CURRENT} nor ${LEGACY}: read ` +
    'it before assuming anything about what the webhooks carry.'];
}

/**
 * Classify the account's default API version. Pure. This field decides what the
 * next number bought on this account arrives pinned to, so repairing the numbers
 * and leaving it is a treadmill. Returns [state, detail].
 */
export function accountVerdict(account) {
  const version = String(account.api_version ?? '').trim();
  if (!version) {
    return ['unread',
      'no api_version on the account resource: the default that new numbers ' +
      'inherit could not be read.'];
  }
  if (version === CURRENT) return ['current', `account default is ${CURRENT}.`];
  return ['legacy-default',
    `account default is ${version}: every number bought from here on arrives ` +
    'pinned to it, so repairing the numbers alone fixes nothing that stays fixed.'];
}

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

  let bad = 0;
  const [aState, aDetail] = accountVerdict(
    await get(auth, `${BASE}/Accounts/${account}.json`));
  if (aState === 'current') {
    console.log(`${aState.padEnd(14)} ${aDetail}`);
  } else {
    bad += 1;
    console.warn(`${aState.padEnd(14)} ${aDetail}`);
    console.warn(`  repair: Console > Account > API version, set it to ${CURRENT}`);
  }

  const numbers = await listNumbers(auth, account);
  let pinned = 0;
  for (const n of numbers) {
    const [state, detail] = verdict(n);
    const line = `${state.padEnd(14)} ${n.phone_number ?? '?'}  ${detail}`;
    if (state === 'current') { console.log(line); continue; }
    pinned += 1;
    console.warn(line);
    console.warn(`  repair: POST ${BASE}/Accounts/${account}/IncomingPhoneNumbers/` +
                 `${n.sid}.json ApiVersion=${CURRENT}`);
  }

  console.log(`${numbers.length} number(s), ${pinned} pinned to an older API version`);
  process.exitCode = (pinned || 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 two that a simpler check gets wrong. A legacy number with no handler is a real finding but not a live one, and reporting it with the same words as the main line wastes the reader's attention. An unexpected or missing api_version must not be quietly counted as current, because on an account with forty numbers the only one that gets skipped is the one nobody could parse.

test_twilio_api_version_audit.py
from twilio_api_version_audit import account_verdict, is_routed, verdict


def make(**kw):
    number = {"sid": "PN01", "phone_number": "+15005550006",
              "api_version": "2010-04-01", "voice_url": "https://app.example.com/voice"}
    number.update(kw)
    return number


def test_a_number_on_the_current_version_is_current():
    state, detail = verdict(make())
    assert state == "current"
    assert "2010-04-01" in detail


def test_a_2008_pin_with_a_live_handler_is_serving_the_old_schema_now():
    state, detail = verdict(make(api_version="2008-08-01"))
    assert state == "legacy-live"
    assert "absent" in detail


def test_a_2008_pin_with_no_handler_is_a_separate_and_quieter_finding():
    state, detail = verdict(make(api_version="2008-08-01", voice_url=""))
    assert state == "legacy-idle"
    assert "day this number is used" in detail


def test_an_application_sid_alone_still_counts_as_routed():
    assert is_routed({"voice_application_sid": "AP0123456789"}) is True
    assert is_routed({"voice_url": "", "sms_url": None}) is False


def test_a_missing_api_version_is_reported_rather_than_assumed_current():
    state, detail = verdict(make(api_version=None))
    assert state == "unread"
    assert "assuming" in detail


def test_an_unexpected_version_is_never_folded_into_either_bucket():
    state, detail = verdict(make(api_version="2015-01-01"))
    assert state == "unread"
    assert "2015-01-01" in detail


def test_the_account_default_is_its_own_finding_with_its_own_repair():
    state, detail = account_verdict({"api_version": "2008-08-01"})
    assert state == "legacy-default"
    assert "bought from here on" in detail


def test_a_current_account_default_means_new_numbers_arrive_correct():
    assert account_verdict({"api_version": "2010-04-01"})[0] == "current"
    assert account_verdict({})[0] == "unread"
twilio-api-version-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { accountVerdict, isRouted, verdict } from './twilio-api-version-audit.mjs';

const make = (over = {}) => ({
  sid: 'PN01',
  phone_number: '+15005550006',
  api_version: '2010-04-01',
  voice_url: 'https://app.example.com/voice',
  ...over,
});

test('a number on the current version is current', () => {
  const [state, detail] = verdict(make());
  assert.equal(state, 'current');
  assert.match(detail, /2010-04-01/);
});

test('a 2008 pin with a live handler is serving the old schema now', () => {
  const [state, detail] = verdict(make({ api_version: '2008-08-01' }));
  assert.equal(state, 'legacy-live');
  assert.match(detail, /absent/);
});

test('a 2008 pin with no handler is a separate and quieter finding', () => {
  const [state, detail] = verdict(make({ api_version: '2008-08-01', voice_url: '' }));
  assert.equal(state, 'legacy-idle');
  assert.match(detail, /day this number is used/);
});

test('an application sid alone still counts as routed', () => {
  assert.equal(isRouted({ voice_application_sid: 'AP0123456789' }), true);
  assert.equal(isRouted({ voice_url: '', sms_url: null }), false);
});

test('a missing api_version is reported rather than assumed current', () => {
  const [state, detail] = verdict(make({ api_version: null }));
  assert.equal(state, 'unread');
  assert.match(detail, /assuming/);
});

test('an unexpected version is never folded into either bucket', () => {
  const [state, detail] = verdict(make({ api_version: '2015-01-01' }));
  assert.equal(state, 'unread');
  assert.match(detail, /2015-01-01/);
});

test('the account default is its own finding with its own repair', () => {
  const [state, detail] = accountVerdict({ api_version: '2008-08-01' });
  assert.equal(state, 'legacy-default');
  assert.match(detail, /bought from here on/);
});

test('a current account default means new numbers arrive correct', () => {
  assert.equal(accountVerdict({ api_version: '2010-04-01' })[0], 'current');
  assert.equal(accountVerdict({})[0], 'unread');
});

FAQ

Is the 2008 API going to be switched off?

There is no announced date, and that is precisely what makes this worth auditing. A retirement with a deadline eventually forces itself onto a roadmap. A version that is merely old gets served forever, so a 2008 pin will sit on an account until somebody goes looking for it, which is what this script is.

Does my client library not control the version?

It controls the version of the REST calls you make, which is why your API responses look modern. It has nothing to do with the webhooks Twilio sends you: those are built from the api_version on the number that received the call or message. Two different decisions, and only one of them is in your code.

Why does the script care whether the number has a handler?

Because it changes what you do this afternoon. A pinned number with a live voice_url is sending your application short webhooks right now, and that is a bug with current consequences. A pinned number with nothing on it is a landmine rather than an explosion, and it deserves a different sentence in the report.

Is repinning a number risky?

It changes the parameter set your handler receives for that number, so it is a code question rather than a config question. Read what your handler actually uses first, deploy any missing tolerance, then repin one number and watch it. Doing all forty in a batch on a Friday is how a working line starts throwing 500s.

Why check the account default separately?

Because it is the field that reintroduces the problem. Numbers inherit it at purchase, so an account still defaulting to the old version will hand you a fresh finding every time somebody buys a number. Fixing the numbers is the cleanup; fixing the default is the fix.

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.