Skip to content

Diagnostic Twilio

inbound SMS disappears into a number with no sms_url

Outbound works perfectly. Replies do not arrive. There is no 4xx, no entry in the Debugger, no request in your access log — the inbound message is accepted by Twilio, matched to a number, and then delivered to nowhere. The STOP replies vanish the same way, which is the part that eventually costs money.

Read-only key Python and Node.js Tests included
Server nameplates
Photo by Marc PEZIN on Unsplash
The short answer

Read GET https://messaging.twilio.com/v1/Services/{ServiceSid} and look at use_inbound_webhook_on_number. When it is true, the number's sms_url wins and the service's inbound_request_url is ignored entirely — so any pool number with a blank sms_url silently drops inbound traffic.

The inverse black-holes the whole pool at once: use_inbound_webhook_on_number false with inbound_request_url unset. Join the pool from GET /v1/Services/{ServiceSid}/PhoneNumbers to GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json to see which numbers actually have a handler.

The problem in plain words

Inbound messages that go nowhere produce no evidence anywhere. Twilio has no failing HTTP request to log, because it never made one. Your server has no request to trace, because none arrived. The message itself exists — it is in the Messages list, direction of inbound, looking entirely normal — but the webhook that was supposed to hand it to your application was never called.

The bill for this is not the missed conversations. It is the STOP replies. An opt-out that never reaches your database means you keep sending to someone who asked you to stop; Twilio still honours the opt-out and rejects the sends with 21610, so you find out eventually, but by then you have a compliance problem with a start date rather than a bug.

Reply arrivesSTOP or a customeranswerMatched tonumberin the sender poolDefer to numberservice URLignoredsms_url isblankno request madeNothing loggedno 4xx, no alert
The service URL is set and correct. It is simply not the URL that wins when the service defers to the sender's webhook.

Why it happens

The setting inverts which URL wins, and it is on by default. "Defer to sender's webhook" reads like a fallback and is not one. When use_inbound_webhook_on_number is true, the number's sms_url is the handler and the service's inbound_request_url is dead configuration — still there, still visible in the API and console, doing nothing.

Configuring the service feels like configuring the numbers. That is the whole appeal of a Messaging Service: one place for pool, opt-out, callbacks. Setting inbound_request_url there is the natural act, it succeeds, and the value is displayed back to you. Nothing indicates it is being overridden per number.

It breaks per number, not per service. The numbers that were bought and wired individually work. The ones added later, or moved in from another service, carry a blank sms_url and drop everything. So inbound "works" in testing, on whichever number the tester happened to use.

The two fields live in different APIs. The service is on messaging.twilio.com/v1; sms_url is on the 2010-04-01 account API; the pool listing gives you SIDs but not handler URLs. No single response shows the failure, so you have to join three of them before it is even visible.

The fix, as a flow

The script joins three responses, because no single one shows the failure: the routing mode is on the Messaging Service, the pool is a subresource of it, and sms_url lives on the number in the account API.

Service, pool and numbersjoined on the PN sidCentralised on serviceone URL for the poolEvery number wiredrouted per senderBlank sms_url in poolthose numbers drop inboundNo inbound_request_urlwhole pool drops inbound
Two settings, two ways to lose everything: the number without a URL, and the service that took the routing back and never set one.

How to fix it

Read the routing mode on every service

GET https://messaging.twilio.com/v1/Services, then per service read use_inbound_webhook_on_number, inbound_request_url and fallback_url. Those three fields determine which of the two failure modes you are looking for.

Catch the whole-pool case first

use_inbound_webhook_on_number false with an empty inbound_request_url drops inbound for every number in the pool at once. It is one comparison, it is the more damaging of the two, and it needs no join to detect.

Join the pool to the numbers

GET /v1/Services/{ServiceSid}/PhoneNumbers returns PN SIDs and E.164 numbers but not handler URLs. Build a map from GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json keyed on sid and look each pool member up in it. A pool member that is not in the map belongs to a subaccount; report it as unresolved rather than as broken.

Flag blank sms_url, then blank sms_fallback_url

A blank sms_url while the service defers to the number is the black hole. A populated sms_url with a blank sms_fallback_url is the lesser finding: inbound works until your endpoint returns non-2xx, and then that message is lost too.

Pick one place to route inbound, and make it true everywhere

Either centralise — POST /v1/Services/{ServiceSid} with UseInboundWebhookOnNumber=false and InboundRequestUrl — or set SmsUrl on every number in the pool. Half-and-half is what produced this. Re-run the audit after adding a number to the pool, because that is when it recurs.

How to check it worked

Re-run the script. Every service should report routed or centralised, and no number should appear as a black hole.

