Skip to content

Diagnostic Twilio

a conversation webhook with no URL fails every event: 50369

The Debugger fills up with 50369, Conversation webhook URL not provided. The webhook resource exists. It is attached to the conversation, it has the right target, and it has been there since the integration was built. What it does not have is a URL, so every message added to that conversation raises an error and reaches nothing.

Read-only key Python and Node.js Tests included
Black android smartphone on brown wooden table
Photo by CardMapr.nl on Unsplash
The short answer

Sweep GET https://monitor.twilio.com/v1/Alerts?LogLevel=error for error_code 50369 and collect the conversation SIDs from resource_sid. Then read GET https://conversations.twilio.com/v1/Conversations/{ConversationSid}/Webhooks and find the entry whose configuration.url is null or empty.

Only a webhook whose target is webhook or trigger needs a URL. A studio-target webhook routes to a flow_sid instead and correctly has none, so a check that flags every empty URL produces a report nobody will read twice.

The problem in plain words

A conversation-scoped webhook with no URL is a subscription to nothing. Twilio holds the resource, matches the event, goes to deliver it, finds no destination and raises 50369. The conversation itself keeps working perfectly — participants send messages, the transcript is complete, nothing a user can see is wrong. The only thing missing is your application finding out any of it happened.

It usually arrives from automation. A service creates a webhook per conversation at the moment the conversation is created, and one path through that code omits the URL: a config value that was empty at boot, a template that rendered to nothing, a later update that cleared the field. Every conversation created after that point gets a webhook that cannot fire, and the count of broken conversations grows for as long as the alerts go unread.

Webhook createdURL left emptyAttached to theconversationtarget is webhookMessage addedthe event matchesNowhere todelivererror 50369App never hearsalerts pile upunread
The conversation is perfect. Participants send messages, the transcript is complete, and the application is never told any of it happened.

Why it happens

The error is per event, not per configuration. 50369 is raised when an event tries to fire, so one badly created webhook produces alerts forever rather than once, and volume in the Debugger tells you how chatty the conversation is rather than how many things are broken. The count of distinct conversation SIDs is the number that means something.

The alert list is a 30-day window. Debugger alerts are retained for 30 days and a single request returns at most 10,000. A webhook created six weeks ago on a conversation that has been quiet since shows up nowhere in the alerts, which is why the alert sweep is the starting point and the webhook list is the confirmation.

Not every webhook needs a URL. target can be webhook, trigger or studio. The first two deliver to configuration.url; the third hands the conversation to a Studio Flow named by configuration.flow_sid. Treating a Studio webhook's empty URL as a finding is the fastest way to make this audit useless.

Conversation-scoped and service-scoped webhooks are different resources. A global post_webhook_url under Configuration/Webhooks being correct does not make a per-conversation webhook correct, and the reverse is also true. 50369 is raised by the conversation-scoped one, and that is the resource to read.

The fix, as a flow

The target field decides whether a URL is required at all. A Studio target routes to a flow_sid and correctly has none, which is the one false positive that would make this report unreadable.

Alerts 50369, then theWebhooks listtarget and configuration.urlTarget webhook, https URLdeliveringTarget studio, flow_sid setno URL is requiredPlain http URLmessage bodies in the clearTarget webhook, no URL50369 on every event
One broken webhook on a busy conversation raises hundreds of alerts, so the finding is the conversation, not the alert. Deduplicate before looking anything up.

How to fix it

Sweep the alerts for the code

GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD&PageSize=100, following meta.next_page_url. Keep alerts whose error_code is 50369. Read the code defensively: it comes back as a number on the resource and as a string in some exports, and comparing the raw value is how a sweep finds nothing on an account full of them.

Pull the conversation SIDs out

resource_sid on the alert is the affected resource. When it is not a CH SID, fall back to the first CH-prefixed SID in alert_text. Deduplicate: one broken webhook on a busy conversation produces hundreds of alerts, and the finding is the conversation, not the alert.

Read the webhooks on each conversation

GET https://conversations.twilio.com/v1/Conversations/{ConversationSid}/Webhooks. Each entry carries target and a configuration object holding url, method, filters, triggers and flow_sid. This is the resource that proves the diagnosis; the alert only told you where to look.

