Skip to content

Diagnostic Twilio

outbound messaging is off, so every send fails with 30037

One tenant stopped receiving messages. Not slowly, not partially — every send from that subaccount comes back with error_code=30037, “outbound message not allowed”, while the other nineteen tenants on the same code, the same numbers and the same deploy are entirely unaffected. Nothing changed on your side, which is exactly what makes it hard to look for.

Read-only key Python and Node.js Tests included
Two baristas smiling behind a counter
Photo by Vincent Leyva on Unsplash
The short answer

Read GET /2010-04-01/Accounts/{AccountSid}.json for the account you are sending as and check status. Anything other than active and that is your answer. Then enumerate GET /2010-04-01/Accounts.json to get the status of every subaccount at once, because the one that is failing is rarely the one you were looking at.

Attribute the failures rather than counting them. Every Message row carries an account_sid. Bucket the 30037s by that field and you learn which account cannot send, and whether it is one of yours at all — a 30037 attributed to a SID that is not in your account list means the sending code is authenticating as something you are not auditing.

The problem in plain words

The failure is per account, and almost every debugging instinct is per request. You check the body, the To number, the From number, the Messaging Service, the campaign. All of them are identical to the nineteen tenants that work. The variable is the account the credential belongs to, and the account is the one thing nobody re-reads because it has been correct since the day it was created.

It gets worse when the answer is that the credential is wrong rather than the account. A key created in the parent and used to send as a subaccount, or a staging SID left in an environment variable, produces exactly the same symptom: sends that should be fine are refused on an account you were not thinking about. The error tells you a send was not allowed. It does not tell you which account did the not-allowing.

And a suspended subaccount is silent. There is no notification into your application, no state change you can subscribe to, no difference in the API response until something tries to send. It is a field on a resource nobody reads until the day it matters.

Same codedeployedtwenty tenantsOne subaccountsuspendedbilling orcomplianceNo notificationstatus is a fieldnobody pollsEvery send30037outbound notallowedBody andnumbers checkedall identical tothe ones that work
Every per-request theory checks out. The variable is the account the credential belongs to, and nobody re-reads that.

Why it happens

Status lives on a resource nobody polls. status on the Account resource is active, suspended or closed. It is read-only information that changes for billing, compliance or fraud reasons, entirely outside your deploy cycle, and there is no reason your application would ever have looked at it.

The parent looks healthy while the child is not. Enumerating subaccounts is the only way to see this. Reading the account your credential belongs to tells you about that account, and the one that has stopped sending is usually a subaccount you have not thought about since onboarding.

A parent API Key cannot read a subaccount's messages. API Keys are scoped to the account they were created in. The status enumeration works from the parent, because subaccounts are listed there; the Messages sweep does not, and has to run with the failing subaccount's own key. That split is why this script takes the account to sweep as an argument.

30037 and a wrong SID are indistinguishable from the error alone. Outbound messaging genuinely disabled, an account suspended for billing, and code that authenticates as the wrong account all produce the same code on the same field. Only the join between the Messages list and the account list separates them.

The fix, as a flow

The script buckets every failure by the account_sid on the message row, because four different causes produce this one error code and only the join against the account list tells them apart.

Accounts plus Messagesbucketed by account_sidActive, no 30037sending normallySuspendedreactivate, or SupportActive but refusedmessaging disabled on itSID not in your listwrong credential entirely
The most useful row is the last one: failures on a SID that is not in your account list at all.

How to fix it

Read the status of the account you are sending as

GET /2010-04-01/Accounts/{AccountSid}.json. status is active, suspended or closed; type is Trial or Full. A suspended or closed account explains every failing send on its own and no further investigation is needed.

Enumerate every subaccount

GET /2010-04-01/Accounts.json?PageSize=100 from the parent, following next_page_uri. This lists the parent and all its subaccounts with their statuses in one sweep, and it is the only way to find the suspended tenant you were not looking for.

Sweep the Messages list and bucket by account_sid

Messages.json?DateSent>={since}&PageSize=1000, filtered client-side for error_code == 30037. Bucket by the account_sid on each row. Read the code as an integer — it arrives as a string often enough that a raw comparison quietly returns nothing.

Join the two, and pay attention to what does not join

A bucket whose account_sid is not in the account list is the most useful finding in the report. It means the credential doing the sending is not one of the accounts you are auditing, which is a configuration problem rather than a Twilio one, and no amount of reading the account you thought you were on would have found it.

Reactivate, or take it to Support

