Skip to content

Diagnostic Twilio

a Studio Flow left in draft, so your edits are live nowhere

Someone opened the Flow, moved a widget, changed the greeting, saved, and tested it from their own handset. It worked. Two weeks later a customer quotes the old greeting back at you. Nothing failed, nothing was rolled back, and the Console still shows the new version exactly as it was left — because the Console shows the draft, and every real caller is running the last revision somebody pressed Publish on.

Read-only key Python and Node.js Tests included
Bicycle parked in a cluttered warehouse with a vintage car.
Photo by Rana Kaname on Unsplash
The short answer

Read GET https://studio.twilio.com/v2/Flows and flag any flow whose status is draft. Then read GET https://studio.twilio.com/v2/Flows/{FlowSid}/Executions: a draft flow that is still taking executions is the bad case, because live traffic is running an older definition than the one on screen.

Publishing is a separate act from saving. Saving raises revision; only Publish moves the definition that inbound calls and messages actually execute, and only phone numbers listed under TEST USERS ever see the draft.

The problem in plain words

Studio has two definitions per Flow and one canvas to show them in. The canvas shows the draft. The runtime uses the last published revision. There is no banner in the Console that a caller ever sees, no failed request, no alert — a Flow in draft is not broken, it is simply not the thing running.

The shape this takes in practice is a change that seems to work for exactly one person. Whoever made the edit tested it from a handset that is in the Flow's TEST USERS list, saw the new behaviour, and closed the tab. Everyone else got the old behaviour, and will keep getting it until someone presses Publish. Weeks later the bug report arrives describing a message nobody can find in the code, because it is not in the code: it is in a revision of a Flow that has not moved since March.

Widget editedgreeting rewrittenSavedrevision goes upNever publishedno Publish pressedTested from atest userthe draft answersCallers hearthe old flowfor as long as ittakes
Nothing failed. The Console shows the new definition, the runtime keeps serving the old one, and the only person who sees the change is the person on the TEST USERS list.

Why it happens

Save and Publish are different operations. The REST equivalent is the Status parameter on the Flow: a flow can be updated with Status=draft any number of times, incrementing revision each time, and none of those revisions is served to anybody. The Console's Save button does the same thing the API does.

The draft is reachable, which is why it fools people. Numbers added to TEST USERS execute the draft rather than the published revision. That is the feature working as designed, and it is also the reason the person who made the change is the least likely person in the company to notice that it never shipped.

Nothing in the message or call logs changes. Executions run, widgets fire, the number answers. The Debugger stays quiet because there is no error: the published revision is valid and it does what it always did. There is no error code for this note to give you, which is exactly why it needs a script.

The API will not tell you which revision is live. revision on the Flow resource counts every saved revision, published or not. A flow reading draft at revision 12 has an earlier published revision serving traffic, and the number of that revision is not a field you can read back. What the script can tell you — definitively — is that the definition on screen is not the definition running.

The fix, as a flow

The classifier keys on status rather than on the canvas, because the canvas is the draft and the runtime is the last published revision. Which of those two a caller hears is not a field you can read back.

GET /v2/Flows and itsExecutionsstatus, revision, validPublishedthe canvas is the runtimeDraft, no executionsedits live nowhere yetDraft, taking trafficcallers run an older revisionvalid is falsepublishing will not work
A draft with executions is an outage; a draft with none is somebody's unfinished work. An invalid definition cannot be published at all, so it never gets told to press Publish.

How to fix it

List the Flows and read status, not the canvas

GET https://studio.twilio.com/v2/Flows?PageSize=50, following meta.next_page_url. The field that matters is status: draft or published. friendly_name and date_updated are for the human reading the report; status is the finding.

Separate never-published from published-then-edited

A draft flow at revision 1 has never been published at all: a number pointed at it has nothing to run. A draft flow at a higher revision has an older published definition still serving traffic, which is the quieter and more common failure. The two need different sentences in a report because they need different reactions.

Ask whether the Flow is carrying live traffic

GET https://studio.twilio.com/v2/Flows/{FlowSid}/Executions?PageSize=20. Executions on a draft flow are the proof that this is not a scratch Flow somebody abandoned — real conversations are running through it, and they are running the definition you cannot see on screen.

Check valid before you blame the publish step

valid is false when the definition itself will not compile: a transition to a deleted widget, broken Liquid, a required field left empty. Publishing that flow is not the repair and will not succeed. Read errors[] on GET https://studio.twilio.com/v2/Flows/{FlowSid} first; each entry names the widget path.

Publish deliberately, and keep the check on a schedule

The repair is Console → Studio → open the Flow → Publish, or the REST update with Status=published and a CommitMessage you will thank yourself for later. Then leave this script on a schedule: the failure is not a one-time mistake, it recurs every time someone edits a Flow at the end of a day.

How to check it worked