Judge by target, not by the URL alone

An empty configuration.url is a finding on a webhook or trigger target and correct on a studio target. While you are there, an http:// URL is worth reporting separately: it is not 50369, but it is a webhook carrying conversation content in the clear.

Repair the resource, then the code that created it

The webhook update takes Configuration.Url and Configuration.Method. Fixing the resource clears today's alerts; fixing the creation path stops tomorrow's. Then re-run the alert sweep over a window that starts after the change, because the historical alerts stay in the list until they age out and will otherwise look like a repair that did not work.

How to check it worked

Re-run with a start date after the fix. The alert sweep should return no 50369 at all, and every webhook the script reads should classify as ok or studio.

python3 twilio_conversation_webhook_audit.py --days 3
# 0 conversation(s) raising 50369 in the last 3 day(s)

The full code

One alert sweep and one webhook list per affected conversation, both GETs, on an API Key with read access. The two pure functions are the SID extraction and the per-webhook verdict, because those are the two places this check can quietly go wrong: a numeric comparison that never matches, and a Studio-target webhook reported as broken because it has no URL and never needed one.

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_conversation_webhook_audit.py
"""Report Twilio conversation webhooks with no URL behind them (error 50369).

Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The repair is printed, never performed,
because this script holds a credential to an account that can send messages and
spend money.
"""
import argparse
import datetime as dt
import logging
import os
import re
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_conversation_webhook_audit")

MONITOR = "https://monitor.twilio.com/v1"
CONVERSATIONS = "https://conversations.twilio.com/v1"

NO_URL = 50369
CH_SID = re.compile(r"CH[0-9a-fA-F]{32}")


def conversation_sids(alerts, code=NO_URL):
    """Distinct conversation SIDs from the alerts carrying one error code. Pure.

    error_code arrives as a number on the Alert resource and as a string in some
    exports, so it is coerced rather than compared raw: that comparison is why a
    sweep reports nothing on an account full of findings. resource_sid is the
    affected resource; alert_text is the fallback when it is not a CH SID.

    Deduplicated, because one broken webhook on a busy conversation raises the
    error on every event and the finding is the conversation, not the alert.
    """
    found = []
    for a in alerts or []:
        raw = a.get("error_code")
        try:
            if raw is None or int(raw) != int(code):
                continue
        except (TypeError, ValueError):
            continue
        sid = str(a.get("resource_sid") or "")
        if not CH_SID.fullmatch(sid):
            match = CH_SID.search(str(a.get("alert_text") or ""))
            sid = match.group(0) if match else ""
        if sid and sid not in found:
            found.append(sid)
    return found


