Skip to content

Diagnostic Twilio

Verify sends SMS to a landline: 60205, or just silence

A small, stubborn fraction of your signups never complete. They are not bots, they do not retry three times and give up, and support cannot reproduce any of it. The numbers look fine: right length, right country, valid E.164. They are landlines. An SMS to a landline either comes back 60205 or, if Lookup is off, disappears into a verification that stays pending until it expires — billed, delivered nowhere.

Read-only key Python and Node.js Tests included
White box on white table
Photo by Kelli McClintock on Unsplash
The short answer

Pre-flight the destination with GET https://lookups.twilio.com/v2/PhoneNumbers/{E164}?Fields=line_type_intelligence and read line_type_intelligence.type. landline, fixedVoip, pager, voicemail and unknown are the types that cannot be relied on to receive an SMS.

Retrospectively, sweep GET https://verify.twilio.com/v2/Attempts?Status=unconverted&DateCreatedAfter={ISO8601} and bucket channel_data.to by line type. Then check the Service itself: skip_sms_to_landlines only works when lookup_enabled is true, so the pair set the wrong way round is a setting that does nothing.

The problem in plain words

Signup forms accept digits. A user typing their desk number, a small business entering its main line, a form that helpfully offers the phone number already on file from an older record — all three produce a valid E.164 string pointed at something that has no SMS inbox. Verify does not refuse it by default. It sends, it is billed, and the message stops at a carrier that has nowhere to put it.

The two shapes this takes are both bad, in different ways. With Lookup enabled on the Service you get a 403 and 60205, which at least appears in your logs. With Lookup disabled — the default — Verify cannot classify the line at all, sends anyway, and leaves you a verification that sits pending until it expires. The second one costs the same and tells you nothing.

What makes it durable is the size. This is never everybody. It is two or three percent of signups, sitting inside the same bucket as typos and abandoned forms, and no funnel dashboard has a line item for users whose phone cannot receive text messages. It has to be looked for deliberately, using the line type as the key.

Form takes anydigitsvalid E.164, deskphoneLookup disabledline type nevercheckedSMS sent anywayattempt is billedNo SMS inboxexistscarrier drops itPending untilexpirycounted asabandoned
With lookup off there is no 60205 to find this by. The verification is simply never converted, alongside every other abandoned signup.

Why it happens

A landline is not a delivery failure, it is a category error. No amount of retrying, no carrier escalation and no change of sender will make an SMS arrive at a number with no SMS capability. The only fixes are upstream: do not send, or send to a different channel.

Lookup is off by default, and the landline guard depends on it. skip_sms_to_landlines can only work if Verify performs a Lookup at start time, which is what lookup_enabled controls. Setting the skip while leaving lookup off produces a configuration that reads as protected and enforces nothing.

Silence is the worse outcome and it is the default one. Without lookup there is no 60205 to grep for; there is just an unconverted attempt among all your other unconverted attempts. That is why the retrospective sweep buckets by line type rather than by error code.

fixedVoip is the interesting middle. Some fixed VoIP numbers receive SMS and some do not, depending entirely on the provider. Treating them as landlines rejects real users; treating them as mobiles produces intermittent, unreproducible failures. They deserve their own state and a voice fallback rather than a rejection.

Line type is a billed lookup, so where you spend it matters. One lookup at signup, cached against the user, is cheap and prevents the whole class. One lookup per failed verification, forever, is a running cost with no ceiling. The audit samples; the signup form is where the check belongs permanently.

The fix, as a flow

The script reads lookup_enabled and skip_sms_to_landlines as a pair, because the skip depends on the lookup: set one without the other and the setting reads as protection while enforcing nothing.

Lookup line_type_intelligenceplus the Service's two settingsmobilesend the SMSfixedVoip or unknownoffer a voice calllandline, pager, voicemailno SMS inbox existsskip on, lookup offa setting that does nothing
Fixed VoIP is the case that needs its own answer: rejecting it loses real users, accepting it produces failures nobody can reproduce.