Re-run after publishing. Every flow that carries traffic should report published, and the only drafts left should be ones nobody has pointed a number at.

python3 twilio_studio_draft_audit.py
# 7 flow(s), 0 running a definition older than the one on screen

The full code

Two read-only surfaces — the Flows list and, for each draft, one page of its Executions — and an API Key with read access, which is all the script can use. The pure part is the classifier, because the judgement here is entirely about which of four situations a draft flow is in, and that distinction is worth reading in one place rather than inferring from a log line.

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_studio_draft_audit.py
"""Report Twilio Studio Flows whose live definition is not the one on screen.

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_studio_draft_audit")

STUDIO = "https://studio.twilio.com/v2"


def execution_stats(executions):
    """Summarise one Flow's executions. Pure, so the report can be tested.

    Only two things are being asked: is anything running through this Flow at
    all, and how recently. Execution status is `active` or `ended`; an ended
    execution still counts as traffic, because it ran a definition.
    """
    total = 0
    active = 0
    latest = None
    for ex in executions or []:
        total += 1
        if str(ex.get("status") or "").lower() == "active":
            active += 1
        created = str(ex.get("date_created") or "")
        if created and (latest is None or created > latest):
            latest = created
    return {"total": total, "active": active, "latest": latest}


def verdict(flow, stats=None):
    """Classify one Studio Flow. Pure, so the four cases are visible together
    rather than spread across a request loop.

    Returns (state, detail).
    """
    stats = stats or {"total": 0, "active": 0, "latest": None}
    status = str(flow.get("status") or "").lower()
    revision = int(flow.get("revision") or 0)
    total = int(stats.get("total") or 0)

    # An invalid definition cannot be published, so saying "press Publish" is
    # wrong advice: the widget errors have to be fixed first.
    if flow.get("valid") is False:
        return ("invalid",
                "definition does not compile, so publishing it is not possible. "
                "Read errors[] on the single-flow fetch: each entry names the "
                "widget path that broke.")

    if status == "published":
        return ("published",
                "revision %d is published and is what inbound traffic runs." % revision)

    if revision <= 1:
        return ("never-published",
                "revision %d and still a draft: this Flow has never been "
                "published, so a number pointed at it has no definition to run. "
                "Only TEST USERS reach the draft." % revision)

    if total:
        return ("draft-over-traffic",
                "draft at revision %d with %d execution(s) seen (%d active, "
                "latest %s). Live traffic is running an earlier published "
                "revision, not the definition in the Console."
                % (revision, total, int(stats.get("active") or 0),
                   stats.get("latest") or "unknown"))

    return ("draft",
            "draft at revision %d with no executions in the page read. The saved "
            "edits are live nowhere; whoever made them sees them because the "
            "Console shows the draft." % revision)


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):
    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-flows", type=int, default=200,
                    help="stop paging after this many Studio Flows")
    ap.add_argument("--executions", type=int, default=20,
                    help="how many executions to read per draft flow")
    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)

    flows = paged(session, "%s/Flows" % STUDIO, "flows", args.max_flows)
    if not flows:
        log.info("no Studio Flows on this account")
        return 0

    bad = 0
    for flow in flows:
        stats = None
        if str(flow.get("status") or "").lower() != "published":
            executions = paged(session, "%s/Flows/%s/Executions"
                               % (STUDIO, flow.get("sid")), "executions",
                               args.executions)
            stats = execution_stats(executions)

        state, detail = verdict(flow, stats)
        line = "%-18s %s (%s)  %s" % (state, flow.get("sid"),
                                      flow.get("friendly_name", "?"), detail)
        if state == "published":
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        if state == "invalid":
            log.warning("  repair: fix the widget at each errors[].path, validate "
                        "the definition, then publish. GET %s/Flows/%s to read "
                        "errors[] and warnings[].", STUDIO, flow.get("sid"))
            continue
        log.warning("  repair: Console -> Studio -> open %s -> Publish, or update "
                    "%s/Flows/%s with Status=published and a CommitMessage. "
                    "Saving is not publishing.",
                    flow.get("friendly_name", flow.get("sid")), STUDIO,
                    flow.get("sid"))

    log.info("%d flow(s), %d running a definition older than the one on screen",
             len(flows), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-studio-draft-audit.mjs
/**
 * Report Twilio Studio Flows whose live definition is not the one on screen.
 *
 * 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 STUDIO = 'https://studio.twilio.com/v2';

/**
 * Summarise one Flow's executions. Pure, so the report can be tested. Only two
 * things are being asked: is anything running through this Flow at all, and how
 * recently. An ended execution still counts as traffic, because it ran a
 * definition.
 */
export function executionStats(executions) {
  let total = 0;
  let active = 0;
  let latest = null;
  for (const ex of executions ?? []) {
    total += 1;
    if (String(ex.status ?? '').toLowerCase() === 'active') active += 1;
    const created = String(ex.date_created ?? '');
    if (created && (latest === null || created > latest)) latest = created;
  }
  return { total, active, latest };
}

/**
 * Classify one Studio Flow. Pure, so the four cases are visible together rather
 * than spread across a request loop. Returns [state, detail].
 */
export function verdict(flow, stats = { total: 0, active: 0, latest: null }) {
  const status = String(flow.status ?? '').toLowerCase();
  const revision = Number(flow.revision ?? 0);
  const total = Number(stats.total ?? 0);

  // An invalid definition cannot be published, so saying "press Publish" is
  // wrong advice: the widget errors have to be fixed first.
  if (flow.valid === false) {
    return ['invalid',
      'definition does not compile, so publishing it is not possible. Read ' +
      'errors[] on the single-flow fetch: each entry names the widget path that broke.'];
  }

  if (status === 'published') {
    return ['published',
      `revision ${revision} is published and is what inbound traffic runs.`];
  }

  if (revision <= 1) {
    return ['never-published',
      `revision ${revision} and still a draft: this Flow has never been published, ` +
      'so a number pointed at it has no definition to run. Only TEST USERS reach the draft.'];
  }

  if (total) {
    return ['draft-over-traffic',
      `draft at revision ${revision} with ${total} execution(s) seen ` +
      `(${Number(stats.active ?? 0)} active, latest ${stats.latest ?? 'unknown'}). ` +
      'Live traffic is running an earlier published revision, not the definition ' +
      'in the Console.'];
  }

  return ['draft',
    `draft at revision ${revision} with no executions in the page read. The saved ` +
    'edits are live nowhere; whoever made them sees them because the Console shows ' +
    'the draft.'];
}

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 = 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 flows = await paged(auth, `${STUDIO}/Flows`, 'flows');
  if (flows.length === 0) {
    console.log('no Studio Flows on this account');
    return;
  }

  let bad = 0;
  for (const flow of flows) {
    let stats;
    if (String(flow.status ?? '').toLowerCase() !== 'published') {
      const executions = await paged(auth, `${STUDIO}/Flows/${flow.sid}/Executions`,
                                     'executions', 20);
      stats = executionStats(executions);
    }
    const [state, detail] = verdict(flow, stats);
    const line = `${state.padEnd(18)} ${flow.sid} (${flow.friendly_name ?? '?'})  ${detail}`;
    if (state === 'published') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    if (state === 'invalid') {
      console.warn('  repair: fix the widget at each errors[].path, validate the ' +
                   `definition, then publish. GET ${STUDIO}/Flows/${flow.sid} to ` +
                   'read errors[] and warnings[].');
      continue;
    }
    console.warn(`  repair: Console -> Studio -> open ${flow.friendly_name ?? flow.sid}` +
                 ` -> Publish, or update ${STUDIO}/Flows/${flow.sid} with ` +
                 'Status=published and a CommitMessage. Saving is not publishing.');
  }

  console.log(`${flows.length} flow(s), ${bad} running a definition older than the ` +
              'one on screen');
  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

The tests pin down the distinctions that make this report worth reading. A draft with executions is a different sentence from a draft without any, because one of them is an outage and the other is a scratch Flow. Revision 1 in draft has never been published at all. And an invalid definition never gets told to press Publish, because pressing it will not work.

test_twilio_studio_draft_audit.py
from twilio_studio_draft_audit import execution_stats, verdict


def flow(status="draft", revision=4, valid=True, sid="FW1"):
    return {"sid": sid, "friendly_name": "Support IVR", "status": status,
            "revision": revision, "valid": valid}


def execution(status="ended", created="2026-08-01T10:00:00Z"):
    return {"sid": "FN1", "status": status, "date_created": created}


def test_execution_stats_counts_traffic_and_keeps_the_latest_date():
    stats = execution_stats([
        execution("ended", "2026-08-01T10:00:00Z"),
        execution("active", "2026-08-03T09:00:00Z"),
        execution("ended", "2026-08-02T11:00:00Z"),
    ])
    assert stats == {"total": 3, "active": 1, "latest": "2026-08-03T09:00:00Z"}


def test_execution_stats_on_nothing_is_zero_not_an_error():
    assert execution_stats([]) == {"total": 0, "active": 0, "latest": None}
    assert execution_stats(None) == {"total": 0, "active": 0, "latest": None}


def test_a_published_flow_is_the_one_that_runs():
    state, detail = verdict(flow(status="published", revision=9))
    assert state == "published"
    assert "revision 9" in detail


def test_a_draft_with_executions_is_the_outage():
    state, detail = verdict(flow(revision=12),
                            {"total": 40, "active": 2, "latest": "2026-08-28T07:00:00Z"})
    assert state == "draft-over-traffic"
    assert "earlier published revision" in detail
    assert "2026-08-28T07:00:00Z" in detail


def test_a_draft_with_no_traffic_is_quieter_but_still_flagged():
    state, detail = verdict(flow(revision=12), {"total": 0, "active": 0, "latest": None})
    assert state == "draft"
    assert "live nowhere" in detail


def test_revision_one_in_draft_has_never_been_published():
    # There is no earlier published definition to fall back to, so a number
    # pointed at this Flow has nothing at all to execute.
    state, detail = verdict(flow(revision=1), {"total": 0, "active": 0, "latest": None})
    assert state == "never-published"
    assert "TEST USERS" in detail


def test_an_invalid_definition_is_not_told_to_press_publish():
    state, detail = verdict(flow(valid=False, revision=6), {"total": 5, "active": 0,
                                                            "latest": None})
    assert state == "invalid"
    assert "errors[]" in detail
    assert "Publish" not in detail


def test_a_missing_stats_argument_still_classifies():
    assert verdict(flow(revision=3))[0] == "draft"
twilio-studio-draft-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { executionStats, verdict } from './twilio-studio-draft-audit.mjs';

const flow = ({ status = 'draft', revision = 4, valid = true } = {}) => ({
  sid: 'FW1', friendly_name: 'Support IVR', status, revision, valid,
});

const execution = (status = 'ended', created = '2026-08-01T10:00:00Z') => ({
  sid: 'FN1', status, date_created: created,
});

test('execution stats counts traffic and keeps the latest date', () => {
  const stats = executionStats([
    execution('ended', '2026-08-01T10:00:00Z'),
    execution('active', '2026-08-03T09:00:00Z'),
    execution('ended', '2026-08-02T11:00:00Z'),
  ]);
  assert.deepEqual(stats, { total: 3, active: 1, latest: '2026-08-03T09:00:00Z' });
});

test('execution stats on nothing is zero, not an error', () => {
  assert.deepEqual(executionStats([]), { total: 0, active: 0, latest: null });
  assert.deepEqual(executionStats(null), { total: 0, active: 0, latest: null });
});

test('a published flow is the one that runs', () => {
  const [state, detail] = verdict(flow({ status: 'published', revision: 9 }));
  assert.equal(state, 'published');
  assert.match(detail, /revision 9/);
});

test('a draft with executions is the outage', () => {
  const [state, detail] = verdict(flow({ revision: 12 }),
    { total: 40, active: 2, latest: '2026-08-28T07:00:00Z' });
  assert.equal(state, 'draft-over-traffic');
  assert.match(detail, /earlier published revision/);
  assert.match(detail, /2026-08-28T07:00:00Z/);
});

test('a draft with no traffic is quieter but still flagged', () => {
  const [state, detail] = verdict(flow({ revision: 12 }),
    { total: 0, active: 0, latest: null });
  assert.equal(state, 'draft');
  assert.match(detail, /live nowhere/);
});

test('revision one in draft has never been published', () => {
  const [state, detail] = verdict(flow({ revision: 1 }),
    { total: 0, active: 0, latest: null });
  assert.equal(state, 'never-published');
  assert.match(detail, /TEST USERS/);
});

test('an invalid definition is not told to press Publish', () => {
  const [state, detail] = verdict(flow({ valid: false, revision: 6 }),
    { total: 5, active: 0, latest: null });
  assert.equal(state, 'invalid');
  assert.match(detail, /errors\[\]/);
  assert.ok(!/Publish/.test(detail));
});

test('a missing stats argument still classifies', () => {
  assert.equal(verdict(flow({ revision: 3 }))[0], 'draft');
});

FAQ

Why does the Console show my change if it is not live?

Because the Studio canvas renders the draft, which is the working copy you last saved. The runtime executes the most recent published revision. Both exist at once, the canvas only ever shows one of them, and there is no marker on a call or message that tells you which definition served it.

Why did it work when I tested it?

Phone numbers listed under TEST USERS on the Flow execute the draft rather than the published revision. The person who made the edit is usually the person on that list, so the change works for exactly one handset in the company and for nobody else.

Can the script tell me which revision is actually serving traffic?

No, and neither can the API. The Flow resource reports the current revision number and its status; there is no field that names the revision the runtime is executing. What the script establishes is the thing that matters: that the current definition is a draft, so whatever is running is older than what you are looking at.

Is a draft Flow always a problem?

No. A Flow nobody has wired up and nobody executes is just work in progress, which is why the script reports that case in a quieter state. A draft that is taking executions is the one to act on, because real conversations are running a definition no one on the team has read recently.

Why not just publish everything the script finds?

Because a draft can be a half-finished edit, and because a Flow whose valid field is false cannot be published at all. The script prints the repair and stops there: it holds a read-only credential on an account that answers real calls, and publishing a Flow changes what those callers hear.

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.