def verdict(webhook):
    """Classify one conversation-scoped webhook. Pure.

    target decides whether a URL is even required: `webhook` and `trigger`
    deliver to configuration.url, while `studio` hands the conversation to the
    Flow named by configuration.flow_sid and correctly has no URL at all.

    Returns (state, detail).
    """
    target = str(webhook.get("target") or "").lower()
    cfg = webhook.get("configuration") or {}
    url = str(cfg.get("url") or "").strip()

    if target == "studio":
        flow = str(cfg.get("flow_sid") or "").strip()
        if flow:
            return ("studio", "routes to Studio Flow %s; no URL is required." % flow)
        return ("studio-no-flow",
                "target is studio but configuration.flow_sid is empty, so there "
                "is no Flow to route to and no URL either.")

    if target not in ("webhook", "trigger"):
        return ("unknown-target",
                "target %r is not one this check understands; read the webhook "
                "resource by hand." % (target or "empty"))

    if not url:
        return ("missing-url",
                "target is %s and configuration.url is empty. Every event on this "
                "conversation raises 50369 and reaches nothing." % target)

    if url.startswith("http://"):
        return ("insecure",
                "delivers conversation content over plain http to %s. Not 50369, "
                "but message bodies in the clear." % url)

    if not url.startswith("https://"):
        return ("invalid-url",
                "configuration.url is %r, which is not an absolute http(s) URL." % url)

    return ("ok", "target %s delivering to %s." % (target, url))


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", 100)
    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("--days", type=int, default=7,
                    help="how far back to sweep the alerts (30 day retention)")
    ap.add_argument("--max-alerts", type=int, default=10000,
                    help="stop paging after this many alerts")
    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)

    start = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
    alerts = paged(session, "%s/Alerts" % MONITOR, "alerts", args.max_alerts,
                   LogLevel="error", StartDate=start)
    sids = conversation_sids(alerts)
    if not sids:
        log.info("0 conversation(s) raising 50369 in the last %d day(s)", args.days)
        return 0

    bad = 0
    for sid in sids:
        webhooks = paged(session, "%s/Conversations/%s/Webhooks"
                         % (CONVERSATIONS, sid), "webhooks", 50)
        if not webhooks:
            log.warning("%-15s %s  50369 in the alerts but the conversation has no "
                        "webhooks now: it was deleted, or the conversation was.",
                        "gone", sid)
            bad += 1
            continue
        for hook in webhooks:
            state, detail = verdict(hook)
            line = "%-15s %s/%s  %s" % (state, sid, hook.get("sid"), detail)
            if state in ("ok", "studio"):
                log.info(line)
                continue
            bad += 1
            log.warning(line)
            log.warning("  repair: update %s/Conversations/%s/Webhooks/%s with "
                        "Configuration.Url=https://... and Configuration.Method="
                        "POST, then fix the code path that created it without one.",
                        CONVERSATIONS, sid, hook.get("sid"))

    log.info("%d conversation(s) raising 50369 in the last %d day(s), %d webhook "
             "finding(s)", len(sids), args.days, bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-conversation-webhook-audit.mjs
/**
 * Report Twilio conversation webhooks with no URL behind them (error 50369).
 *
 * 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 MONITOR = 'https://monitor.twilio.com/v1';
const CONVERSATIONS = 'https://conversations.twilio.com/v1';

const NO_URL = 50369;
const CH_SID = /CH[0-9a-fA-F]{32}/;

/**
 * Distinct conversation SIDs from the alerts carrying one error code. Pure.
 *
 * error_code arrives as a number on the Alert resource and as a string in some
 * exports, so it is coerced rather than compared raw. resource_sid is the
 * affected resource; alert_text is the fallback. Deduplicated, because one
 * broken webhook on a busy conversation raises the error on every event.
 */
export function conversationSids(alerts, code = NO_URL) {
  const found = [];
  for (const a of alerts ?? []) {
    const raw = a.error_code;
    if (raw === null || raw === undefined || raw === '') continue;
    const n = Number(raw);
    if (!Number.isFinite(n) || n !== Number(code)) continue;
    let sid = String(a.resource_sid ?? '');
    if (!/^CH[0-9a-fA-F]{32}$/.test(sid)) {
      const match = CH_SID.exec(String(a.alert_text ?? ''));
      sid = match ? match[0] : '';
    }
    if (sid && !found.includes(sid)) found.push(sid);
  }
  return found;
}

/**
 * Classify one conversation-scoped webhook. Pure.
 *
 * target decides whether a URL is even required: `webhook` and `trigger`
 * deliver to configuration.url, while `studio` hands the conversation to the
 * Flow named by configuration.flow_sid and correctly has no URL at all.
 * Returns [state, detail].
 */
export function verdict(webhook) {
  const target = String(webhook.target ?? '').toLowerCase();
  const cfg = webhook.configuration ?? {};
  const url = String(cfg.url ?? '').trim();

  if (target === 'studio') {
    const flow = String(cfg.flow_sid ?? '').trim();
    if (flow) return ['studio', `routes to Studio Flow ${flow}; no URL is required.`];
    return ['studio-no-flow',
      'target is studio but configuration.flow_sid is empty, so there is no Flow ' +
      'to route to and no URL either.'];
  }

  if (target !== 'webhook' && target !== 'trigger') {
    return ['unknown-target',
      `target "${target || 'empty'}" is not one this check understands; read the ` +
      'webhook resource by hand.'];
  }

  if (!url) {
    return ['missing-url',
      `target is ${target} and configuration.url is empty. Every event on this ` +
      'conversation raises 50369 and reaches nothing.'];
  }

  if (url.startsWith('http://')) {
    return ['insecure',
      `delivers conversation content over plain http to ${url}. Not 50369, but ` +
      'message bodies in the clear.'];
  }

  if (!url.startsWith('https://')) {
    return ['invalid-url',
      `configuration.url is "${url}", which is not an absolute http(s) URL.`];
  }

  return ['ok', `target ${target} delivering to ${url}.`];
}

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 paged(auth, url, key, limit = 10000, first = {}) {
  let next = url;
  let params = { PageSize: 100, ...first };
  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 days = Number(process.argv.includes('--days')
    ? process.argv[process.argv.indexOf('--days') + 1] : 7) || 7;
  const start = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);

  const alerts = await paged(auth, `${MONITOR}/Alerts`, 'alerts', 10000,
                             { LogLevel: 'error', StartDate: start });
  const sids = conversationSids(alerts);
  if (sids.length === 0) {
    console.log(`0 conversation(s) raising 50369 in the last ${days} day(s)`);
    return;
  }

  let bad = 0;
  for (const sid of sids) {
    const webhooks = await paged(auth, `${CONVERSATIONS}/Conversations/${sid}/Webhooks`,
                                 'webhooks', 50);
    if (webhooks.length === 0) {
      console.warn(`${'gone'.padEnd(15)} ${sid}  50369 in the alerts but the ` +
                   'conversation has no webhooks now: it was deleted, or the ' +
                   'conversation was.');
      bad += 1;
      continue;
    }
    for (const hook of webhooks) {
      const [state, detail] = verdict(hook);
      const line = `${state.padEnd(15)} ${sid}/${hook.sid}  ${detail}`;
      if (state === 'ok' || state === 'studio') { console.log(line); continue; }
      bad += 1;
      console.warn(line);
      console.warn(`  repair: update ${CONVERSATIONS}/Conversations/${sid}/Webhooks/` +
                   `${hook.sid} with Configuration.Url=https://... and ` +
                   'Configuration.Method=POST, then fix the code path that created ' +
                   'it without one.');
    }
  }

  console.log(`${sids.length} conversation(s) raising 50369 in the last ${days} ` +
              `day(s), ${bad} webhook finding(s)`);
  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

Two things decide whether this audit is worth running. An error_code that arrives as the string "50369" has to match, and a Studio-target webhook with no URL has to come back clean — it is supposed to have no URL. The rest of the tests are about not double-counting: hundreds of alerts on one chatty conversation are one finding.

test_twilio_conversation_webhook_audit.py
from twilio_conversation_webhook_audit import conversation_sids, verdict

CH = "CH11111111111111111111111111111111"
CH2 = "CH22222222222222222222222222222222"


def alert(code=50369, resource=CH, text=""):
    return {"sid": "NO1", "error_code": code, "resource_sid": resource,
            "alert_text": text, "log_level": "error"}


def hook(target="webhook", **cfg):
    return {"sid": "WH1", "target": target, "configuration": cfg}


def test_error_code_as_a_string_still_matches():
    assert conversation_sids([alert(code="50369")]) == [CH]


def test_other_error_codes_are_ignored():
    assert conversation_sids([alert(code=50361), alert(code=None)]) == []


def test_one_chatty_conversation_is_one_finding():
    assert conversation_sids([alert(), alert(), alert(resource=CH2)]) == [CH, CH2]


def test_the_conversation_sid_is_recovered_from_the_alert_text():
    a = alert(resource="ACxxxxxxxx", text="Conversation webhook URL not provided "
                                          "for %s" % CH2)
    assert conversation_sids([a]) == [CH2]


def test_a_webhook_target_with_no_url_is_the_finding():
    state, detail = verdict(hook("webhook", url=None))
    assert state == "missing-url"
    assert "50369" in detail


def test_a_studio_target_with_no_url_is_correct():
    # This is the false positive that would make the report useless: a studio
    # webhook routes to a Flow and never has a URL.
    state, detail = verdict(hook("studio", flow_sid="FW1"))
    assert state == "studio"
    assert "FW1" in detail


def test_a_studio_target_with_no_flow_is_still_wrong():
    assert verdict(hook("studio"))[0] == "studio-no-flow"


def test_a_trigger_target_needs_a_url_too():
    assert verdict(hook("trigger", url=""))[0] == "missing-url"
    assert verdict(hook("trigger", url="https://app.example.com/hook"))[0] == "ok"


def test_plain_http_is_reported_separately_from_the_missing_url():
    state, detail = verdict(hook("webhook", url="http://app.example.com/hook"))
    assert state == "insecure"
    assert "Not 50369" in detail
twilio-conversation-webhook-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { conversationSids, verdict } from './twilio-conversation-webhook-audit.mjs';

const CH = 'CH11111111111111111111111111111111';
const CH2 = 'CH22222222222222222222222222222222';

const alert = ({ code = 50369, resource = CH, text = '' } = {}) => ({
  sid: 'NO1', error_code: code, resource_sid: resource, alert_text: text,
  log_level: 'error',
});

const hook = (target = 'webhook', configuration = {}) => ({
  sid: 'WH1', target, configuration,
});

test('error code as a string still matches', () => {
  assert.deepEqual(conversationSids([alert({ code: '50369' })]), [CH]);
});

test('other error codes are ignored', () => {
  assert.deepEqual(conversationSids([alert({ code: 50361 }), alert({ code: null })]), []);
});

test('one chatty conversation is one finding', () => {
  assert.deepEqual(conversationSids([alert(), alert(), alert({ resource: CH2 })]),
    [CH, CH2]);
});

test('the conversation sid is recovered from the alert text', () => {
  const a = alert({ resource: 'ACxxxxxxxx',
                    text: `Conversation webhook URL not provided for ${CH2}` });
  assert.deepEqual(conversationSids([a]), [CH2]);
});

test('a webhook target with no url is the finding', () => {
  const [state, detail] = verdict(hook('webhook', { url: null }));
  assert.equal(state, 'missing-url');
  assert.match(detail, /50369/);
});

test('a studio target with no url is correct', () => {
  const [state, detail] = verdict(hook('studio', { flow_sid: 'FW1' }));
  assert.equal(state, 'studio');
  assert.match(detail, /FW1/);
});

test('a studio target with no flow is still wrong', () => {
  assert.equal(verdict(hook('studio', {}))[0], 'studio-no-flow');
});

test('a trigger target needs a url too', () => {
  assert.equal(verdict(hook('trigger', { url: '' }))[0], 'missing-url');
  assert.equal(verdict(hook('trigger', { url: 'https://app.example.com/hook' }))[0], 'ok');
});

test('plain http is reported separately from the missing url', () => {
  const [state, detail] = verdict(hook('webhook', { url: 'http://app.example.com/hook' }));
  assert.equal(state, 'insecure');
  assert.match(detail, /Not 50369/);
});

FAQ

What exactly does 50369 mean?

That an event matched a conversation-scoped webhook whose configuration carried no URL to deliver to. The webhook resource exists and is attached; there is simply nowhere for Twilio to send the request, so it raises the error instead. The conversation itself is unaffected and the participants notice nothing.

Why do I get hundreds of these for one conversation?

Because the error is raised per event, not per configuration. Every message added, every participant change, every state update tries to fire the webhook and fails again. The number worth reporting is the count of distinct conversation SIDs, which is why the script deduplicates before it looks anything up.

Can I find every affected conversation from the alerts alone?

No. Debugger alerts are retained for 30 days, and a webhook on a conversation that has been quiet for longer raises nothing to find. The alert sweep tells you which conversations are actively failing; auditing the creation path in your own code is the only way to bound the rest.

Why does the script not flag a webhook with no URL when the target is studio?

Because a studio-target webhook routes the conversation to the Flow named in configuration.flow_sid and never has a URL. Reporting those would put a false positive next to every real one, and a report with false positives in it stops being read after the second week.

Is the global Conversations webhook the same thing?

No. The service-level configuration under Configuration/Webhooks holds post_webhook_url, pre_webhook_url and the filters that decide which events fire at all. It is a different resource with a different failure mode; 50369 comes from the conversation-scoped webhook, and that is what this script reads.

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.