python3 twilio_inbound_route_audit.py
# 3 service(s), 0 dropping inbound messages

The full code

Three GETs and a join: the services, each service's pool, and the account's numbers — all read with an API Key that has read access and nothing more. The routing rule is a pure function taking the service and its resolved pool, because which URL actually wins is the entire content of this note and it should be readable in one screen.

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_inbound_route_audit.py
"""Report Messaging Services whose inbound messages are routed nowhere.

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.
"""
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_inbound_route_audit")

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


def verdict(service, pool):
    """Decide where a Messaging Service's inbound messages actually land.

    `service` is the Messaging Service resource. `pool` is its sender pool with
    each number already joined to its IncomingPhoneNumber record, so every entry
    carries `phone_number`, `sms_url` and `sms_fallback_url`.

    Pure, so the precedence rule can be tested without a network. Returns
    (state, detail).
    """
    defers = bool(service.get("use_inbound_webhook_on_number"))
    inbound = str(service.get("inbound_request_url") or "").strip()

    if not defers:
        if not inbound:
            return ("service-black-hole",
                    "use_inbound_webhook_on_number is false and "
                    "inbound_request_url is empty: inbound to all %d pool "
                    "number(s) is dropped." % len(pool))
        return ("centralised",
                "all inbound goes to the service URL; the numbers' sms_url "
                "values are ignored.")

    if not pool:
        return ("empty-pool",
                "defers to the sender's webhook, but the pool has no numbers.")

    blank = [n.get("phone_number", "?") for n in pool
             if not str(n.get("sms_url") or "").strip()]
    if blank:
        detail = ("%d of %d pool number(s) have a blank sms_url and the service "
                  "defers to the number, so inbound to %s is dropped."
                  % (len(blank), len(pool), ", ".join(blank[:5])))
        if inbound:
            detail += " inbound_request_url is set but ignored."
        return ("number-black-hole", detail)

    no_fallback = [n.get("phone_number", "?") for n in pool
                   if not str(n.get("sms_fallback_url") or "").strip()]
    if no_fallback:
        return ("no-fallback",
                "every number has an sms_url, but %d have no sms_fallback_url: "
                "one non-2xx and that message is gone." % len(no_fallback))

    return ("routed", "all %d pool number(s) have their own sms_url" % len(pool))


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_v1(session, url, key, limit=1000):
    """Page a messaging.twilio.com list. meta.next_page_url is absolute."""
    out = []
    while url and len(out) < limit:
        page = get(session, url, PageSize=50)
        out.extend(page.get(key, []))
        url = (page.get("meta") or {}).get("next_page_url")
    return out[:limit]