How to fix it

Read the Service's two settings together

GET https://verify.twilio.com/v2/Services/{ServiceSid} and read lookup_enabled and skip_sms_to_landlines as a pair. Skip true with lookup false is the no-op combination, and it is the one that convinces a team it is already handled.

Sweep the unconverted attempts

GET https://verify.twilio.com/v2/Attempts?Status=unconverted&DateCreatedAfter={ISO8601}, following meta.next_page_url. These are the verifications nobody completed. Take channel_data.to from each and deduplicate: the same number appearing four times is one user trying four times, not four findings.

Classify each distinct number by line type

GET https://lookups.twilio.com/v2/PhoneNumbers/{E164}?Fields=line_type_intelligence. Cap the number of lookups: the field is billed, and a sample is enough to tell you whether landlines are one percent of your unconverted attempts or forty.

Separate cannot from might-not

landline, pager and voicemail cannot receive an SMS at all. fixedVoip and unknown might, depending on the provider. The first group is a hard finding; the second is the group to route through a voice call rather than reject.

Fix it in two places, then re-run

On the Service, enable LookupEnabled and SkipSmsToLandlines so Verify stops paying to text landlines. On the signup form, check the line type before you accept the number and offer Channel=call when it is not a mobile. Re-run the sweep; the landline share of unconverted attempts should fall to roughly nothing.

How to check it worked

Re-run the script. The Service should report both settings on, and no unconverted attempt should be sitting on a landline.

python3 twilio_verify_landline_audit.py --service VA00000000000000000000000000000000
# service guard: guarded
# 60 number(s) sampled, 0 that cannot receive SMS

The full code

