Diagnostic Twilio
Notify services still on the account after Notify's EOL
Push notifications stopped some time last winter. Nobody can put a date on it, because nothing recorded one. The Notify service is still in the API, the bindings are still listed, your code still gets a response it can parse, and the handset gets nothing. Twilio Notify reached end of life on 31 December 2025. The resource outlived the product by a comfortable margin, which is the only reason this is still in the codebase.
Read GET https://notify.twilio.com/v1/Services. A non-empty list is the finding on its own: Notify reached end of life on 2025-12-31, and anything still here is unsupported.
Then read GET https://notify.twilio.com/v1/Services/{ServiceSid}/Bindings per service to answer the question that decides what you do about it. Bindings still registered means real devices are pointed at a product that no longer delivers to them. No bindings means this is a deletion to schedule rather than an outage to explain.
The problem in plain words
This is the end-of-life failure in its completed form. Chat is a deadline; Notify is what the far side of one looks like. Delivery stopped, the REST surface did not, and the two facts are unrelated: the service resource still returns, still lists, still carries a friendly name, because retiring a product removes support and delivery and leaves the API shaped exactly as it was.
Push is the worst possible thing to lose quietly, because it is unobservable from your side even when it is working perfectly. There is no delivery receipt coming back from a handset, so a healthy push and a discarded one produce the same silence in your logs. The only monitoring most teams have for push is a user complaining, and users do not complain about a notification they never knew was coming. That is why this ran for months, and why the check has to be about registration rather than about failures: there are no failures to count.
Why it happens
End of life removes the product, not the resource. Nothing is deleted from your account on the date, no field flips, and no request starts returning an error that names the reason. The list endpoint answers today exactly as it did in 2019, which is precisely how a dead dependency stays in a codebase.
Nothing in the API says why the push stopped. There is no delivery status to poll and no error code attached to the silence. This is the documented symptom rather than a gap in your instrumentation, and it means no amount of log-searching on your side will ever produce the answer.
Bindings are the only measure of dependence available. A binding is a device that registered itself for push through this service. Counting them is how "we still have a Notify service" becomes "eleven thousand installs are registered against something that does not deliver", which is the sentence that gets the work scheduled.
The migration leaves Twilio entirely. Chat has a successor product one domain away. Notify does not: push goes back to FCM and APNs directly, or to Verify Push if what you were actually sending was authentication. There is no equivalent resource to create, so nobody can do this in an afternoon and everybody defers it.
The devices will not come back on their own. Every binding was created by an app registering a token. Removing the service does not re-register anything, so the migration has to ship in a client release and then wait for installs to pick it up, which is the part that takes months rather than the part that takes an engineer.
The fix, as a flow
The script counts bindings as well as services, because that is the difference between an outage nobody noticed and a deletion to schedule, and a sampled page answers it honestly as a floor.
How to fix it
List the Notify services
GET https://notify.twilio.com/v1/Services?PageSize=50, following meta.next_page_url. On this domain that is an absolute URL, not the path next_page_uri returns on the 2010-04-01 API. An empty list is the answer you want and ends the run.
Count what is still bound to each service
GET https://notify.twilio.com/v1/Services/{ServiceSid}/Bindings?PageSize=50. One page is enough to separate the two cases that matter: something is registered, or nothing is. Treat the number as a floor rather than a total, and say so in the report, because a sampled page is not a count.
Do not wait for an error to confirm it
There is no delivery status on this path and no error code for the silence. If you are looking for a failed request to prove the problem before acting, you will not find one, and the months you spend looking are months of notifications nobody receives.
Decide where push goes instead
Straight to FCM and APNs is the general answer, since that is what Notify was wrapping. If what you were sending was really authentication rather than messaging, Verify Push is the closer replacement and keeps the challenge flow on Twilio. Either way this ships in a client release, so start it before the cleanup rather than after.
Delete the service last, once nothing is registered
DELETE https://notify.twilio.com/v1/Services/{ServiceSid} once traffic is off it. Last, not first: while a service is still there, its bindings tell you how far the client rollout has got, and deleting it throws away the only progress meter you have.
How to check it worked
Re-run with bindings checked once the last service is removed. The state should be clear.
python3 twilio_notify_eol_audit.py --check-bindings
# clear no Notify services on this account.
The full code
One paginated GET over the services and, with --check-bindings, one more per service — read access is all it needs. The classifier takes the services and a mapping of what was found bound to each, and it deliberately keeps not checked as its own state rather than defaulting it to zero: an account reported as abandoned because nobody passed a flag is worse than one reported as unknown.
"""Report Twilio Notify services still held after Notify's end of life.
Notify reached end of life on 2025-12-31. Nothing was deleted on the date and
nothing started returning an error, so the only signal that this account still
depends on it is that the services are still here and devices are still bound
to them.
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
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_notify_eol_audit")
NOTIFY = "https://notify.twilio.com/v1"
# Twilio Notify reached end of life on this date. Remaining services are
# unsupported, and the resource still exists in the API long after delivery
# stopped.
EOL = datetime.date(2025, 12, 31)
def days_past_eol(today):
"""Days since Notify's end of life. Negative before it. Pure."""
return (today - EOL).days
def binding_count(bindings, sid):
"""How many bindings were seen for one service. Pure, and forgiving.
The value is whatever the caller counted on a sampled page, so it can arrive
as an int, as a string, or missing entirely for a service that errored. None
of those should raise in the middle of a report.
"""
try:
return max(0, int((bindings or {}).get(sid) or 0))
except (TypeError, ValueError):
return 0
def verdict(services, bindings=None):
"""Classify what this account still has bound to Notify. Pure, so the rules
can be tested without a network.
bindings is a mapping of service sid to how many bindings were seen, or None
when the bindings were not read at all. Not-checked stays its own state
rather than defaulting to zero: an account reported as abandoned because
nobody passed a flag is worse than one reported as unknown.
Returns (state, detail).
"""
found = list(services or [])
if not found:
return ("clear", "no Notify services on this account.")
if bindings is None:
return ("unchecked",
"%d Notify service(s) on an account, and Notify reached end of "
"life on %s. The bindings were not read, so how much still "
"depends on this is unknown." % (len(found), EOL.isoformat()))
total = sum(binding_count(bindings, s.get("sid")) for s in found)
if total:
return ("registered",
"%d Notify service(s) with at least %d binding(s) still "
"registered: those are devices pointed at a product that no "
"longer delivers, and every push aimed at them is discarded with "
"nothing on either side to show for it." % (len(found), total))
return ("abandoned",
"%d Notify service(s) with nothing bound to them: this is a deletion "
"to schedule rather than an outage to explain." % len(found))
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_all(session, url, key, limit=200):
"""Page a newer-domain list. meta.next_page_url is absolute here, unlike the
next_page_uri path the 2010-04-01 API returns."""
params = {"PageSize": 50}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get(key, []))
url = (page.get("meta") or {}).get("next_page_url")
params = {}
return out[:limit]
def sample_bindings(session, services, sample):
"""One page of bindings per service. A floor, not a total, and the report
says so rather than presenting a sampled page as a count."""
seen = {}
for service in services:
sid = service.get("sid")
page = get(session, "%s/Services/%s/Bindings" % (NOTIFY, sid), PageSize=sample)
seen[sid] = len(page.get("bindings", []))
return seen
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--check-bindings", action="store_true",
help="one extra GET per service to see what is still registered")
ap.add_argument("--sample", type=int, default=50,
help="how many bindings to read per service")
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_all(session, NOTIFY + "/Services", "services")
bindings = sample_bindings(session, services, args.sample) if (
services and args.check_bindings) else None
for service in services:
log.info(" %s %s bound=%s", service.get("sid", "?"),
service.get("friendly_name") or "(no name)",
binding_count(bindings, service.get("sid")) if bindings else "?")
state, detail = verdict(services, bindings)
if state == "clear":
log.info("%-14s %s", state, detail)
return 0
log.warning("%-14s %s", state, detail)
log.warning(" %d day(s) past end of life; nothing in this API reports why "
"the push stopped, so there is no failure to wait for",
days_past_eol(datetime.date.today()))
log.warning(" repair: move push to FCM and APNs directly, or to Verify Push "
"if what you were sending was authentication. That ships in a "
"client release, so start it before the cleanup")
log.warning(" then, once nothing is bound: DELETE %s/Services/{ServiceSid} "
"for each one", NOTIFY)
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Twilio Notify services still held after Notify's end of life.
*
* Nothing was deleted on the date and nothing started returning an error, so the
* only signal that this account still depends on Notify is that the services are
* still here and devices are still bound to them.
*
* 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 NOTIFY = 'https://notify.twilio.com/v1';
// Twilio Notify reached end of life on this date.
const EOL = Date.UTC(2025, 11, 31);
const EOL_TEXT = '2025-12-31';
const DAY = 86400000;
/** Days since Notify's end of life. Negative before it. Pure. */
export function daysPastEol(today) {
const utc = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
return Math.round((utc - EOL) / DAY);
}
/**
* How many bindings were seen for one service. Pure, and forgiving: the value
* can arrive as a number, as a string, or missing for a service that errored.
*/
export function bindingCount(bindings, sid) {
const raw = Number.parseInt((bindings ?? {})[sid] ?? 0, 10);
return Number.isFinite(raw) ? Math.max(0, raw) : 0;
}
/**
* Classify what this account still has bound to Notify. Pure.
*
* bindings is a mapping of service sid to how many bindings were seen, or null
* when they were not read at all. Not-checked stays its own state rather than
* defaulting to zero. Returns [state, detail].
*/
export function verdict(services, bindings = null) {
const found = [...(services ?? [])];
if (found.length === 0) return ['clear', 'no Notify services on this account.'];
if (bindings === null || bindings === undefined) {
return ['unchecked',
`${found.length} Notify service(s) on an account, and Notify reached end of ` +
`life on ${EOL_TEXT}. The bindings were not read, so how much still ` +
'depends on this is unknown.'];
}
const total = found.reduce((n, s) => n + bindingCount(bindings, s.sid), 0);
if (total) {
return ['registered',
`${found.length} Notify service(s) with at least ${total} binding(s) still ` +
'registered: those are devices pointed at a product that no longer ' +
'delivers, and every push aimed at them is discarded with nothing on ' +
'either side to show for it.'];
}
return ['abandoned',
`${found.length} Notify service(s) with nothing bound to them: this is a ` +
'deletion to schedule rather than an outage to explain.'];
}
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 listAll(auth, url, key, limit = 200) {
let next = url;
let params = { PageSize: 50 };
const out = [];
while (next && out.length < limit) {
const page = await get(auth, next, params);
out.push(...(page[key] ?? []));
next = page.meta?.next_page_url ?? 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 check = process.argv.includes('--check-bindings');
const services = await listAll(auth, `${NOTIFY}/Services`, 'services');
let bindings = null;
if (services.length && check) {
bindings = {};
for (const s of services) {
const page = await get(auth, `${NOTIFY}/Services/${s.sid}/Bindings`,
{ PageSize: 50 });
bindings[s.sid] = (page.bindings ?? []).length;
}
}
for (const s of services) {
console.log(` ${s.sid ?? '?'} ${s.friendly_name || '(no name)'} ` +
`bound=${bindings ? bindingCount(bindings, s.sid) : '?'}`);
}
const [state, detail] = verdict(services, bindings);
if (state === 'clear') {
console.log(`${state.padEnd(14)} ${detail}`);
return;
}
console.warn(`${state.padEnd(14)} ${detail}`);
console.warn(` ${daysPastEol(new Date())} day(s) past end of life; nothing in ` +
'this API reports why the push stopped, so there is no failure to ' +
'wait for');
console.warn(' repair: move push to FCM and APNs directly, or to Verify Push ' +
'if what you were sending was authentication. That ships in a ' +
'client release, so start it before the cleanup');
console.warn(` then, once nothing is bound: DELETE ${NOTIFY}/Services/{ServiceSid} ` +
'for each one');
process.exitCode = 1;
}
// 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 worth pinning hardest is the one where the bindings were never read. It is tempting to let a missing count fall through to zero and report the account as abandoned, and that produces a report saying the safe thing about an account with eleven thousand registered devices on it. Unknown has to survive as unknown all the way to the printed line, so it gets its own state and its own test.
import datetime
from twilio_notify_eol_audit import binding_count, days_past_eol, verdict
def service(sid="IS01", name="push"):
return {"sid": sid, "friendly_name": name}
def test_an_account_with_no_notify_services_is_clear():
state, detail = verdict([])
assert state == "clear"
assert "no Notify services" in detail
def test_services_with_unread_bindings_stay_unknown_rather_than_abandoned():
state, detail = verdict([service()])
assert state == "unchecked"
assert "not read" in detail
def test_bindings_still_registered_is_the_finding_that_gets_scheduled():
state, detail = verdict([service("IS01"), service("IS02")],
{"IS01": 11000, "IS02": 4})
assert state == "registered"
assert "at least 11004" in detail
def test_no_bindings_anywhere_is_cleanup_rather_than_an_outage():
state, detail = verdict([service()], {"IS01": 0})
assert state == "abandoned"
assert "deletion to schedule" in detail
def test_a_service_missing_from_the_bindings_map_counts_as_zero():
assert verdict([service("IS09")], {"IS01": 3})[0] == "abandoned"
def test_binding_count_takes_strings_and_refuses_to_raise_on_junk():
assert binding_count({"IS01": "12"}, "IS01") == 12
assert binding_count({"IS01": "many"}, "IS01") == 0
assert binding_count({"IS01": -4}, "IS01") == 0
assert binding_count(None, "IS01") == 0
def test_days_past_eol_counts_forward_from_the_end_of_life_date():
assert days_past_eol(datetime.date(2026, 1, 31)) == 31
assert days_past_eol(datetime.date(2025, 12, 1)) == -30
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { bindingCount, daysPastEol, verdict } from './twilio-notify-eol-audit.mjs';
const service = (sid = 'IS01', name = 'push') => ({ sid, friendly_name: name });
const day = (y, m, d) => new Date(Date.UTC(y, m - 1, d));
test('an account with no notify services is clear', () => {
const [state, detail] = verdict([]);
assert.equal(state, 'clear');
assert.match(detail, /no Notify services/);
});
test('services with unread bindings stay unknown rather than abandoned', () => {
const [state, detail] = verdict([service()]);
assert.equal(state, 'unchecked');
assert.match(detail, /not read/);
});
test('bindings still registered is the finding that gets scheduled', () => {
const [state, detail] = verdict([service('IS01'), service('IS02')],
{ IS01: 11000, IS02: 4 });
assert.equal(state, 'registered');
assert.match(detail, /at least 11004/);
});
test('no bindings anywhere is cleanup rather than an outage', () => {
const [state, detail] = verdict([service()], { IS01: 0 });
assert.equal(state, 'abandoned');
assert.match(detail, /deletion to schedule/);
});
test('a service missing from the bindings map counts as zero', () => {
assert.equal(verdict([service('IS09')], { IS01: 3 })[0], 'abandoned');
});
test('bindingCount takes strings and refuses to throw on junk', () => {
assert.equal(bindingCount({ IS01: '12' }, 'IS01'), 12);
assert.equal(bindingCount({ IS01: 'many' }, 'IS01'), 0);
assert.equal(bindingCount({ IS01: -4 }, 'IS01'), 0);
assert.equal(bindingCount(null, 'IS01'), 0);
});
test('daysPastEol counts forward from the end of life date', () => {
assert.equal(daysPastEol(day(2026, 1, 31)), 31);
assert.equal(daysPastEol(day(2025, 12, 1)), -30);
});
FAQ
Why is there nothing in the API explaining why push stopped?
Because the product was retired rather than broken. There is no delivery status on this path and no error code attached to the silence, which is the documented symptom rather than a hole in your instrumentation. No amount of searching your own logs will produce a reason, so waiting for one is waiting for something that does not exist.
Can I keep using Notify if it still returns 200?
The REST surface answering is not the same as notifications arriving, and this note exists because those two are easy to confuse. End of life removes support and delivery; it leaves the resource shaped as it was. An unsupported dependency that has already stopped doing its job is not a thing to keep using, it is a thing to remove once the clients have moved.
What replaces it?
Nothing on Twilio, for the general case. Notify was a wrapper over FCM and APNs, so the migration is to call those directly with the tokens your app already collects. The exception is authentication: if the push you were sending was a login approval or a second factor, Verify Push is the closer replacement and keeps that flow on the platform.
Why count bindings instead of just deleting the services?
Because the binding count is your only progress meter, and deleting the service throws it away. The migration ships in a client release and then waits on installs updating, which takes months. While the service is still there, the number of bindings tells you how far that rollout has actually got. Delete it once that number stops mattering.
The count came back as fifty for every service. Is that real?
No, and the report says at least rather than exactly for that reason. One page of bindings is a sample sized by whatever you passed for the page size, so a service with fifty on the first page has fifty or more. The distinction the script cares about is between nothing and something, and a sampled page answers that honestly while a total would need every page.
Related field notes
- The other dead product, still one deadline behind
- Resources kept long after they stopped being used
- Credentials that outlived whoever created them
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.
- Notify Service resource — Twilio Docs
- Verify Push — Twilio Docs
- Manage unused resources — 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.