A suspended subaccount is reactivated by writing Status=active to /2010-04-01/Accounts/{SubAccountSid}.json. A closed one is permanent. A parent suspended by Twilio, or messaging disabled at the platform level, only Support can lift. The script prints the exact resource and field and stops there.

How to check it worked

Re-run after reactivating. Every account should read active and the 30037 count should be zero.

python3 twilio_outbound_disabled_audit.py --days 3
# 20 account(s), 0 unable to send

The full code

One paginated GET over the accounts, one over the messages, and a join between them. Read access is enough. The attribution and the verdict are pure functions, because the whole difficulty of this problem is deciding which of four indistinguishable causes you are looking at, and that decision is worth having in a form you can read and test.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 40 Twilio fixes, free and open source.
twilio_outbound_disabled_audit.py
"""Report Twilio accounts that cannot send, and the 30037s attributed to them.

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

import requests

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

HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"

NOT_ALLOWED = 30037


def error_code(message):
    """Read error_code as an integer, or None.

    It arrives as a string often enough that comparing the raw value against
    30037 is how this audit reports nothing on an account that is failing every
    send.
    """
    raw = message.get("error_code")
    if raw is None or raw == "":
        return None
    try:
        return int(raw)
    except (TypeError, ValueError):
        return None


def attribute(messages, code=NOT_ALLOWED):
    """Bucket outbound messages by the account that actually sent them.

    Pure, so the grouping rule can be tested without a network. account_sid is
    the field that distinguishes a subaccount problem from a credential
    problem, and it is on every Message row.
    """
    out = {}
    for m in messages:
        if str(m.get("direction") or "").startswith("inbound"):
            continue
        sid = str(m.get("account_sid") or "unknown")
        row = out.setdefault(sid, {"total": 0, "blocked": 0, "sids": []})
        row["total"] += 1
        if error_code(m) == code:
            row["blocked"] += 1
            if len(row["sids"]) < 3:
                row["sids"].append(m.get("sid"))
    return out


def verdict(account, stats):
    """Classify one account against the 30037s attributed to it. Pure.

    account is None when the failures belong to a SID that is not in the
    account list at all, which is the finding worth having. Returns
    (state, detail).
    """
    total = int((stats or {}).get("total") or 0)
    blocked = int((stats or {}).get("blocked") or 0)

    if account is None:
        return ("unknown-account",
                "%d of %d message(s) rejected with 30037 on an account_sid that "
                "is not in this account list. The code doing the sending is "
                "authenticating as something you are not auditing: check the "
                "Account SID in its environment." % (blocked, total))

    status = str(account.get("status") or "").strip().lower()
    kind = str(account.get("type") or "").strip()

    if status == "closed":
        return ("closed",
                "account is closed, so every send fails permanently. Closure is "
                "not reversible: move the numbers and the traffic to a live "
                "account. %d message(s) attempted in the window." % total)

    if status == "suspended":
        return ("suspended",
                "account is suspended, so outbound messaging is off for every "
                "sender under it. %d message(s) attempted, %d rejected with "
                "30037." % (total, blocked))

    if blocked:
        return ("messaging-disabled",
                "account status is active but %d of %d message(s) were rejected "
                "with 30037. Outbound messaging is disabled on this account "
                "specifically, or the sending credential belongs to a different "
                "one." % (blocked, total))

    return ("active",
            "%s account, %d message(s) in the window, none rejected with 30037"
            % (kind or "unknown", 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 page(session, url, key, params, limit):
    """Page any 2010-04-01 list. next_page_uri is a path, not an absolute URL."""
    out = []
    while url and len(out) < limit:
        body = get(session, url, **params)
        out.extend(body.get(key, []))
        nxt = body.get("next_page_uri")
        url, params = (HOST + nxt) if nxt else None, {}
    return out[:limit]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=3,
                    help="how far back to read the Messages list")
    ap.add_argument("--account",
                    help="account to sweep for messages; defaults to the "
                         "credential's own account. An API Key cannot read a "
                         "subaccount's Messages, so run this with that "
                         "subaccount's own key.")
    ap.add_argument("--max-messages", type=int, default=20000,
                    help="stop paging after this many messages")
    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)

    accounts = page(session, "%s/Accounts.json" % BASE, "accounts",
                    {"PageSize": 100}, 1000)
    by_sid = {str(a.get("sid")): a for a in accounts}

    sweep = args.account or account
    since = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
    messages = page(session, "%s/Accounts/%s/Messages.json" % (BASE, sweep),
                    "messages", {"PageSize": 1000, "DateSent>": since},
                    args.max_messages)
    buckets = attribute(messages)

    bad = 0
    for sid in sorted(set(by_sid) | set(buckets)):
        stats = buckets.get(sid, {"total": 0, "blocked": 0, "sids": []})
        acct = by_sid.get(sid)
        state, detail = verdict(acct, stats)
        label = (acct or {}).get("friendly_name") or sid
        line = "%-18s %s (%s)  %s" % (state, sid, label, detail)
        if state == "active":
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        if stats["sids"]:
            log.warning("  message sids: %s", ", ".join(str(s) for s in stats["sids"]))
        if state == "suspended":
            log.warning("  repair: reactivate by writing Status=active to "
                        "%s/Accounts/%s.json. If the parent was suspended by "
                        "Twilio, only Support can lift it.", BASE, sid)
        elif state == "messaging-disabled":
            log.warning("  repair: confirm the credential's Account SID matches "
                        "this account, then ask Twilio Support to re-enable "
                        "outbound messaging on %s.", sid)
        elif state == "unknown-account":
            log.warning("  repair: no Twilio call fixes this. Find the "
                        "TWILIO_ACCOUNT_SID your sender is configured with and "
                        "reconcile it with the account you meant to send as.")
        else:
            log.warning("  repair: a closed account cannot be reopened. Move "
                        "the numbers and the traffic to a live account.")

    log.info("%d account(s), %d unable to send", len(by_sid), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-outbound-disabled-audit.mjs
/**
 * Report Twilio accounts that cannot send, and the 30037s attributed to them.
 *
 * Read only. GET requests and nothing else: give this an API Key with read
 * access rather than the account auth token. The repair is printed, never
 * performed.
 */
const HOST = 'https://api.twilio.com';
const BASE = `${HOST}/2010-04-01`;

const NOT_ALLOWED = 30037;

/**
 * Read error_code as a number, or null. It arrives as a string often enough
 * that a raw comparison against 30037 reports nothing on an account that is
 * failing every send.
 */
export function errorCode(message) {
  const raw = message.error_code;
  if (raw === null || raw === undefined || raw === '') return null;
  const n = Number(raw);
  return Number.isFinite(n) ? n : null;
}

/**
 * Bucket outbound messages by the account that actually sent them. Pure, so the
 * grouping rule can be tested without a network. account_sid is the field that
 * distinguishes a subaccount problem from a credential problem.
 */
export function attribute(messages, code = NOT_ALLOWED) {
  const out = new Map();
  for (const m of messages) {
    if (String(m.direction ?? '').startsWith('inbound')) continue;
    const sid = String(m.account_sid ?? 'unknown');
    if (!out.has(sid)) out.set(sid, { total: 0, blocked: 0, sids: [] });
    const row = out.get(sid);
    row.total += 1;
    if (errorCode(m) === code) {
      row.blocked += 1;
      if (row.sids.length < 3) row.sids.push(m.sid);
    }
  }
  return out;
}

/**
 * Classify one account against the 30037s attributed to it. Pure. account is
 * null when the failures belong to a SID that is not in the account list at
 * all, which is the finding worth having. Returns [state, detail].
 */
export function verdict(account, stats) {
  const total = Number(stats?.total ?? 0);
  const blocked = Number(stats?.blocked ?? 0);

  if (account === null || account === undefined) {
    return ['unknown-account',
      `${blocked} of ${total} message(s) rejected with 30037 on an account_sid ` +
      'that is not in this account list. The code doing the sending is ' +
      'authenticating as something you are not auditing: check the Account SID ' +
      'in its environment.'];
  }

  const status = String(account.status ?? '').trim().toLowerCase();
  const kind = String(account.type ?? '').trim();

  if (status === 'closed') {
    return ['closed',
      'account is closed, so every send fails permanently. Closure is not ' +
      'reversible: move the numbers and the traffic to a live account. ' +
      `${total} message(s) attempted in the window.`];
  }

  if (status === 'suspended') {
    return ['suspended',
      'account is suspended, so outbound messaging is off for every sender ' +
      `under it. ${total} message(s) attempted, ${blocked} rejected with 30037.`];
  }

  if (blocked) {
    return ['messaging-disabled',
      `account status is active but ${blocked} of ${total} message(s) were ` +
      'rejected with 30037. Outbound messaging is disabled on this account ' +
      'specifically, or the sending credential belongs to a different one.'];
  }

  return ['active',
    `${kind || 'unknown'} account, ${total} message(s) in the window, none ` +
    'rejected with 30037'];
}

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();
}

/** Page any 2010-04-01 list. next_page_uri is a path, not an absolute URL. */
export async function pageAll(auth, url, key, params, limit) {
  const out = [];
  let next = url;
  let p = params;
  while (next && out.length < limit) {
    const body = await get(auth, next, p);
    out.push(...(body[key] ?? []));
    next = body.next_page_uri ? HOST + body.next_page_uri : null;
    p = {};
  }
  return out.slice(0, limit);
}

function argOf(name, fallback) {
  const i = process.argv.indexOf(name);
  return i === -1 ? fallback : process.argv[i + 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 days = Number(argOf('--days', 3));
  const sweep = argOf('--account', account);

  const accounts = await pageAll(auth, `${BASE}/Accounts.json`, 'accounts',
                                 { PageSize: 100 }, 1000);
  const bySid = new Map(accounts.map((a) => [String(a.sid), a]));

  const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
  const messages = await pageAll(auth, `${BASE}/Accounts/${sweep}/Messages.json`,
                                 'messages',
                                 { PageSize: 1000, 'DateSent>': since }, 20000);
  const buckets = attribute(messages);

  let bad = 0;
  const sids = [...new Set([...bySid.keys(), ...buckets.keys()])].sort();
  for (const sid of sids) {
    const stats = buckets.get(sid) ?? { total: 0, blocked: 0, sids: [] };
    const acct = bySid.get(sid) ?? null;
    const [state, detail] = verdict(acct, stats);
    const label = acct?.friendly_name || sid;
    const line = `${state.padEnd(18)} ${sid} (${label})  ${detail}`;
    if (state === 'active') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    if (stats.sids.length) console.warn(`  message sids: ${stats.sids.join(', ')}`);
    if (state === 'suspended') {
      console.warn('  repair: reactivate by writing Status=active to ' +
                   `${BASE}/Accounts/${sid}.json. If the parent was suspended ` +
                   'by Twilio, only Support can lift it.');
    } else if (state === 'messaging-disabled') {
      console.warn("  repair: confirm the credential's Account SID matches this " +
                   'account, then ask Twilio Support to re-enable outbound ' +
                   `messaging on ${sid}.`);
    } else if (state === 'unknown-account') {
      console.warn('  repair: no Twilio call fixes this. Find the ' +
                   'TWILIO_ACCOUNT_SID your sender is configured with and ' +
                   'reconcile it with the account you meant to send as.');
    } else {
      console.warn('  repair: a closed account cannot be reopened. Move the ' +
                   'numbers and the traffic to a live account.');
    }
  }

  console.log(`${bySid.size} account(s), ${bad} unable to send`);
  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

Four causes produce one error code, so the tests are one per cause: suspended, closed, active-but-refused, and failures attributed to a SID that is not yours. The last is the one that saves the most time, because no amount of reading the account you thought you were on will ever surface it.

test_twilio_outbound_disabled_audit.py
from twilio_outbound_disabled_audit import attribute, verdict


def test_attribute_buckets_by_account_sid_and_skips_inbound():
    rows = [
        {"direction": "outbound-api", "account_sid": "ACchild",
         "error_code": "30037", "sid": "SM1"},
        {"direction": "outbound-api", "account_sid": "ACchild",
         "error_code": 30037, "sid": "SM2"},
        {"direction": "outbound-api", "account_sid": "ACparent",
         "error_code": None, "sid": "SM3"},
        {"direction": "inbound", "account_sid": "ACchild",
         "error_code": 30037, "sid": "SM4"},
    ]
    buckets = attribute(rows)
    assert buckets["ACchild"]["total"] == 2
    assert buckets["ACchild"]["blocked"] == 2
    assert buckets["ACparent"]["blocked"] == 0
    assert buckets["ACchild"]["sids"] == ["SM1", "SM2"]


def test_other_error_codes_are_not_counted():
    rows = [{"direction": "outbound-api", "account_sid": "AC1",
             "error_code": 30007, "sid": "SM1"}]
    assert attribute(rows)["AC1"]["blocked"] == 0


def test_suspended_account_explains_every_failure():
    state, detail = verdict({"status": "suspended", "type": "Full"},
                            {"total": 120, "blocked": 120})
    assert state == "suspended"
    assert "every sender" in detail


def test_closed_account_is_permanent():
    state, detail = verdict({"status": "closed", "type": "Full"},
                            {"total": 0, "blocked": 0})
    assert state == "closed"
    assert "not reversible" in detail


def test_active_account_with_30037_means_messaging_is_disabled():
    state, detail = verdict({"status": "active", "type": "Full"},
                            {"total": 90, "blocked": 90})
    assert state == "messaging-disabled"
    assert "disabled on this account" in detail


def test_active_account_with_no_rejections_is_fine():
    state, _ = verdict({"status": "active", "type": "Full"},
                       {"total": 90, "blocked": 0})
    assert state == "active"


def test_failures_on_a_sid_outside_the_account_list_are_a_credential_problem():
    state, detail = verdict(None, {"total": 40, "blocked": 40})
    assert state == "unknown-account"
    assert "Account SID" in detail
twilio-outbound-disabled-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { attribute, verdict } from './twilio-outbound-disabled-audit.mjs';

test('attribute buckets by account_sid and skips inbound', () => {
  const buckets = attribute([
    { direction: 'outbound-api', account_sid: 'ACchild', error_code: '30037', sid: 'SM1' },
    { direction: 'outbound-api', account_sid: 'ACchild', error_code: 30037, sid: 'SM2' },
    { direction: 'outbound-api', account_sid: 'ACparent', error_code: null, sid: 'SM3' },
    { direction: 'inbound', account_sid: 'ACchild', error_code: 30037, sid: 'SM4' },
  ]);
  assert.equal(buckets.get('ACchild').total, 2);
  assert.equal(buckets.get('ACchild').blocked, 2);
  assert.equal(buckets.get('ACparent').blocked, 0);
  assert.deepEqual(buckets.get('ACchild').sids, ['SM1', 'SM2']);
});

test('other error codes are not counted', () => {
  const buckets = attribute([
    { direction: 'outbound-api', account_sid: 'AC1', error_code: 30007, sid: 'SM1' },
  ]);
  assert.equal(buckets.get('AC1').blocked, 0);
});

test('suspended account explains every failure', () => {
  const [state, detail] = verdict({ status: 'suspended', type: 'Full' },
    { total: 120, blocked: 120 });
  assert.equal(state, 'suspended');
  assert.match(detail, /every sender/);
});

test('closed account is permanent', () => {
  const [state, detail] = verdict({ status: 'closed', type: 'Full' },
    { total: 0, blocked: 0 });
  assert.equal(state, 'closed');
  assert.match(detail, /not reversible/);
});

test('active account with 30037 means messaging is disabled', () => {
  const [state, detail] = verdict({ status: 'active', type: 'Full' },
    { total: 90, blocked: 90 });
  assert.equal(state, 'messaging-disabled');
  assert.match(detail, /disabled on this account/);
});

test('active account with no rejections is fine', () => {
  const [state] = verdict({ status: 'active', type: 'Full' },
    { total: 90, blocked: 0 });
  assert.equal(state, 'active');
});

test('failures on a sid outside the account list are a credential problem', () => {
  const [state, detail] = verdict(null, { total: 40, blocked: 40 });
  assert.equal(state, 'unknown-account');
  assert.match(detail, /Account SID/);
});

FAQ

Does 30037 always mean the account is suspended?

No, and that is why the script separates the states. Suspended is one cause. Outbound messaging disabled on an otherwise active account is another. A closed account is a third. Code authenticating as an account you did not intend is a fourth, and it is the one that looks least like a Twilio problem because it is not one.

Why can this script not sweep every subaccount's messages at once?

Because API Keys are scoped to the account they were created in. The parent lists its subaccounts, so status enumeration works from one credential, but a parent key cannot read a subaccount's Messages resource. Run the sweep once per failing subaccount with that subaccount's own read key, which is what --account is for.

Can the script reactivate a suspended subaccount?

It will not. Reactivating an account is a write to a resource that can immediately start spending money, made from a script that runs unattended. It prints the resource, the field and the value, and a person decides whether the reason for the suspension has actually been dealt with.

What if the account list and the failing account_sid do not overlap at all?

Then you are auditing the wrong account, and the report says so rather than reporting nothing. It is the most valuable output of the whole script: the sending credential and the auditing credential belong to different accounts, which usually means a stale environment variable pointing at staging.

Is a suspended parent the same as a suspended subaccount?

In effect, worse. A suspended parent takes every subaccount under it with it, so a report full of failing tenants can have a single cause sitting above all of them. Reactivating a subaccount will not help while the parent is suspended, and lifting a parent suspension is a Support conversation rather than an API call.

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.