Diagnostic Twilio
Verify runs with lookup_enabled false, so landlines are billed
Somebody switched on skip SMS to landlines months ago, the setting is still there in the console, and Verify has been sending SMS into landlines the entire time. The guard needs a Lookup on each verification start to know what a landline is, and Lookup is off — so the switch is set, saved, displayed back to you, and doing nothing at all.
Read GET https://verify.twilio.com/v2/Services and, per service, GET https://verify.twilio.com/v2/Services/{ServiceSid}. The finding is lookup_enabled == false, and the sharp version of it is skip_sms_to_landlines true while lookup_enabled is false — a guard that cannot run.
lookup_enabled is off when a service is created. With it off Verify never classifies the destination line type, so it sends the SMS, bills the attempt at the full verification price, and logs no 60205 for you to find later. The attempt simply stays pending until it expires.
The problem in plain words
This one costs money quietly rather than breaking anything loudly. A verification started against a landline is a verification Twilio accepted, priced and attempted. It comes back pending, it expires, and the user tries again on a different number or gives up. There is no failed send in the Messages list, no 60205 in the Debugger, and no alert — because from the platform's side nothing failed. Verify was asked to send an SMS and it sent one.
What makes it durable is that the visible setting is the wrong one. skip_sms_to_landlines is the switch with the obvious name, it is the one people turn on, and turning it on succeeds. The dependency runs the other way: the skip is implemented by the Lookup that lookup_enabled authorises. Set one without the other and you have bought a guard with nothing behind it, which is worse than having no guard, because now the question looks answered.
Why it happens
The default is off, and it is off per service. Every Verify Service you create arrives with lookup_enabled false. An account that has three services — production, staging, and the one made for a partner integration — has three separate copies of this setting and no reason for them to agree.
The two fields do not validate each other. Setting SkipSmsToLandlines=true on a service whose lookup_enabled is false returns 200 and stores the value. Nothing warns you, and reading the service back shows exactly what you set. The inconsistency is only visible if you know that one field is a prerequisite of the other.
The failure produces no error code. 60205 is what you get when Verify knows the destination is a landline and refuses. With Lookup off it never knows, so the code that would have named this problem for you is precisely the code that cannot be emitted.
A 4% shortfall reads as user behaviour. Conversion rates never hit 100%, so a permanent slice of unconverted attempts is easy to file under mistyped numbers and abandoned signups. It stays filed there until somebody asks which numbers those attempts went to.
The fix, as a flow
The script reads both booleans together, because the setting with the obvious name is the one that does nothing on its own: the landline skip is implemented by the Lookup that lookup_enabled authorises.
How to fix it
List every Verify Service, not just the one you think is live
GET https://verify.twilio.com/v2/Services, following meta.next_page_url. Services accumulate: one per environment, one per brand, one left over from an integration test. Each carries its own lookup_enabled, and the one that is misconfigured is rarely the one you would have checked by hand.
Read the two settings as a pair, never singly
lookup_enabled and skip_sms_to_landlines have four combinations and three of them are wrong in different ways. Reporting on either field alone gives you a green tick for the combination that is actively costing you money.
Name the no-op configuration explicitly
skip_sms_to_landlines true with lookup_enabled false deserves its own line in the report and its own wording. Anyone reading a list of services where this one says lookup off will assume the landline guard was never configured; the point is that it was, and it is not running.
Weigh it by traffic before you weigh it by badness
GET https://verify.twilio.com/v2/Attempts?DateCreatedAfter={ISO8601} returns attempts across the account with a service_sid on each. Counting per service turns a list of five misconfigured services into one that is billing you daily and four that are dormant.
Turn on Lookup first, then the skip, then re-run
POST https://verify.twilio.com/v2/Services/{ServiceSid} with LookupEnabled=true and SkipSmsToLandlines=true. Enabling Lookup adds a per-verification cost of its own, which is the honest trade and worth stating in the ticket: you pay for a Lookup on every start instead of paying for an SMS that could never arrive.
How to check it worked
Re-run the script. Every service that sends SMS should report guarded, and no service should report no-op-guard.
python3 twilio_verify_lookup_audit.py --check-traffic
# 3 service(s), 0 sending SMS without a line type check
The full code
One paginated GET for the services and, with --check-traffic, one more for recent attempts — an API Key with read access is enough and is what you should give it. The classifier is pure and takes both booleans together, because the whole content of this note is that reading either one alone gives you the wrong answer.
"""Report Verify Services that send SMS without a line type check.
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 datetime as dt
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_verify_lookup_audit")
VERIFY = "https://verify.twilio.com/v2"
def attempts_for(attempts, service_sid):
"""Count verification attempts belonging to one service.
Pure. The Attempts list is account-wide, so the per-service number that sets
urgency has to be produced here rather than asked for.
"""
return sum(1 for a in attempts if a.get("service_sid") == service_sid)
def verdict(service, attempts=None):
"""Classify one Verify Service's landline protection. Pure, so the rule can
be tested without a network.
`attempts` is the count of recent verification attempts on this service, or
None when traffic was not checked. Returns (state, detail).
"""
lookup = bool(service.get("lookup_enabled"))
skip = bool(service.get("skip_sms_to_landlines"))
if lookup and skip:
return ("guarded",
"lookup_enabled and skip_sms_to_landlines are both true: the "
"line type is checked and landlines are not sent to.")
if lookup and not skip:
return ("lookup-only",
"lookup_enabled is true but skip_sms_to_landlines is false: you "
"pay for a Lookup on every start and still send SMS to "
"landlines.")
if skip:
return ("no-op-guard",
"skip_sms_to_landlines is true while lookup_enabled is false. "
"The skip is implemented by that Lookup, so it never runs: this "
"service is configured to protect landlines and does not.")
busy = "" if attempts is None else " %d attempt(s) in the window." % attempts
if attempts:
return ("unguarded",
"lookup_enabled is false, so every attempt is sent blind and "
"billed in full; 60205 is never logged because the line type is "
"never read." + busy)
return ("unguarded-idle",
"lookup_enabled is false. No attempts seen in the window, so this "
"is a setting to fix before the service is used rather than a bill "
"to stop." + busy)
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_v2(session, url, key, limit=1000, **params):
"""Page a verify.twilio.com list. meta.next_page_url is absolute."""
out = []
params.setdefault("PageSize", 50)
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get(key, []))
url, params = (page.get("meta") or {}).get("next_page_url"), {}
return out[:limit]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=7,
help="window for the attempt count")
ap.add_argument("--check-traffic", action="store_true",
help="one extra paginated GET to weigh each service by use")
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_v2(session, VERIFY + "/Services", "services", args.max_services)
if not services:
log.info("no Verify Services on this account")
return 0
attempts = None
if args.check_traffic:
since = (dt.datetime.now(dt.timezone.utc)
- dt.timedelta(days=args.days)).strftime("%Y-%m-%dT%H:%M:%SZ")
attempts = list_v2(session, VERIFY + "/Attempts", "attempts", 5000,
DateCreatedAfter=since)
bad = 0
for svc in services:
seen = None if attempts is None else attempts_for(attempts, svc.get("sid"))
state, detail = verdict(svc, seen)
line = "%-15s %s %s" % (state, svc.get("friendly_name", svc.get("sid")), detail)
if state == "guarded":
log.info(line)
continue
bad += 1
log.warning(line)
log.warning(" repair: POST %s/Services/%s LookupEnabled=true "
"SkipSmsToLandlines=true", VERIFY, svc.get("sid"))
log.info("%d service(s), %d sending SMS without a line type check",
len(services), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Verify Services that send SMS without a line type check.
*
* 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 VERIFY = 'https://verify.twilio.com/v2';
/**
* Count verification attempts belonging to one service. Pure: the Attempts list
* is account-wide, so the per-service number has to be produced here.
*/
export function attemptsFor(attempts, serviceSid) {
return attempts.filter((a) => a.service_sid === serviceSid).length;
}
/**
* Classify one Verify Service's landline protection. Pure, so the rule can be
* tested without a network. `attempts` is null when traffic was not checked.
* Returns [state, detail].
*/
export function verdict(service, attempts = null) {
const lookup = Boolean(service.lookup_enabled);
const skip = Boolean(service.skip_sms_to_landlines);
if (lookup && skip) {
return ['guarded',
'lookup_enabled and skip_sms_to_landlines are both true: the line type ' +
'is checked and landlines are not sent to.'];
}
if (lookup && !skip) {
return ['lookup-only',
'lookup_enabled is true but skip_sms_to_landlines is false: you pay for ' +
'a Lookup on every start and still send SMS to landlines.'];
}
if (skip) {
return ['no-op-guard',
'skip_sms_to_landlines is true while lookup_enabled is false. The skip ' +
'is implemented by that Lookup, so it never runs: this service is ' +
'configured to protect landlines and does not.'];
}
const busy = attempts === null ? '' : ` ${attempts} attempt(s) in the window.`;
if (attempts) {
return ['unguarded',
'lookup_enabled is false, so every attempt is sent blind and billed in ' +
'full; 60205 is never logged because the line type is never read.' + busy];
}
return ['unguarded-idle',
'lookup_enabled is false. No attempts seen in the window, so this is a ' +
'setting to fix before the service is used rather than a bill to stop.' + busy];
}
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 listV2(auth, url, key, limit = 1000, params = {}) {
const out = [];
let next = url;
let query = { PageSize: 50, ...params };
while (next && out.length < limit) {
const page = await get(auth, next, query);
out.push(...(page[key] ?? []));
next = page.meta?.next_page_url ?? null;
query = {};
}
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 days = Number(process.env.DAYS ?? 7);
const checkTraffic = process.argv.includes('--check-traffic');
const services = await listV2(auth, `${VERIFY}/Services`, 'services');
if (services.length === 0) {
console.log('no Verify Services on this account');
return;
}
let attempts = null;
if (checkTraffic) {
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 19) + 'Z';
attempts = await listV2(auth, `${VERIFY}/Attempts`, 'attempts', 5000,
{ DateCreatedAfter: since });
}
let bad = 0;
for (const svc of services) {
const seen = attempts === null ? null : attemptsFor(attempts, svc.sid);
const [state, detail] = verdict(svc, seen);
const line = `${state.padEnd(15)} ${svc.friendly_name ?? svc.sid} ${detail}`;
if (state === 'guarded') { console.log(line); continue; }
bad += 1;
console.warn(line);
console.warn(` repair: POST ${VERIFY}/Services/${svc.sid} ` +
'LookupEnabled=true SkipSmsToLandlines=true');
}
console.log(`${services.length} service(s), ${bad} sending SMS without a line type check`);
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 case the whole note exists for is the third one: skip_sms_to_landlines true, lookup_enabled false. A classifier that checks the skip flag and stops reports that service as protected, which is the same conclusion the console invites you to draw. The other combination worth pinning is lookup on with the skip off, because it is not free — you are paying for a Lookup whose answer is then ignored.
from twilio_verify_lookup_audit import attempts_for, verdict
def test_both_settings_on_is_the_only_guarded_state():
state, detail = verdict({"lookup_enabled": True, "skip_sms_to_landlines": True})
assert state == "guarded"
assert "line type is checked" in detail
def test_skip_without_lookup_is_a_guard_that_never_runs():
# The reason this note exists: the visible setting is on and inert.
state, detail = verdict({"lookup_enabled": False, "skip_sms_to_landlines": True})
assert state == "no-op-guard"
assert "never runs" in detail
def test_lookup_without_skip_still_sends_to_landlines():
state, detail = verdict({"lookup_enabled": True, "skip_sms_to_landlines": False})
assert state == "lookup-only"
assert "pay for a Lookup" in detail
def test_both_off_with_traffic_is_the_billing_finding():
state, detail = verdict({"lookup_enabled": False}, 412)
assert state == "unguarded"
assert "412 attempt(s)" in detail
assert "60205" in detail
def test_both_off_with_no_traffic_is_separated_from_the_live_one():
state, detail = verdict({"lookup_enabled": False, "skip_sms_to_landlines": False}, 0)
assert state == "unguarded-idle"
assert "before the service is used" in detail
def test_missing_fields_are_read_as_the_defaults_they_are():
# A service resource with neither field set is a new service: both are off.
assert verdict({})[0] == "unguarded-idle"
def test_attempts_are_counted_per_service_from_an_account_wide_list():
attempts = [{"service_sid": "VA1"}, {"service_sid": "VA2"}, {"service_sid": "VA1"}]
assert attempts_for(attempts, "VA1") == 2
assert attempts_for(attempts, "VA3") == 0
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { attemptsFor, verdict } from './twilio-verify-lookup-audit.mjs';
test('both settings on is the only guarded state', () => {
const [state, detail] = verdict({ lookup_enabled: true, skip_sms_to_landlines: true });
assert.equal(state, 'guarded');
assert.match(detail, /line type is checked/);
});
test('skip without lookup is a guard that never runs', () => {
const [state, detail] = verdict({ lookup_enabled: false, skip_sms_to_landlines: true });
assert.equal(state, 'no-op-guard');
assert.match(detail, /never runs/);
});
test('lookup without skip still sends to landlines', () => {
const [state, detail] = verdict({ lookup_enabled: true, skip_sms_to_landlines: false });
assert.equal(state, 'lookup-only');
assert.match(detail, /pay for a Lookup/);
});
test('both off with traffic is the billing finding', () => {
const [state, detail] = verdict({ lookup_enabled: false }, 412);
assert.equal(state, 'unguarded');
assert.match(detail, /412 attempt\(s\)/);
assert.match(detail, /60205/);
});
test('both off with no traffic is separated from the live one', () => {
const [state, detail] = verdict({ lookup_enabled: false, skip_sms_to_landlines: false }, 0);
assert.equal(state, 'unguarded-idle');
assert.match(detail, /before the service is used/);
});
test('missing fields are read as the defaults they are', () => {
assert.equal(verdict({})[0], 'unguarded-idle');
});
test('attempts are counted per service from an account wide list', () => {
const attempts = [{ service_sid: 'VA1' }, { service_sid: 'VA2' }, { service_sid: 'VA1' }];
assert.equal(attemptsFor(attempts, 'VA1'), 2);
assert.equal(attemptsFor(attempts, 'VA3'), 0);
});
FAQ
Why does skip_sms_to_landlines need lookup_enabled?
Because the skip is implemented by the Lookup. Verify has no idea what kind of line a destination is until it performs a Lookup on the verification start, and lookup_enabled is the field that authorises that call. With it false there is no line type to compare against, so there is nothing for the skip to act on.
Shouldn't Twilio reject the inconsistent combination?
It does not. POSTing SkipSmsToLandlines=true to a service with lookup_enabled false returns 200 and stores the value, and reading the service back shows exactly what you set. That is why an audit that reads both fields together is the only way to see it.
Why is there no 60205 in the logs?
60205 is Verify refusing to send because it knows the destination is a landline. Knowing requires the Lookup. With Lookup off Verify never forms that opinion, so the one error code that would have named this problem is the code that cannot be emitted.
Does turning on Lookup cost more?
Yes, and it is worth saying so in the ticket rather than discovering it on the invoice. You pay for a Lookup on every verification start instead of paying for an SMS that could never be delivered, plus the support cost of the users who never got a code.
Why does the script count attempts per service?
Because the Attempts list is account-wide and the decision is per service. Five services with lookup off are not five equal problems: one of them is billing you today and the other four are dormant settings to correct before anything is routed through them.
Related field notes
- Verify sending SMS to a landline: 60205, or silence
- Sending SMS to landlines that can never receive it
- Verify conversion collapsing in one country
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.
- Verify Service resource — Twilio Docs
- Error 60205: SMS is not supported by landline phone number — Twilio Docs
- Lookup Line Type Intelligence — Twilio Docs
- API keys — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.