Diagnostic Twilio
Programmable Chat is still in use past its end of life
The Chat SDK still works. Channels list, messages send, the integration tests are green, and the changelog entry announcing the end of life went to a billing address that forwards to a team alias nobody reads. Nothing failed on 1 June 2026 and nothing will fail on any particular morning after it. The account simply has three IS services on it that Twilio is no longer developing, and an iOS build from last year still talking to them.
Read GET https://chat.twilio.com/v2/Services. A non-empty list is the whole detection: the account still holds Programmable Chat services, which Twilio superseded with Conversations and which reach end of life on 2026-06-01 for Flex.
Then read GET https://conversations.twilio.com/v1/Services in the same run. Chat services and no Conversations services means nothing has been moved. Both present means somebody started and these are the remainder, which is the state most likely to be recorded internally as done.
The problem in plain words
A deprecation is an announcement, not a behaviour change. On the day it takes effect the API returns the same 200, the SDK builds, the messages deliver, and every check you have keeps passing. There is no deprecated field on the resource, no Sunset header on the response, nothing in the Debugger, and no error code — because from the platform's point of view nothing has gone wrong yet. It has only stopped being maintained.
That leaves exactly one signal available to you: you are still calling it. Everything else about this problem, including when it eventually breaks and how badly, is unknowable from the API. So the check cannot be "has something failed" and has to be "is this account still depending on it, and how much" — because the answer to the second question is what decides whether the migration is a quarter of planning or a Tuesday afternoon.
Why it happens
Nothing marks a resource as end-of-life. A Chat Service resource fetched today is byte-for-byte the shape it was three years ago. Deprecation lives in a changelog and a support email, both of which are outside the surface any monitoring you own can read.
There is no automated migration, and there cannot be. Chat and Conversations have different models — channels against conversations, and Conversations reaches out over SMS and WhatsApp as well as chat. That is not a schema translation, it is a product decision per channel, which is why nobody can run it for you and why it is easy to defer.
date_updated is staleness, never traffic. It moves when somebody edits the service, not when a message goes through it. A service carrying your whole support queue and a service nobody has opened since 2021 are indistinguishable in the list response, so the script reports that field as an upper bound on staleness and refuses to call it usage.
Half-migrated is the most dangerous state and the most reassuring one. An account with both Chat and Conversations services has a migration in its history, a ticket marked resolved, and some number of clients still pointed at the old product. Nobody re-opens a closed migration without a reason, and there will be no error to provide one.
The real dependency is in clients you cannot see. The service resource is the account-side shadow of an SDK compiled into a mobile app that shipped to users who will not update it. The API tells you the service exists; only your own release history tells you who is still calling it.
The fix, as a flow
The script reads the Conversations services in the same run, because the same three Chat services mean one thing on an account that has never created a Conversations service and another on one that has twelve.
How to fix it
List the Chat services
GET https://chat.twilio.com/v2/Services?PageSize=50, following meta.next_page_url, which on this domain is an absolute URL rather than the path the 2010-04-01 API returns. An empty list ends the audit with the answer you want.
Read the Conversations services in the same run
GET https://conversations.twilio.com/v1/Services. This is what turns a count into a state: no Conversations services at all means the migration has not started, and both products present means it started and stopped somewhere in the middle.
Age the services, and be honest about what that measures
date_updated on these newer domains is ISO 8601 (2024-03-11T09:12:00Z), not the RFC 2822 the account API returns. It tells you when the service was last configured, which sorts an abandoned service from one somebody was recently touching, and tells you nothing whatsoever about message volume.
Count the days against the date
Past the end-of-life date the finding is not "plan this" but "you are running unsupported", and the sentence in the report should say so. Before it, the number of days remaining is the only prioritisation signal available, because nothing else about this gets worse in a measurable way.
Find the clients, then migrate service by service
Grep your own repositories for the Chat SDK and chat.twilio.com, and check which mobile releases still embed it — the API cannot see any of that. Then create the replacement with POST https://conversations.twilio.com/v1/Services, repoint one client, and only remove the Chat service once nothing is left on it.
How to check it worked
Re-run once the last service is gone. The state should be clear and the exit code zero.
python3 twilio_chat_eol_audit.py
# clear no Programmable Chat services on this account.
The full code
Two paginated GETs on two different Twilio domains, with an API Key that has read access and nothing more. The classifier takes both lists rather than one, because the finding is a relationship between them: the same three Chat services mean something different on an account that has never created a Conversations service than on one that has twelve. The countdown is a separate pure function so the arithmetic that decides the tone of the report is visible and testable.
"""Report Programmable Chat services still held by a Twilio account.
Nothing breaks on the day a product is deprecated, so there is no error to look
for. The only available signal is that the account is still calling it, and the
only useful question is how much still depends on it.
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_chat_eol_audit")
CHAT = "https://chat.twilio.com/v2"
CONVERSATIONS = "https://conversations.twilio.com/v1"
# Programmable Chat in Flex reaches end of life on this date. After it the
# product may stop working as expected, and there is no automated migration.
EOL = datetime.date(2026, 6, 1)
def parse_when(value):
"""Parse a timestamp from one of Twilio's newer API domains.
chat.twilio.com and conversations.twilio.com return ISO 8601 with a trailing
Z. The 2010-04-01 account API returns RFC 2822 instead, so a parser written
for one returns nothing at all when pointed at the other, and a report with
no findings reads exactly like a clean account.
"""
text = str(value or "").strip()
if not text:
return None
try:
return datetime.datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None
def deadline(today):
"""How far this account is from Chat's end of life. Pure.
Past the date the finding is not "plan this", it is "you are running
unsupported", and the report should use different words for the two.
Returns (urgency, text).
"""
days = (EOL - today).days
if days < 0:
return ("past", "%d day(s) past the %s end of life" % (-days, EOL.isoformat()))
if days <= 90:
return ("soon", "%d day(s) until the %s end of life" % (days, EOL.isoformat()))
return ("ahead", "%d day(s) until the %s end of life" % (days, EOL.isoformat()))
def days_since_touched(services, today):
"""Days since the most recently updated service was last configured. Pure.
date_updated moves when the service resource is edited and not when a
message passes through it, so this is an upper bound on staleness and never
a measure of traffic. It is here to sort a service somebody was recently
working on from one nobody has opened in three years, and for nothing else.
"""
seen = []
for service in services or []:
when = (parse_when(service.get("date_updated"))
or parse_when(service.get("date_created")))
if when:
seen.append(when.date())
return (today - max(seen)).days if seen else None
def verdict(chat_services, conversations_services):
"""Classify what the account still depends on. Pure, so the rules can be
tested without a network.
Takes both lists because the finding is the relationship between them: three
Chat services mean one thing on an account with no Conversations services
and another on an account with twelve.
Returns (state, detail).
"""
chat = list(chat_services or [])
conversations = list(conversations_services or [])
if not chat:
return ("clear", "no Programmable Chat services on this account.")
if not conversations:
return ("not-started",
"%d Chat service(s) and no Conversations services: nothing has "
"been moved yet, and there is no automated migration to run "
"because the two products do not have the same model."
% len(chat))
return ("in-progress",
"%d Chat service(s) alongside %d Conversations service(s): the "
"migration was started and these are what is left of it, which is "
"the state most likely to be recorded internally as finished."
% (len(chat), len(conversations)))
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 main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-services", type=int, default=200,
help="stop after this many services per product")
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)
chat = list_all(session, CHAT + "/Services", "services", args.max_services)
conversations = list_all(session, CONVERSATIONS + "/Services", "services",
args.max_services)
for service in chat:
log.info(" %s %s updated=%s", service.get("sid", "?"),
service.get("friendly_name") or "(no name)",
service.get("date_updated") or "?")
state, detail = verdict(chat, conversations)
if state == "clear":
log.info("%-14s %s", state, detail)
return 0
today = datetime.date.today()
urgency, text = deadline(today)
log.warning("%-14s %s", state, detail)
log.warning(" %s (%s)", text, urgency)
stale = days_since_touched(chat, today)
if stale is not None:
log.warning(" most recently configured %d day(s) ago: staleness, not "
"traffic. Nothing in this API reports message volume.", stale)
log.warning(" repair: create the replacement with POST %s/Services, repoint "
"one client at a time, then remove each Chat service once nothing "
"is left on it", CONVERSATIONS)
log.warning(" the clients are not visible from here: grep your repositories "
"for chat.twilio.com and check which mobile releases embed the SDK")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Programmable Chat services still held by a Twilio account.
*
* Nothing breaks on the day a product is deprecated, so there is no error to
* look for. The only available signal is that the account is still calling it.
*
* 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 CHAT = 'https://chat.twilio.com/v2';
const CONVERSATIONS = 'https://conversations.twilio.com/v1';
// Programmable Chat in Flex reaches end of life on this date.
const EOL = Date.UTC(2026, 5, 1);
const EOL_TEXT = '2026-06-01';
const DAY = 86400000;
/** Parse a timestamp from one of Twilio's newer domains: ISO 8601 with a Z. */
export function parseWhen(value) {
const text = String(value ?? '').trim();
if (!text) return null;
const ms = Date.parse(text);
return Number.isNaN(ms) ? null : new Date(ms);
}
function utcDay(d) {
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}
/**
* How far this account is from Chat's end of life. Pure. Returns
* [urgency, text].
*/
export function deadline(today) {
const days = Math.round((EOL - utcDay(today)) / DAY);
if (days < 0) return ['past', `${-days} day(s) past the ${EOL_TEXT} end of life`];
if (days <= 90) return ['soon', `${days} day(s) until the ${EOL_TEXT} end of life`];
return ['ahead', `${days} day(s) until the ${EOL_TEXT} end of life`];
}
/**
* Days since the most recently updated service was last configured. Pure.
* date_updated moves on a configuration edit and not on a message, so this is
* an upper bound on staleness and never a measure of traffic.
*/
export function daysSinceTouched(services, today) {
const seen = (services ?? [])
.map((s) => parseWhen(s.date_updated) ?? parseWhen(s.date_created))
.filter(Boolean)
.map(utcDay);
if (seen.length === 0) return null;
return Math.round((utcDay(today) - Math.max(...seen)) / DAY);
}
/**
* Classify what the account still depends on. Pure. Takes both lists because
* the finding is the relationship between them. Returns [state, detail].
*/
export function verdict(chatServices, conversationsServices) {
const chat = [...(chatServices ?? [])];
const conversations = [...(conversationsServices ?? [])];
if (chat.length === 0) {
return ['clear', 'no Programmable Chat services on this account.'];
}
if (conversations.length === 0) {
return ['not-started',
`${chat.length} Chat service(s) and no Conversations services: nothing has ` +
'been moved yet, and there is no automated migration to run because the ' +
'two products do not have the same model.'];
}
return ['in-progress',
`${chat.length} Chat service(s) alongside ${conversations.length} ` +
'Conversations service(s): the migration was started and these are what is ' +
'left of it, which is the state most likely to be recorded internally as ' +
'finished.'];
}
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 chat = await listAll(auth, `${CHAT}/Services`, 'services');
const conversations = await listAll(auth, `${CONVERSATIONS}/Services`, 'services');
for (const s of chat) {
console.log(` ${s.sid ?? '?'} ${s.friendly_name || '(no name)'} ` +
`updated=${s.date_updated ?? '?'}`);
}
const [state, detail] = verdict(chat, conversations);
if (state === 'clear') {
console.log(`${state.padEnd(14)} ${detail}`);
return;
}
const today = new Date();
const [urgency, text] = deadline(today);
console.warn(`${state.padEnd(14)} ${detail}`);
console.warn(` ${text} (${urgency})`);
const stale = daysSinceTouched(chat, today);
if (stale !== null) {
console.warn(` most recently configured ${stale} day(s) ago: staleness, not ` +
'traffic. Nothing in this API reports message volume.');
}
console.warn(` repair: create the replacement with POST ${CONVERSATIONS}/Services, ` +
'repoint one client at a time, then remove each Chat service once ' +
'nothing is left on it');
console.warn(' the clients are not visible from here: grep your repositories ' +
'for chat.twilio.com and check which mobile releases embed the SDK');
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 that matters most is the half-finished migration, because it is the one a count alone gets wrong: an account with Chat services and Conversations services has a closed ticket saying this was handled, and the report has to contradict that in words rather than in a number. The date arithmetic is pinned separately, with a date on each side of the deadline, so that the difference between a plan and an outage stays in the tests rather than in someone's head.
import datetime
from twilio_chat_eol_audit import days_since_touched, deadline, parse_when, verdict
def chat(**kw):
service = {"sid": "IS01", "friendly_name": "support",
"date_created": "2019-04-02T11:00:00Z",
"date_updated": "2021-08-19T14:32:00Z"}
service.update(kw)
return service
def test_an_account_with_no_chat_services_is_clear():
state, detail = verdict([], [{"sid": "IS90"}])
assert state == "clear"
assert "no Programmable Chat" in detail
def test_chat_services_and_no_conversations_means_nothing_has_moved():
state, detail = verdict([chat()], [])
assert state == "not-started"
assert "no automated migration" in detail
def test_both_products_present_is_the_half_finished_migration():
state, detail = verdict([chat()], [{"sid": "IS90"}, {"sid": "IS91"}])
assert state == "in-progress"
assert "recorded internally as finished" in detail
def test_after_the_date_the_account_is_running_unsupported():
urgency, text = deadline(datetime.date(2026, 8, 30))
assert urgency == "past"
assert "90 day(s) past" in text
def test_inside_ninety_days_is_soon_and_beyond_it_is_ahead():
assert deadline(datetime.date(2026, 5, 1))[0] == "soon"
assert deadline(datetime.date(2025, 1, 1))[0] == "ahead"
def test_staleness_comes_from_the_most_recently_touched_service():
services = [chat(date_updated="2021-08-19T14:32:00Z"),
chat(date_updated="2026-08-20T09:00:00Z")]
assert days_since_touched(services, datetime.date(2026, 8, 30)) == 10
def test_a_service_with_no_usable_timestamp_yields_no_staleness():
assert days_since_touched([{"sid": "IS02"}], datetime.date(2026, 8, 30)) is None
assert days_since_touched([], datetime.date(2026, 8, 30)) is None
def test_date_created_stands_in_when_date_updated_is_missing():
service = {"sid": "IS03", "date_created": "2026-08-25T00:00:00Z"}
assert days_since_touched([service], datetime.date(2026, 8, 30)) == 5
def test_parse_when_reads_iso_8601_and_refuses_anything_else():
assert parse_when("2024-03-11T09:12:00Z") is not None
assert parse_when("Tue, 18 Apr 2023 09:12:00 +0000") is None
assert parse_when("") is None
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
daysSinceTouched, deadline, parseWhen, verdict,
} from './twilio-chat-eol-audit.mjs';
const chat = (over = {}) => ({
sid: 'IS01',
friendly_name: 'support',
date_created: '2019-04-02T11:00:00Z',
date_updated: '2021-08-19T14:32:00Z',
...over,
});
const day = (y, m, d) => new Date(Date.UTC(y, m - 1, d));
test('an account with no chat services is clear', () => {
const [state, detail] = verdict([], [{ sid: 'IS90' }]);
assert.equal(state, 'clear');
assert.match(detail, /no Programmable Chat/);
});
test('chat services and no conversations means nothing has moved', () => {
const [state, detail] = verdict([chat()], []);
assert.equal(state, 'not-started');
assert.match(detail, /no automated migration/);
});
test('both products present is the half-finished migration', () => {
const [state, detail] = verdict([chat()], [{ sid: 'IS90' }, { sid: 'IS91' }]);
assert.equal(state, 'in-progress');
assert.match(detail, /recorded internally as finished/);
});
test('after the date the account is running unsupported', () => {
const [urgency, text] = deadline(day(2026, 8, 30));
assert.equal(urgency, 'past');
assert.match(text, /90 day\(s\) past/);
});
test('inside ninety days is soon and beyond it is ahead', () => {
assert.equal(deadline(day(2026, 5, 1))[0], 'soon');
assert.equal(deadline(day(2025, 1, 1))[0], 'ahead');
});
test('staleness comes from the most recently touched service', () => {
const services = [chat({ date_updated: '2021-08-19T14:32:00Z' }),
chat({ date_updated: '2026-08-20T09:00:00Z' })];
assert.equal(daysSinceTouched(services, day(2026, 8, 30)), 10);
});
test('a service with no usable timestamp yields no staleness', () => {
assert.equal(daysSinceTouched([{ sid: 'IS02' }], day(2026, 8, 30)), null);
assert.equal(daysSinceTouched([], day(2026, 8, 30)), null);
});
test('date_created stands in when date_updated is missing', () => {
const service = { sid: 'IS03', date_created: '2026-08-25T00:00:00Z' };
assert.equal(daysSinceTouched([service], day(2026, 8, 30)), 5);
});
test('parseWhen reads ISO 8601', () => {
assert.notEqual(parseWhen('2024-03-11T09:12:00Z'), null);
assert.equal(parseWhen(''), null);
assert.equal(parseWhen('not a timestamp'), null);
});
FAQ
The end-of-life date has passed and everything still works. Is this urgent?
It is urgent in the sense that you no longer control the timing. Before the date, breakage is scheduled and you can plan around it; after it, the product is unmaintained and the change that finally breaks something arrives without notice and without an error code you can act on. Nothing about your Tuesday changes. What changed is that you have no warning left.
Why does the script look at Conversations services at all?
Because it converts a count into a state. Chat services with no Conversations services anywhere means the migration has not begun, which is a big piece of work nobody has scoped. Both present means somebody did the work, stopped partway, and closed the ticket. Those two accounts need very different conversations and the Chat list alone cannot tell them apart.
Can I use date_updated to find the services nobody uses?
No, and the script says so in its own output for that reason. The field moves when the service resource is edited and not when a message passes through it, so an untouched date proves nobody has changed the configuration and proves nothing at all about traffic. Treat it as an upper bound on staleness and get real usage from your own application logs.
Is there a migration tool?
No, and the reason is structural rather than a gap in the tooling. Conversations models a conversation that can span SMS, WhatsApp and chat, where Chat models channels inside one product. Mapping one onto the other requires deciding what each channel becomes, which is a product decision per service. That is also why this note is about finding the dependency rather than about running a script that fixes it.
Deleting the Chat service would clear the finding. Should I?
Only once nothing is calling it, which is exactly what the API cannot tell you. A mobile build from last year that still embeds the SDK will start failing the moment the service is gone, and those users will not have updated. Find the clients first from your own release history, move them, and let the empty service be the last thing you remove.
Related field notes
- The replacement product with no webhook wired up
- The other dead product still sitting on the account
- Credentials nobody can account for either
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.
- Conversations API overview — Twilio Docs
- Conversations Service resource — Twilio Docs
- Conversation resource — 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.