Two pure functions carry the diagnosis and both are tested offline: one turns a Lookup response into a verdict for the channel you are actually sending on, and one reads the Service's two settings as the pair they really are. Around them the script does GETs only — the Service, a page of unconverted attempts, and a capped number of billed lookups — and prints the repair rather than applying it.

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_verify_landline_audit.py
"""Find Verify traffic aimed at numbers that cannot receive an SMS.

A landline destination is not a delivery failure, it is a category error: it
returns 60205 when Lookup is on, and silently expires as an unconverted
verification when Lookup is off, which is the default.

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

VERIFY = "https://verify.twilio.com/v2"
LOOKUPS = "https://lookups.twilio.com/v2"

# No SMS inbox exists behind these, whatever the carrier or the sender.
NO_SMS = {"landline", "pager", "voicemail"}

# These may or may not receive an SMS depending on the provider, which is worse
# than a clean no: the failures are intermittent and unreproducible.
UNRELIABLE = {"fixedvoip", "uan", "unknown"}


def line_type(lookup):
    """Lowercased line_type_intelligence.type, or None if the field is absent.

    The API returns camelCase values such as fixedVoip; lowercasing once here
    keeps every comparison below in one case.
    """
    lti = (lookup or {}).get("line_type_intelligence") or {}
    t = lti.get("type")
    return str(t).strip().lower() if t else None


def verdict(lookup, channel="sms"):
    """Classify one Lookup response for the channel you intend to use.

    Pure, so the rules can be tested without a network. Returns (state, detail).
    """
    if lookup is not None and lookup.get("valid") is False:
        return ("invalid",
                "Lookup says the number is not valid: it will fail on any channel")

    t = line_type(lookup)
    if t is None:
        return ("no-line-type",
                "no line_type_intelligence on the response. Either the field was "
                "not requested (Fields=line_type_intelligence) or the account is "
                "not entitled to it: do not read this as a mobile.")

    if t in NO_SMS:
        if channel == "call":
            return ("voice-ok",
                    "%s, and this verification is on the call channel: a voice "
                    "code reaches it fine" % t)
        return ("no-sms",
                "%s: there is no SMS inbox behind this number. Verify returns "
                "60205 when lookup_enabled is true, and bills a verification "
                "that expires unconverted when it is false." % t)

    if t in UNRELIABLE:
        return ("unreliable",
                "%s: SMS delivery depends entirely on the provider, so these "
                "fail intermittently and never reproduce. Offer a voice call "
                "rather than rejecting the number." % t)

    return ("mobile", "%s: can receive SMS" % t)


def guard_state(service):
    """Read lookup_enabled and skip_sms_to_landlines as the pair they are.

    Pure. The no-op combination -- skip on, lookup off -- is the one that
    convinces a team the problem is already handled.
    """
    lookup_on = bool((service or {}).get("lookup_enabled"))
    skip_on = bool((service or {}).get("skip_sms_to_landlines"))

    if skip_on and not lookup_on:
        return ("no-op",
                "skip_sms_to_landlines is true but lookup_enabled is false. The "
                "skip needs the Lookup to classify the line, so this setting "
                "does nothing at all.")
    if not lookup_on:
        return ("unguarded",
                "lookup_enabled is false: Verify cannot classify the line type, "
                "so landlines are sent to and billed in silence.")
    if not skip_on:
        return ("lookup-only",
                "lookup_enabled is true but skip_sms_to_landlines is false: you "
                "get 60205 in the logs instead of a skipped send.")
    return ("guarded", "lookup_enabled and skip_sms_to_landlines are both on")


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 unconverted(session, service, since, limit=1000):
    url = VERIFY + "/Attempts"
    params = {"VerifyServiceSid": service, "Status": "unconverted",
              "DateCreatedAfter": since, "PageSize": 100}
    out = []
    while url and len(out) < limit:
        page = get(session, url, **params)
        out.extend(page.get("attempts", []))
        url, params = (page.get("meta") or {}).get("next_page_url"), {}
    return out[:limit]


def distinct_destinations(attempts):
    """One entry per number, with the channel it was tried on. The same number
    four times is one user trying four times, not four findings.
    """
    seen = {}
    for a in attempts:
        data = a.get("channel_data") or {}
        to = data.get("to")
        if to and to not in seen:
            seen[to] = (a.get("channel") or "sms").lower()
    return list(seen.items())


def line_type_lookup(session, e164):
    return get(session, "%s/PhoneNumbers/%s" % (LOOKUPS, e164),
               Fields="line_type_intelligence")


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--service", required=True, help="Verify Service SID (VA...)")
    ap.add_argument("--days", type=int, default=7, help="window to sweep")
    ap.add_argument("--max-lookups", type=int, default=60,
                    help="cap on billed Lookup calls")
    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)

    service = get(session, "%s/Services/%s" % (VERIFY, args.service))
    gstate, gdetail = guard_state(service)
    log.info("service guard: %s  %s", gstate, gdetail)
    if gstate != "guarded":
        log.warning("  repair: set LookupEnabled=true and SkipSmsToLandlines=true "
                    "on %s/Services/%s", VERIFY, args.service)

    since = (dt.datetime.now(dt.timezone.utc)
             - dt.timedelta(days=args.days)).strftime("%Y-%m-%dT%H:%M:%SZ")
    numbers = distinct_destinations(unconverted(session, args.service, since))
    if not numbers:
        log.info("no unconverted attempts in the last %d day(s)", args.days)
        return 1 if gstate != "guarded" else 0

    bad = 0
    for e164, channel in numbers[:args.max_lookups]:
        state, detail = verdict(line_type_lookup(session, e164), channel)
        line = "%-13s %s  %s" % (state, e164, detail)
        if state in ("no-sms", "invalid"):
            bad += 1
            log.warning(line)
        elif state in ("unreliable", "no-line-type"):
            log.warning(line)
        else:
            log.info(line)

    if bad:
        log.warning("  repair: gate signup on line_type_intelligence.type == "
                    "\"mobile\" and start these verifications with Channel=call "
                    "instead")

    log.info("%d number(s) sampled, %d that cannot receive SMS",
             min(len(numbers), args.max_lookups), bad)
    return 1 if (bad or gstate != "guarded") else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-verify-landline-audit.mjs
/**
 * Find Verify traffic aimed at numbers that cannot receive an SMS.
 *
 * A landline destination is not a delivery failure, it is a category error: it
 * returns 60205 when Lookup is on, and silently expires as an unconverted
 * verification when Lookup is off, which is the default.
 *
 * 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';
const LOOKUPS = 'https://lookups.twilio.com/v2';

// No SMS inbox exists behind these, whatever the carrier or the sender.
const NO_SMS = new Set(['landline', 'pager', 'voicemail']);

// These may or may not receive an SMS depending on the provider, which is worse
// than a clean no: the failures are intermittent and unreproducible.
const UNRELIABLE = new Set(['fixedvoip', 'uan', 'unknown']);

/**
 * Lowercased line_type_intelligence.type, or null if the field is absent. The
 * API returns camelCase values such as fixedVoip; lowercasing once here keeps
 * every comparison below in one case.
 */
export function lineType(lookup) {
  const t = lookup?.line_type_intelligence?.type;
  return t ? String(t).trim().toLowerCase() : null;
}

/**
 * Classify one Lookup response for the channel you intend to use. Pure, so the
 * rules can be tested without a network. Returns [state, detail].
 */
export function verdict(lookup, channel = 'sms') {
  if (lookup && lookup.valid === false) {
    return ['invalid',
      'Lookup says the number is not valid: it will fail on any channel'];
  }

  const t = lineType(lookup);
  if (t === null) {
    return ['no-line-type',
      'no line_type_intelligence on the response. Either the field was not ' +
      'requested (Fields=line_type_intelligence) or the account is not ' +
      'entitled to it: do not read this as a mobile.'];
  }

  if (NO_SMS.has(t)) {
    if (channel === 'call') {
      return ['voice-ok',
        `${t}, and this verification is on the call channel: a voice code ` +
        'reaches it fine'];
    }
    return ['no-sms',
      `${t}: there is no SMS inbox behind this number. Verify returns 60205 ` +
      'when lookup_enabled is true, and bills a verification that expires ' +
      'unconverted when it is false.'];
  }

  if (UNRELIABLE.has(t)) {
    return ['unreliable',
      `${t}: SMS delivery depends entirely on the provider, so these fail ` +
      'intermittently and never reproduce. Offer a voice call rather than ' +
      'rejecting the number.'];
  }

  return ['mobile', `${t}: can receive SMS`];
}

/**
 * Read lookup_enabled and skip_sms_to_landlines as the pair they are. Pure. The
 * no-op combination -- skip on, lookup off -- is the one that convinces a team
 * the problem is already handled. Returns [state, detail].
 */
export function guardState(service) {
  const lookupOn = Boolean(service?.lookup_enabled);
  const skipOn = Boolean(service?.skip_sms_to_landlines);

  if (skipOn && !lookupOn) {
    return ['no-op',
      'skip_sms_to_landlines is true but lookup_enabled is false. The skip ' +
      'needs the Lookup to classify the line, so this setting does nothing at all.'];
  }
  if (!lookupOn) {
    return ['unguarded',
      'lookup_enabled is false: Verify cannot classify the line type, so ' +
      'landlines are sent to and billed in silence.'];
  }
  if (!skipOn) {
    return ['lookup-only',
      'lookup_enabled is true but skip_sms_to_landlines is false: you get ' +
      '60205 in the logs instead of a skipped send.'];
  }
  return ['guarded', 'lookup_enabled and skip_sms_to_landlines are both on'];
}

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

async function unconverted(auth, service, since, limit = 1000) {
  let url = `${VERIFY}/Attempts`;
  let params = { VerifyServiceSid: service, Status: 'unconverted',
                 DateCreatedAfter: since, PageSize: 100 };
  const out = [];
  while (url && out.length < limit) {
    const page = await get(auth, url, params);
    out.push(...(page.attempts ?? []));
    url = page.meta?.next_page_url ?? null;
    params = {};
  }
  return out.slice(0, limit);
}

/** One entry per number: the same number four times is one user, not four findings. */
export function distinctDestinations(attempts) {
  const seen = new Map();
  for (const a of attempts) {
    const to = a.channel_data?.to;
    if (to && !seen.has(to)) seen.set(to, String(a.channel ?? 'sms').toLowerCase());
  }
  return [...seen.entries()];
}

function arg(name, fallback) {
  const i = process.argv.indexOf(name);
  return i === -1 ? fallback : process.argv[i + 1];
}

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 serviceSid = arg('--service');
  if (!serviceSid) {
    console.error('pass --service VA...');
    process.exitCode = 2;
    return;
  }
  const auth = authHeader(key, secret);
  const days = Number(arg('--days', '7'));
  const maxLookups = Number(arg('--max-lookups', '60'));

  const service = await get(auth, `${VERIFY}/Services/${serviceSid}`);
  const [gstate, gdetail] = guardState(service);
  console.log(`service guard: ${gstate}  ${gdetail}`);
  if (gstate !== 'guarded') {
    console.warn('  repair: set LookupEnabled=true and SkipSmsToLandlines=true ' +
                 `on ${VERIFY}/Services/${serviceSid}`);
  }

  const since = new Date(Date.now() - days * 86400000).toISOString()
    .replace(/\.\d+Z$/, 'Z');
  const numbers = distinctDestinations(await unconverted(auth, serviceSid, since));
  if (numbers.length === 0) {
    console.log(`no unconverted attempts in the last ${days} day(s)`);
    process.exitCode = gstate === 'guarded' ? 0 : 1;
    return;
  }

  let bad = 0;
  for (const [e164, channel] of numbers.slice(0, maxLookups)) {
    const lookup = await get(auth, `${LOOKUPS}/PhoneNumbers/${e164}`,
                             { Fields: 'line_type_intelligence' });
    const [state, detail] = verdict(lookup, channel);
    const line = `${state.padEnd(13)} ${e164}  ${detail}`;
    if (state === 'no-sms' || state === 'invalid') { bad += 1; console.warn(line); }
    else if (state === 'unreliable' || state === 'no-line-type') console.warn(line);
    else console.log(line);
  }

  if (bad) {
    console.warn('  repair: gate signup on line_type_intelligence.type === ' +
                 '"mobile" and start these verifications with Channel=call instead');
  }

  console.log(`${Math.min(numbers.length, maxLookups)} number(s) sampled, ` +
              `${bad} that cannot receive SMS`);
  process.exitCode = (bad || gstate !== 'guarded') ? 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

Four things have to hold: a landline is a finding on SMS and not on a voice call, fixedVoip is neither a pass nor a rejection, a response with no line type is never treated as a mobile, and the Service's two settings are read together so the no-op combination is named as one. That last case is the one worth the test — it is the configuration that looks correct in the Console.

test_twilio_verify_landline_audit.py
from twilio_verify_landline_audit import guard_state, line_type, verdict


def test_landline_on_the_sms_channel_is_the_finding():
    state, detail = verdict({"line_type_intelligence": {"type": "landline"}})
    assert state == "no-sms"
    assert "60205" in detail


def test_the_same_landline_on_a_voice_verification_is_fine():
    state, _ = verdict({"line_type_intelligence": {"type": "landline"}},
                       channel="call")
    assert state == "voice-ok"


def test_fixed_voip_is_neither_a_pass_nor_a_rejection():
    # Camel case from the API, matched case-insensitively.
    state, detail = verdict({"line_type_intelligence": {"type": "fixedVoip"}})
    assert state == "unreliable"
    assert "voice call" in detail


def test_a_response_with_no_line_type_is_not_a_mobile():
    state, detail = verdict({"valid": True})
    assert state == "no-line-type"
    assert "Fields=line_type_intelligence" in detail


def test_mobile_passes():
    assert verdict({"line_type_intelligence": {"type": "mobile"}})[0] == "mobile"
    assert line_type({"line_type_intelligence": {"type": "  Mobile "}}) == "mobile"


def test_skip_without_lookup_is_a_setting_that_does_nothing():
    state, detail = guard_state({"lookup_enabled": False,
                                 "skip_sms_to_landlines": True})
    assert state == "no-op"
    assert "does nothing" in detail


def test_both_settings_on_is_the_only_guarded_state():
    assert guard_state({"lookup_enabled": True,
                        "skip_sms_to_landlines": True})[0] == "guarded"
    assert guard_state({"lookup_enabled": True,
                        "skip_sms_to_landlines": False})[0] == "lookup-only"
    assert guard_state({})[0] == "unguarded"
twilio-verify-landline-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { guardState, lineType, verdict } from './twilio-verify-landline-audit.mjs';

test('landline on the sms channel is the finding', () => {
  const [state, detail] = verdict({ line_type_intelligence: { type: 'landline' } });
  assert.equal(state, 'no-sms');
  assert.match(detail, /60205/);
});

test('the same landline on a voice verification is fine', () => {
  assert.equal(
    verdict({ line_type_intelligence: { type: 'landline' } }, 'call')[0],
    'voice-ok');
});

test('fixed voip is neither a pass nor a rejection', () => {
  const [state, detail] = verdict({ line_type_intelligence: { type: 'fixedVoip' } });
  assert.equal(state, 'unreliable');
  assert.match(detail, /voice call/);
});

test('a response with no line type is not a mobile', () => {
  const [state, detail] = verdict({ valid: true });
  assert.equal(state, 'no-line-type');
  assert.match(detail, /Fields=line_type_intelligence/);
});

test('mobile passes', () => {
  assert.equal(verdict({ line_type_intelligence: { type: 'mobile' } })[0], 'mobile');
  assert.equal(lineType({ line_type_intelligence: { type: '  Mobile ' } }), 'mobile');
});

test('skip without lookup is a setting that does nothing', () => {
  const [state, detail] = guardState({ lookup_enabled: false,
                                       skip_sms_to_landlines: true });
  assert.equal(state, 'no-op');
  assert.match(detail, /does nothing/);
});

test('both settings on is the only guarded state', () => {
  assert.equal(guardState({ lookup_enabled: true,
                            skip_sms_to_landlines: true })[0], 'guarded');
  assert.equal(guardState({ lookup_enabled: true,
                            skip_sms_to_landlines: false })[0], 'lookup-only');
  assert.equal(guardState({})[0], 'unguarded');
});

FAQ

Why would a verification just stay pending instead of erroring?

Because with lookup_enabled false, Verify never classifies the destination. It hands the message to the carrier, the carrier has nowhere to deliver it, and the verification sits pending until its ten-minute TTL expires. You are billed for the attempt and there is no error code anywhere to find it by.

Is skip_sms_to_landlines enough on its own?

No, and this is the trap. The skip depends on Verify performing a Lookup at start time, which is what lookup_enabled controls. Set the skip while lookup is off and you have a setting that reads as protection in the Console and does nothing at all in production.

What about fixedVoip numbers? Plenty of my users have them.

Some receive SMS and some do not, entirely depending on the provider, so blanket-rejecting them loses real users and blanket-accepting them produces intermittent failures nobody can reproduce. Treat them as their own case: try SMS, and offer a voice call as the fallback rather than an error.

Should I check the line type at signup or after the failure?

At signup. One billed lookup, cached against the user, prevents the whole class before any money is spent on a message. The retrospective sweep in this note exists to tell you how large the problem already is, not to be the permanent control.

Can these users verify at all?

Yes, over the voice channel. Verify will call the number and read the code out, which works on a landline exactly as it does on a mobile. Routing non-mobile numbers to Channel=call turns a silent signup failure into a slightly different signup flow.

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.