def list_numbers(session, account, limit=1000):
    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-services", type=int, default=200)
    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)

    services = list_v1(session, MSG + "/Services", "services", args.max_services)
    if not services:
        log.info("no Messaging Services on this account")
        return 0

    by_sid = {n.get("sid"): n for n in list_numbers(session, account)}

    bad = 0
    for svc in services:
        members = list_v1(session, "%s/Services/%s/PhoneNumbers" % (MSG, svc["sid"]),
                          "phone_numbers")
        pool, unresolved = [], []
        for m in members:
            record = by_sid.get(m.get("sid"))
            (pool if record else unresolved).append(record or m)

        state, detail = verdict(svc, pool)
        line = "%-18s %s  %s" % (state, svc.get("friendly_name", svc["sid"]), detail)
        if unresolved:
            log.info("%s: %d pool number(s) live in another account, not read",
                     svc["sid"], len(unresolved))
        if state in ("routed", "centralised"):
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        if state == "service-black-hole":
            log.warning("  repair: POST %s/Services/%s "
                        "InboundRequestUrl=https://your-app.example.com/twilio/inbound",
                        MSG, svc["sid"])
        elif state == "number-black-hole":
            log.warning("  repair: set SmsUrl on each number, or POST %s/Services/%s "
                        "UseInboundWebhookOnNumber=false with an InboundRequestUrl",
                        MSG, svc["sid"])

    log.info("%d service(s), %d dropping inbound messages", len(services), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-inbound-route-audit.mjs
/**
 * Report Messaging Services whose inbound messages are routed nowhere.
 *
 * 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 MSG = 'https://messaging.twilio.com/v1';

/**
 * Decide where a Messaging Service's inbound messages actually land.
 *
 * `service` is the Messaging Service resource; `pool` is its sender pool with
 * each number already joined to its IncomingPhoneNumber record. Pure, so the
 * precedence rule can be tested without a network. Returns [state, detail].
 */
export function verdict(service, pool) {
  const defers = Boolean(service.use_inbound_webhook_on_number);
  const inbound = String(service.inbound_request_url ?? '').trim();

  if (!defers) {
    if (!inbound) {
      return ['service-black-hole',
        'use_inbound_webhook_on_number is false and inbound_request_url is ' +
        `empty: inbound to all ${pool.length} pool number(s) is dropped.`];
    }
    return ['centralised',
      "all inbound goes to the service URL; the numbers' sms_url values are ignored."];
  }

  if (pool.length === 0) {
    return ['empty-pool', "defers to the sender's webhook, but the pool has no numbers."];
  }

  const blank = pool.filter((n) => !String(n.sms_url ?? '').trim())
                    .map((n) => n.phone_number ?? '?');
  if (blank.length) {
    return ['number-black-hole',
      `${blank.length} of ${pool.length} pool number(s) have a blank sms_url ` +
      `and the service defers to the number, so inbound to ${blank.slice(0, 5).join(', ')} ` +
      `is dropped.${inbound ? ' inbound_request_url is set but ignored.' : ''}`];
  }

  const noFallback = pool.filter((n) => !String(n.sms_fallback_url ?? '').trim());
  if (noFallback.length) {
    return ['no-fallback',
      `every number has an sms_url, but ${noFallback.length} have no ` +
      'sms_fallback_url: one non-2xx and that message is gone.'];
  }

  return ['routed', `all ${pool.length} pool number(s) have their own sms_url`];
}

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 listV1(auth, url, key, limit = 1000) {
  const out = [];
  let next = url;
  while (next && out.length < limit) {
    const page = await get(auth, next, { PageSize: 50 });
    out.push(...(page[key] ?? []));
    next = page.meta?.next_page_url ?? null;
  }
  return out.slice(0, limit);
}

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 services = await listV1(auth, `${MSG}/Services`, 'services');
  if (services.length === 0) {
    console.log('no Messaging Services on this account');
    return;
  }

  const bySid = new Map((await listNumbers(auth, account)).map((n) => [n.sid, n]));

  let bad = 0;
  for (const svc of services) {
    const members = await listV1(auth, `${MSG}/Services/${svc.sid}/PhoneNumbers`,
                                 'phone_numbers');
    const pool = [];
    let unresolved = 0;
    for (const m of members) {
      const record = bySid.get(m.sid);
      if (record) pool.push(record); else unresolved += 1;
    }

    const [state, detail] = verdict(svc, pool);
    const line = `${state.padEnd(18)} ${svc.friendly_name ?? svc.sid}  ${detail}`;
    if (unresolved) {
      console.log(`${svc.sid}: ${unresolved} pool number(s) live in another account, not read`);
    }
    if (state === 'routed' || state === 'centralised') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    if (state === 'service-black-hole') {
      console.warn(`  repair: POST ${MSG}/Services/${svc.sid} ` +
                   'InboundRequestUrl=https://your-app.example.com/twilio/inbound');
    } else if (state === 'number-black-hole') {
      console.warn(`  repair: set SmsUrl on each number, or POST ${MSG}/Services/` +
                   `${svc.sid} UseInboundWebhookOnNumber=false with an InboundRequestUrl`);
    }
  }

  console.log(`${services.length} service(s), ${bad} dropping inbound messages`);
  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 test that matters is the one where the service has a perfectly good inbound_request_url and the pool number does not have an sms_url. Everything about that service reads as configured; it is the precedence rule that makes it a black hole, so the classifier has to report it as broken while the field is populated.

test_twilio_inbound_route_audit.py
from twilio_inbound_route_audit import verdict

SERVICE_URL = "https://app.example.com/twilio/inbound"
NUMBER_URL = "https://app.example.com/sms"


def test_service_url_is_ignored_when_the_service_defers_to_the_number():
    # The point of the note: inbound_request_url is set and it does not matter.
    state, detail = verdict(
        {"use_inbound_webhook_on_number": True, "inbound_request_url": SERVICE_URL},
        [{"phone_number": "+15550001111", "sms_url": ""}])
    assert state == "number-black-hole"
    assert "ignored" in detail


def test_false_with_no_inbound_url_drops_the_whole_pool():
    state, detail = verdict(
        {"use_inbound_webhook_on_number": False, "inbound_request_url": None},
        [{"phone_number": "+15550001111", "sms_url": NUMBER_URL}])
    assert state == "service-black-hole"
    assert "all 1 pool number(s)" in detail


def test_centralised_routing_is_healthy_even_with_blank_number_urls():
    state, _ = verdict(
        {"use_inbound_webhook_on_number": False, "inbound_request_url": SERVICE_URL},
        [{"phone_number": "+15550001111", "sms_url": ""}])
    assert state == "centralised"


def test_one_bad_number_among_good_ones_is_still_reported():
    state, detail = verdict(
        {"use_inbound_webhook_on_number": True, "inbound_request_url": ""},
        [{"phone_number": "+15550001111", "sms_url": NUMBER_URL,
          "sms_fallback_url": NUMBER_URL},
         {"phone_number": "+15550002222", "sms_url": None}])
    assert state == "number-black-hole"
    assert "+15550002222" in detail


def test_missing_fallback_is_the_lesser_finding_not_the_black_hole():
    state, _ = verdict(
        {"use_inbound_webhook_on_number": True},
        [{"phone_number": "+15550001111", "sms_url": NUMBER_URL,
          "sms_fallback_url": ""}])
    assert state == "no-fallback"


def test_fully_wired_pool_is_routed():
    state, _ = verdict(
        {"use_inbound_webhook_on_number": True},
        [{"phone_number": "+15550001111", "sms_url": NUMBER_URL,
          "sms_fallback_url": NUMBER_URL}])
    assert state == "routed"


def test_empty_pool_is_not_reported_as_routed():
    state, _ = verdict({"use_inbound_webhook_on_number": True}, [])
    assert state == "empty-pool"
twilio-inbound-route-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './twilio-inbound-route-audit.mjs';

const SERVICE_URL = 'https://app.example.com/twilio/inbound';
const NUMBER_URL = 'https://app.example.com/sms';

test('service url is ignored when the service defers to the number', () => {
  const [state, detail] = verdict(
    { use_inbound_webhook_on_number: true, inbound_request_url: SERVICE_URL },
    [{ phone_number: '+15550001111', sms_url: '' }]);
  assert.equal(state, 'number-black-hole');
  assert.match(detail, /ignored/);
});

test('false with no inbound url drops the whole pool', () => {
  const [state, detail] = verdict(
    { use_inbound_webhook_on_number: false, inbound_request_url: null },
    [{ phone_number: '+15550001111', sms_url: NUMBER_URL }]);
  assert.equal(state, 'service-black-hole');
  assert.match(detail, /all 1 pool number\(s\)/);
});

test('centralised routing is healthy even with blank number urls', () => {
  const [state] = verdict(
    { use_inbound_webhook_on_number: false, inbound_request_url: SERVICE_URL },
    [{ phone_number: '+15550001111', sms_url: '' }]);
  assert.equal(state, 'centralised');
});

test('one bad number among good ones is still reported', () => {
  const [state, detail] = verdict(
    { use_inbound_webhook_on_number: true, inbound_request_url: '' },
    [{ phone_number: '+15550001111', sms_url: NUMBER_URL, sms_fallback_url: NUMBER_URL },
     { phone_number: '+15550002222', sms_url: null }]);
  assert.equal(state, 'number-black-hole');
  assert.match(detail, /\+15550002222/);
});

test('missing fallback is the lesser finding, not the black hole', () => {
  const [state] = verdict(
    { use_inbound_webhook_on_number: true },
    [{ phone_number: '+15550001111', sms_url: NUMBER_URL, sms_fallback_url: '' }]);
  assert.equal(state, 'no-fallback');
});

test('fully wired pool is routed', () => {
  const [state] = verdict(
    { use_inbound_webhook_on_number: true },
    [{ phone_number: '+15550001111', sms_url: NUMBER_URL, sms_fallback_url: NUMBER_URL }]);
  assert.equal(state, 'routed');
});

test('empty pool is not reported as routed', () => {
  assert.equal(verdict({ use_inbound_webhook_on_number: true }, [])[0], 'empty-pool');
});

FAQ

What does use_inbound_webhook_on_number actually change?

Which URL Twilio calls for an inbound message. True means the number's sms_url handles it and the service's inbound_request_url is ignored. False means the service's URL handles everything in the pool and the numbers' own sms_url values are ignored. It is a switch between two routes, not a fallback chain.

Why is there nothing in the Debugger?

Because no HTTP request failed. With no handler URL there is nothing for Twilio to call, so there is no 11200 and no alert. The inbound message still appears in the Messages list with direction inbound, which is the only trace that it existed at all.

How does this end up as a compliance problem?

STOP replies are inbound messages. If they never reach your application, your database never records the opt-out and your sends keep going. Twilio blocks them with 21610 so the recipient is protected, but your own records show consent you no longer have.

Should I centralise on the service or configure each number?

Either works; mixing them is what produces this. Centralising is one field for the whole pool and one place to change when the endpoint moves, which is why it survives a number being added later. Per-number routing is right when different numbers genuinely belong to different applications.

Why does the script join three API responses instead of one?

Because no single response contains the failure. The routing mode is on the Messaging Service, the pool membership is a subresource of it, and sms_url lives on the number in the 2010-04-01 account API. The bug is only visible where the three meet.

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.