Diagnostic Twilio
Conversations webhooks fire for nothing when filters are empty
post_webhook_url is set. It is the right URL, it is HTTPS, and curl gets a 200 out of it in a second. Messages are being added to conversations all day, and your endpoint has never once been called. There is no error code to look up, because nothing failed: Conversations delivered every event it was asked for, and it was asked for none.
Read GET https://conversations.twilio.com/v1/Configuration/Webhooks and look at filters next to post_webhook_url and pre_webhook_url. filters is an allowlist: only the event names in it are ever delivered, and an empty list delivers nothing at all.
The second trap is the tense. Post-action names end in -ed — onMessageAdded, onConversationStateUpdated — and go to post_webhook_url; the pre-action name is onMessageAdd, it fires before the action is committed, and it goes to pre_webhook_url. One filter list feeds both, so a list of pre-action names with only a post URL configured is the same silence with a longer configuration.
The problem in plain words
This is a subscription with nothing subscribed. The configuration is present, populated and correct on every field a review would look at, and the one field that decides whether anything is sent holds an empty array. Twilio raises nothing, because there is no failure: no delivery was attempted, so no delivery failed. The Debugger stays empty, the conversation transcripts are complete, and the application simply never learns that any of it happened.
It is usually a partial setup. Someone configures the URL first, intending to come back for the filters, and the URL is the part that feels like the work. Or a later update sets one filter to add an event and replaces the whole list in doing so, quietly unsubscribing from the three that were already there. Both leave a configuration that reads as finished.
Why it happens
filters is an allowlist, not a mute list. An empty list is not "no exclusions", it is "no events". That reading is the entire bug, and it is the opposite of how almost every other filter field in every other product behaves.
The default is deliberately narrow. Conversations does not subscribe you to everything, because a post-action webhook that fires on every event can drive an integration that writes back into the conversation into a loop. Narrow by default is the right choice and it means the working configuration is always something you built, never something you inherited.
One list serves two webhooks with two naming conventions. The -ed suffix is the only thing separating an event that fires after the fact from one that fires before it and can reject the action. Put onMessageAdd in the list, set only post_webhook_url, and the configuration is coherent, well-formed, and delivers nothing to the URL you set.
An update replaces the list rather than adding to it. The configuration is one resource that you overwrite, so the safe change is always "send the full set of filters you want", and the natural change — sending the one you are adding — drops the rest without saying so.
The fix, as a flow
filters is an allowlist rather than a mute list, and one list feeds two webhooks. The past tense suffix is the only thing deciding whether an event goes to the post URL or the pre URL.
How to fix it
Read the configuration as one object
GET https://conversations.twilio.com/v1/Configuration/Webhooks. It is a singleton, not a list: one response carrying pre_webhook_url, post_webhook_url, method and filters. Every finding in this note is a relationship between those four fields, which is why the check reads them together.
Write down the events your code actually handles
The audit needs a required list or it can only tell you that filters is empty. Take the event names your handler branches on — usually onMessageAdded, often onConversationStateUpdated and onParticipantAdded — and pass them in. Anything your code handles that Conversations is not sending is a silent gap.
Check the tense against the URL that is set
Split the filter list on the -ed suffix. Post-action names need post_webhook_url; pre-action names need pre_webhook_url. A list that is entirely one kind while only the other URL is configured is a configuration that cannot deliver, and it looks busier than an empty one.
Treat every update as a replacement
Because the resource is overwritten, the repair is the complete set: Filters=onMessageAdded&Filters=onConversationStateUpdated&Filters=onParticipantAdded, repeating the parameter once per event. Sending only the new one is how a working integration loses the two events it had.
Check the per-service configurations too
An account with more than the default Conversations service carries a webhook configuration per service, and each has its own filters. The script does this under --services rather than by default, because on most accounts the account-level configuration is the whole story and an extra pass is noise.
How to check it worked
Re-run with the events your handler needs. Every configuration should report ok.
python3 twilio_conversations_filter_audit.py --require onMessageAdded,onParticipantAdded
# 1 configuration(s), 0 delivering nothing
The full code
One GET for the account-level configuration, and one per service under --services, on an API Key with read access. The pure part is the pair of functions that split a filter list by tense and then judge the configuration against the events you say you need — the whole note lives in those two, and neither of them should need a Twilio account to test.
"""Report Conversations webhook configurations that deliver no events.
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 logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_conversations_filter_audit")
CONVERSATIONS = "https://conversations.twilio.com/v1"
DEFAULT_REQUIRED = ("onMessageAdded",)
def split_filters(filters):
"""Split a filter list into (pre_action, post_action) names. Pure.
Post-action names are past tense: onMessageAdded fires once the message is
committed and is delivered to post_webhook_url. The pre-action name is
onMessageAdd, it fires before the action and can reject it, and it goes to
pre_webhook_url. One list feeds both webhooks and the suffix is the only
thing separating the two halves.
"""
pre, post = [], []
for f in filters or []:
name = str(f or "").strip()
if not name:
continue
(post if name.endswith("ed") else pre).append(name)
return pre, post
def verdict(config, required=DEFAULT_REQUIRED):
"""Classify one Conversations webhook configuration. Pure.
`required` is the set of events the application actually handles. Without it
the check can only say whether filters is empty, which misses the far more
common case of a list that is populated and short of the one event the code
is waiting for. Returns (state, detail).
"""
post_url = str(config.get("post_webhook_url") or "").strip()
pre_url = str(config.get("pre_webhook_url") or "").strip()
pre, post = split_filters(config.get("filters"))
wanted = [str(r).strip() for r in (required or []) if str(r).strip()]
total = len(pre) + len(post)
if not (post_url or pre_url):
return ("no-webhook",
"neither pre_webhook_url nor post_webhook_url is set, so the "
"filter list has nowhere to deliver to.")
if total == 0:
return ("no-filters",
"a webhook URL is set and filters is empty. filters is an "
"allowlist, so no event is delivered and nothing fails.")
if post_url and not post:
return ("post-url-no-post-filters",
"post_webhook_url is set but every filter is a pre-action name "
"(%s). Post-action names end in -ed; the post webhook fires for "
"nothing." % ", ".join(pre))
if pre_url and not pre:
return ("pre-url-no-pre-filters",
"pre_webhook_url is set but every filter is a post-action name, "
"so nothing is ever sent to it before an action is committed.")
missing = [w for w in wanted if w not in pre and w not in post]
if missing:
return ("missing-events",
"delivering %d event type(s) but not %s, and an event that is "
"not in filters is dropped without a trace."
% (total, ", ".join(missing)))
return ("ok", "delivering %d event type(s), including everything the "
"application asked for" % total)
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 paged(session, url, key, limit, **first):
params = dict(first)
params.setdefault("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 report(name, config, required):
"""Print one configuration's verdict. Returns 1 when it is a finding."""
state, detail = verdict(config, required)
line = "%-24s %s %s" % (state, name, detail)
if state == "ok":
log.info(line)
return 0
log.warning(line)
log.warning(" repair: update the webhook configuration with the complete "
"filter list, repeating the parameter once per event: "
"Filters=%s. An update replaces the list rather than adding to it.",
"&Filters=".join(required))
return 1
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--require", default=",".join(DEFAULT_REQUIRED),
help="comma-separated event names the application handles")
ap.add_argument("--services", action="store_true",
help="also read the per-service webhook configurations")
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
required = [r.strip() for r in args.require.split(",") if r.strip()]
session = requests.Session()
session.auth = (key, secret)
checked = 1
bad = report("account configuration",
get(session, CONVERSATIONS + "/Configuration/Webhooks"), required)
if args.services:
for svc in paged(session, CONVERSATIONS + "/Services", "services", 200):
sid = svc.get("sid")
try:
config = get(session, "%s/Services/%s/Configuration/Webhooks"
% (CONVERSATIONS, sid))
except requests.HTTPError as exc:
log.info("%s: no readable webhook configuration (%s)", sid, exc)
continue
checked += 1
bad += report(svc.get("friendly_name") or sid, config, required)
log.info("%d configuration(s), %d delivering nothing the application needs",
checked, bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Conversations webhook configurations that deliver no events.
*
* 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 CONVERSATIONS = 'https://conversations.twilio.com/v1';
const DEFAULT_REQUIRED = ['onMessageAdded'];
/**
* Split a filter list into [preAction, postAction] names. Pure.
*
* Post-action names are past tense: onMessageAdded fires once the message is
* committed and is delivered to post_webhook_url. The pre-action name is
* onMessageAdd, it fires before the action and can reject it, and it goes to
* pre_webhook_url. One list feeds both webhooks and the suffix is the only
* thing separating the two halves.
*/
export function splitFilters(filters) {
const pre = [];
const post = [];
for (const f of filters ?? []) {
const name = String(f ?? '').trim();
if (!name) continue;
(name.endsWith('ed') ? post : pre).push(name);
}
return [pre, post];
}
/**
* Classify one Conversations webhook configuration. Pure.
*
* `required` is the set of events the application actually handles; without it
* the check can only say whether filters is empty. Returns [state, detail].
*/
export function verdict(config, required = DEFAULT_REQUIRED) {
const postUrl = String(config.post_webhook_url ?? '').trim();
const preUrl = String(config.pre_webhook_url ?? '').trim();
const [pre, post] = splitFilters(config.filters);
const wanted = (required ?? []).map((r) => String(r).trim()).filter(Boolean);
const total = pre.length + post.length;
if (!postUrl && !preUrl) {
return ['no-webhook',
'neither pre_webhook_url nor post_webhook_url is set, so the filter list ' +
'has nowhere to deliver to.'];
}
if (total === 0) {
return ['no-filters',
'a webhook URL is set and filters is empty. filters is an allowlist, so ' +
'no event is delivered and nothing fails.'];
}
if (postUrl && post.length === 0) {
return ['post-url-no-post-filters',
`post_webhook_url is set but every filter is a pre-action name ` +
`(${pre.join(', ')}). Post-action names end in -ed; the post webhook ` +
'fires for nothing.'];
}
if (preUrl && pre.length === 0) {
return ['pre-url-no-pre-filters',
'pre_webhook_url is set but every filter is a post-action name, so ' +
'nothing is ever sent to it before an action is committed.'];
}
const missing = wanted.filter((w) => !pre.includes(w) && !post.includes(w));
if (missing.length) {
return ['missing-events',
`delivering ${total} event type(s) but not ${missing.join(', ')}, and an ` +
'event that is not in filters is dropped without a trace.'];
}
return ['ok', `delivering ${total} event type(s), including everything the ` +
'application asked for'];
}
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 paged(auth, url, key, limit, first = {}) {
let params = { PageSize: 50, ...first };
const out = [];
while (url && out.length < limit) {
const page = await get(auth, url, params);
out.push(...(page[key] ?? []));
url = (page.meta ?? {}).next_page_url ?? null;
params = {};
}
return out.slice(0, limit);
}
function report(name, config, required) {
const [state, detail] = verdict(config, required);
const line = `${state.padEnd(24)} ${name} ${detail}`;
if (state === 'ok') { console.log(line); return 0; }
console.warn(line);
console.warn(' repair: update the webhook configuration with the complete ' +
'filter list, repeating the parameter once per event: ' +
`Filters=${required.join('&Filters=')}. An update replaces the ` +
'list rather than adding to it.');
return 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 auth = authHeader(key, secret);
const flag = process.argv.indexOf('--require');
const required = flag === -1
? DEFAULT_REQUIRED
: String(process.argv[flag + 1] ?? '').split(',').map((r) => r.trim()).filter(Boolean);
let checked = 1;
let bad = report('account configuration',
await get(auth, `${CONVERSATIONS}/Configuration/Webhooks`), required);
if (process.argv.includes('--services')) {
for (const svc of await paged(auth, `${CONVERSATIONS}/Services`, 'services', 200)) {
let config;
try {
config = await get(auth, `${CONVERSATIONS}/Services/${svc.sid}/Configuration/Webhooks`);
} catch (err) {
console.log(`${svc.sid}: no readable webhook configuration (${err.message})`);
continue;
}
checked += 1;
bad += report(svc.friendly_name || svc.sid, config, required);
}
}
console.log(`${checked} configuration(s), ${bad} delivering nothing the ` +
'application needs');
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
Three cases carry this note. An empty filters with a perfectly good URL, which is the headline. A list of pre-action names against a post URL, which is the same silence dressed up as configuration. And a populated list missing exactly one required event, which is the version that runs in production for months because most of the integration works.
from twilio_conversations_filter_audit import split_filters, verdict
POST_URL = "https://app.example.com/conversations"
def config(**kw):
base = {"post_webhook_url": POST_URL, "pre_webhook_url": "",
"filters": ["onMessageAdded"], "method": "POST"}
base.update(kw)
return base
def test_a_good_url_with_no_filters_delivers_nothing():
state, detail = verdict(config(filters=[]))
assert state == "no-filters"
assert "allowlist" in detail
def test_pre_action_names_against_a_post_url_deliver_nothing_either():
state, detail = verdict(config(filters=["onMessageAdd", "onParticipantAdd"]))
assert state == "post-url-no-post-filters"
assert "-ed" in detail
def test_a_populated_list_missing_one_required_event_is_a_finding():
state, detail = verdict(config(filters=["onParticipantAdded"]),
required=["onMessageAdded", "onParticipantAdded"])
assert state == "missing-events"
assert "onMessageAdded" in detail
def test_no_url_at_all_is_reported_before_the_filters():
state, _ = verdict(config(post_webhook_url="", pre_webhook_url="", filters=[]))
assert state == "no-webhook"
def test_a_pre_webhook_with_only_post_filters_is_its_own_finding():
state, _ = verdict(config(post_webhook_url="", pre_webhook_url=POST_URL,
filters=["onMessageAdded"]))
assert state == "pre-url-no-pre-filters"
def test_everything_the_application_asked_for_is_ok():
state, _ = verdict(config(filters=["onMessageAdded", "onConversationStateUpdated"]),
required=["onMessageAdded", "onConversationStateUpdated"])
assert state == "ok"
def test_split_filters_uses_the_tense_and_ignores_blanks():
pre, post = split_filters(["onMessageAdd", "onMessageAdded", "", None,
"onConversationStateUpdated"])
assert pre == ["onMessageAdd"]
assert post == ["onMessageAdded", "onConversationStateUpdated"]
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { splitFilters, verdict } from './twilio-conversations-filter-audit.mjs';
const POST_URL = 'https://app.example.com/conversations';
const config = (kw = {}) => ({
post_webhook_url: POST_URL, pre_webhook_url: '',
filters: ['onMessageAdded'], method: 'POST', ...kw,
});
test('a good url with no filters delivers nothing', () => {
const [state, detail] = verdict(config({ filters: [] }));
assert.equal(state, 'no-filters');
assert.match(detail, /allowlist/);
});
test('pre-action names against a post url deliver nothing either', () => {
const [state, detail] = verdict(config({ filters: ['onMessageAdd', 'onParticipantAdd'] }));
assert.equal(state, 'post-url-no-post-filters');
assert.match(detail, /-ed/);
});
test('a populated list missing one required event is a finding', () => {
const [state, detail] = verdict(config({ filters: ['onParticipantAdded'] }),
['onMessageAdded', 'onParticipantAdded']);
assert.equal(state, 'missing-events');
assert.match(detail, /onMessageAdded/);
});
test('no url at all is reported before the filters', () => {
assert.equal(
verdict(config({ post_webhook_url: '', pre_webhook_url: '', filters: [] }))[0],
'no-webhook');
});
test('a pre webhook with only post filters is its own finding', () => {
assert.equal(
verdict(config({ post_webhook_url: '', pre_webhook_url: POST_URL,
filters: ['onMessageAdded'] }))[0],
'pre-url-no-pre-filters');
});
test('everything the application asked for is ok', () => {
const [state] = verdict(
config({ filters: ['onMessageAdded', 'onConversationStateUpdated'] }),
['onMessageAdded', 'onConversationStateUpdated']);
assert.equal(state, 'ok');
});
test('splitFilters uses the tense and ignores blanks', () => {
const [pre, post] = splitFilters(['onMessageAdd', 'onMessageAdded', '', null,
'onConversationStateUpdated']);
assert.deepEqual(pre, ['onMessageAdd']);
assert.deepEqual(post, ['onMessageAdded', 'onConversationStateUpdated']);
});
FAQ
Does an empty filters list mean everything or nothing?
Nothing. filters is an allowlist of event names, so an empty list subscribes to no events at all. That is the reverse of how most filter fields behave, and it is the whole reason this configuration can look complete and deliver silence.
Why is there no error code for it?
Because nothing failed. No delivery was attempted, so no delivery could fail, so the Debugger has nothing to log. This is one of the failures that error-based monitoring cannot see at any volume: the account is healthy and the integration is deaf.
What is the difference between onMessageAdd and onMessageAdded?
Tense, and which webhook receives it. The pre-action event fires before the action is committed and goes to pre_webhook_url, where a handler can reject it. The past-tense event fires afterwards and goes to post_webhook_url. Both live in the same filters list, which is why a list of the wrong tense delivers nothing to the URL you configured.
Why does the script want a list of required events?
Because an empty filters list is the easy case. The common case is a list with three events in it and not the fourth one your handler branches on, and no check that only asks whether the list is empty will ever find that.
Will adding one filter keep the ones already there?
No. The configuration is a single resource that is replaced when updated, so the repair has to send the complete set with the Filters parameter repeated once per event. Sending only the new one is how an integration silently unsubscribes from what it already had.
Related field notes
- A conversation webhook with no URL: 50369
- Five conversation webhooks is the cap
- An Event Streams sink that stopped delivering
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 webhook configuration resource — Twilio Docs
- Conversations webhooks — Twilio Docs
- Conversation-scoped webhook resource — Twilio Docs
- Webhooks (HTTP callbacks) — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.