Skip to content

Diagnostic Twilio

an A2P brand parked at PENDING for weeks with no callback

There is no error to find. errors[] is empty, nothing is red in the console, and every US send still comes back 30034. The brand was submitted five weeks ago, the status callback fired into a service that had not been deployed yet, and since then status has read PENDING with nobody looking at it.

Read-only key Python and Node.js Tests included
A white board with writing on it
Photo by sarah b on Unsplash
The short answer

Read GET https://messaging.twilio.com/v1/a2p/BrandRegistrations and flag any item where status is PENDING or IN_REVIEW and date_created is more than about seven days old. Confirm with tcr_id, which stays null until The Campaign Registry accepts the brand, and with brand_score, which is null for the same reason.

The two waiting states are not the same waiting. PENDING means automated registry validation has not finished, which normally takes minutes; past a week it is stuck and worth a support ticket quoting the BN… SID. IN_REVIEW means a human is doing third-party vetting and there is nothing for you to do but wait.

The problem in plain words

Every other A2P failure hands you something to read. A rejected brand has errors[]. A rejected campaign has a code and a fields array. This one has an empty object and a timestamp, which means the only evidence that anything is wrong is arithmetic: the difference between date_created and today.

That is exactly the kind of evidence a monitoring setup built around callbacks cannot produce. The integration registered the brand, wired a status_callback, and treated the absence of a callback as the absence of news. A callback delivered to a URL that returned 502, or to a service that was deployed the following week, is indistinguishable from a brand still being reviewed. So the brand sits, no campaign can attach to it, no number can be registered under a campaign that does not exist, and the launch date passes with the team debugging the send path.

Brand submittedstatus PENDINGCallback firesonceendpoint returns502Nobody pollsno error to findNo campaign canattachtcr_id still nullEvery US send30034launch date passes
Nothing here returns an error. The callback fired into a service that was not deployed yet, and a missed callback looks exactly like a review still running.

Why it happens

The waiting state has no failure signal by design. PENDING is the correct, healthy state for the first few minutes of a brand's life. There is no field that says "this has been pending too long", because how long is too long is a judgement about your launch, not about the resource. The script has to supply the threshold; the API will never volunteer it.

Callbacks are the only notification and they are at-most-once in practice. A brand transition fires status_callback once. If your endpoint is down, mid-deploy, or behind a signature check that rejects it, the transition is still real and you simply do not hear about it. Polling one list endpoint removes the entire dependency.

The send-side error says nothing about which layer is stuck. Traffic fails with 30034, which reads the same for a brand awaiting review, a brand that was rejected, a campaign that never finished vetting and a number outside the sender pool. Four different waits, one code.

The obvious workaround makes it worse. The instinct after two silent weeks is to register the brand again, which produces two brands on one EIN and gets the duplicate rejected with 30898. Reporting the duplicates alongside the stall is what stops that, and duplicates are visible from the same list response.

The fix, as a flow

The classifier takes the current time as an argument rather than reading the clock, because the only evidence of this problem is arithmetic on date_created and a test has to be able to pick the day.

GET BrandRegistrationsstatus, date_created, tcr_idPENDING under 7 daysvalidation still runningIN_REVIEW, any agemanual vetting, waitPENDING over 7 daysstuck, raise a ticketTwo brands, one bundleduplicate, 30898 next
PENDING and IN_REVIEW are not the same wait. One is a support ticket past a week; the other is a human doing their job.

How to fix it

Page the brand list and keep date_created

GET https://messaging.twilio.com/v1/a2p/BrandRegistrations. This resource returns its items under data rather than a resource-named key, and meta.next_page_url is absolute. date_created is the timestamp everything here is measured against, so read it on every item rather than only on the ones that look interesting.

Separate PENDING from IN_REVIEW before you time either of them

PENDING is automated registry validation and usually resolves in minutes. IN_REVIEW is manual third-party vetting and legitimately takes days. Reporting them under one heading produces a list where the actionable rows and the rows you must not touch look identical.

Corroborate the status with tcr_id and brand_score

Both are null while a brand is unapproved. A brand that says PENDING and carries a tcr_id is a disagreement between two fields on one object, and a script that reports it rather than resolving it is more useful than one that quietly trusts whichever field it happened to read first.

Flag brands sharing a Customer Profile bundle

customer_profile_bundle_sid appears on every brand. Two brands pointing at one bundle is the duplicate-registration mistake made visible in the same response that shows the stall, and it is worth reporting before somebody makes a third.

Open a ticket for PENDING, and only wait on IN_REVIEW

There is no API repair for either. Past roughly seven days at PENDING, raise a Twilio Support ticket quoting the BN… SID. At IN_REVIEW, no customer action is required and resubmitting or re-registering only adds a duplicate to the queue.

How to check it worked

Re-run the script. Nothing should be waiting past your threshold, and no two brands should share one Customer Profile bundle.

python3 twilio_a2p_brand_stall_audit.py --stall-days 7
# 3 brand(s), 0 stalled in review

The full code

One paginated GET and some arithmetic — an API Key with read access is all it needs. The classifier takes the brand and the current time as arguments rather than reading the clock itself, which is what makes "this has been PENDING for nine days" a testable statement instead of a thing that only happens on the day you run it.

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_a2p_brand_stall_audit.py
"""Report A2P 10DLC brands that have been waiting for review too long.

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
from datetime import datetime, timezone

import requests

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

MSG = "https://messaging.twilio.com/v1"

# The two waiting states mean different things and want different responses, so
# they are never collapsed into one bucket anywhere in this script.
AUTOMATED = "PENDING"      # registry validation, normally minutes
MANUAL = "IN_REVIEW"       # third party vetting, legitimately days
SETTLED = ("APPROVED", "FAILED", "SUSPENDED")
DELETING = ("DELETION_PENDING", "DELETION_FAILED")


def parsed_time(value):
    """Parse a Twilio ISO 8601 timestamp into an aware datetime. Pure.

    Returns None when the field is absent or will not parse, because a brand
    with an unreadable date is a finding of its own rather than a brand that is
    zero days old.
    """
    text = str(value or "").strip()
    if not text:
        return None
    if text.endswith("Z"):
        text = text[:-1] + "+00:00"
    try:
        when = datetime.fromisoformat(text)
    except ValueError:
        return None
    return when if when.tzinfo else when.replace(tzinfo=timezone.utc)


def age_days(brand, now):
    """How many days ago the brand was created, or None. Pure."""
    when = parsed_time(brand.get("date_created"))
    if when is None:
        return None
    return (now - when).total_seconds() / 86400.0


def duplicate_bundles(brands):
    """Customer Profile bundles carrying more than one brand, sorted. Pure.

    Registering a second brand because the first went quiet is the usual
    response to a stall, and it is the one that gets rejected with 30898. The
    same list response that shows the stall shows the duplicate, so report both
    together or the reader will make the mistake this note exists to prevent.
    """
    counts = {}
    for brand in brands:
        bundle = str(brand.get("customer_profile_bundle_sid") or "").strip()
        if bundle:
            counts[bundle] = counts.get(bundle, 0) + 1
    return sorted(b for b, n in counts.items() if n > 1)


def verdict(brand, now, stall_days=7):
    """Classify one BrandRegistration against the clock. Pure, so a nine day
    stall is testable on any day of the year.

    Returns (state, detail).
    """
    status = str(brand.get("status") or "").upper()
    tcr = str(brand.get("tcr_id") or "").strip()
    age = age_days(brand, now)

    if status in SETTLED:
        return ("settled",
                "status is %s: this brand has a verdict, not a wait." % status)
    if status in DELETING:
        return ("deleting",
                "status is %s: on its way out, not waiting for review." % status)
    if status not in (AUTOMATED, MANUAL):
        return ("unknown-status",
                "status is %s, which this script does not recognise."
                % (status or "unset"))

    if tcr:
        return ("waiting-with-tcr-id",
                "status is %s but tcr_id is %s, which only an accepted brand "
                "should have. Two fields on one object disagree; report it "
                "rather than picking a side." % (status, tcr))

    if age is None:
        return ("undated",
                "status is %s and date_created is missing or unparseable, so "
                "there is no way to tell a fresh submission from a stall."
                % status)

    if age <= stall_days:
        if status == AUTOMATED:
            return ("pending",
                    "PENDING for %.1f day(s). Registry validation normally "
                    "finishes in minutes; this is still inside the window." % age)
        return ("in-review",
                "IN_REVIEW for %.1f day(s). A human is vetting it and no "
                "customer action is required." % age)

    if status == AUTOMATED:
        return ("pending-stalled",
                "PENDING for %.1f day(s), past the %d day threshold. Automated "
                "validation does not take this long; nothing here will change "
                "on its own." % (age, stall_days))
    return ("in-review-long",
            "IN_REVIEW for %.1f day(s). Still the correct state, still nothing "
            "to submit, but long enough to plan around rather than wait on."
            % age)


def get(session, url, **params):
    r = session.get(url, params=params, timeout=30)
    if r.status_code in (401, 403):
        raise SystemExit("%d from Twilio: check TWILIO_ACCOUNT_SID and that the "
                         "API key belongs to that account with read access"
                         % r.status_code)
    r.raise_for_status()
    return r.json()


def list_brands(session, limit=500):
    """Page the brand list.

    This resource returns its items under `data`, not under a resource-named key
    like the rest of messaging v1. meta.next_page_url is absolute.
    """
    url = MSG + "/a2p/BrandRegistrations"
    out = []
    while url and len(out) < limit:
        page = get(session, url, PageSize=50)
        out.extend(page.get("data", []))
        url = (page.get("meta") or {}).get("next_page_url")
    return out[:limit]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--stall-days", type=float, default=7.0,
                    help="how long a brand may wait before it is reported")
    ap.add_argument("--max-brands", type=int, default=500)
    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)

    brands = list_brands(session, args.max_brands)
    if not brands:
        log.info("no A2P brand registrations on this account")
        return 0

    now = datetime.now(timezone.utc)
    stalled = 0
    for brand in brands:
        state, detail = verdict(brand, now, args.stall_days)
        sid = brand.get("sid", "?")
        line = "%-19s %s  %s" % (state, sid, detail)
        if state in ("pending", "in-review", "settled", "deleting"):
            log.info(line)
            continue
        stalled += 1
        log.warning(line)
        if state == "pending-stalled":
            log.warning("  repair: none by API. Open a Twilio Support ticket "
                        "quoting brand %s. Do not register a second brand on the "
                        "same EIN, which is rejected with 30898", sid)
        elif state == "in-review-long":
            log.warning("  repair: none, and none wanted. Gate the launch on "
                        "status APPROVED and send US traffic over a verified "
                        "toll-free number until then")

    for bundle in duplicate_bundles(brands):
        stalled += 1
        log.warning("duplicate-bundle    %s  more than one brand points at this "
                    "Customer Profile. Duplicates on one EIN are rejected with "
                    "30898; keep the oldest and delete the rest", bundle)

    log.info("%d brand(s), %d stalled in review", len(brands), stalled)
    return 1 if stalled else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-a2p-brand-stall-audit.mjs
/**
 * Report A2P 10DLC brands that have been waiting for review too long.
 *
 * 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 MSG = 'https://messaging.twilio.com/v1';

// The two waiting states mean different things and want different responses.
const AUTOMATED = 'PENDING';   // registry validation, normally minutes
const MANUAL = 'IN_REVIEW';    // third party vetting, legitimately days
const SETTLED = ['APPROVED', 'FAILED', 'SUSPENDED'];
const DELETING = ['DELETION_PENDING', 'DELETION_FAILED'];

/**
 * Parse a Twilio ISO 8601 timestamp to epoch milliseconds, or null. Pure.
 * A brand with an unreadable date is a finding, not a brand zero days old.
 */
export function parsedTime(value) {
  const text = String(value ?? '').trim();
  if (!text) return null;
  const ms = Date.parse(text);
  return Number.isNaN(ms) ? null : ms;
}

/** How many days ago the brand was created, or null. Pure. */
export function ageDays(brand, nowMs) {
  const when = parsedTime(brand.date_created);
  return when === null ? null : (nowMs - when) / 86400000;
}

/**
 * Customer Profile bundles carrying more than one brand, sorted. Pure.
 * Registering a second brand because the first went quiet is the usual response
 * to a stall, and it is the one rejected with 30898.
 */
export function duplicateBundles(brands) {
  const counts = new Map();
  for (const brand of brands) {
    const bundle = String(brand.customer_profile_bundle_sid ?? '').trim();
    if (bundle) counts.set(bundle, (counts.get(bundle) ?? 0) + 1);
  }
  return [...counts.entries()].filter(([, n]) => n > 1)
    .map(([b]) => b).sort();
}

/**
 * Classify one BrandRegistration against the clock. Pure, so a nine day stall
 * is testable on any day of the year. Returns [state, detail].
 */
export function verdict(brand, nowMs, stallDays = 7) {
  const status = String(brand.status ?? '').toUpperCase();
  const tcr = String(brand.tcr_id ?? '').trim();
  const age = ageDays(brand, nowMs);

  if (SETTLED.includes(status)) {
    return ['settled', `status is ${status}: this brand has a verdict, not a wait.`];
  }
  if (DELETING.includes(status)) {
    return ['deleting',
      `status is ${status}: on its way out, not waiting for review.`];
  }
  if (status !== AUTOMATED && status !== MANUAL) {
    return ['unknown-status',
      `status is ${status || 'unset'}, which this script does not recognise.`];
  }

  if (tcr) {
    return ['waiting-with-tcr-id',
      `status is ${status} but tcr_id is ${tcr}, which only an accepted brand ` +
      'should have. Two fields on one object disagree; report it rather than ' +
      'picking a side.'];
  }

  if (age === null) {
    return ['undated',
      `status is ${status} and date_created is missing or unparseable, so there ` +
      'is no way to tell a fresh submission from a stall.'];
  }

  if (age <= stallDays) {
    if (status === AUTOMATED) {
      return ['pending',
        `PENDING for ${age.toFixed(1)} day(s). Registry validation normally ` +
        'finishes in minutes; this is still inside the window.'];
    }
    return ['in-review',
      `IN_REVIEW for ${age.toFixed(1)} day(s). A human is vetting it and no ` +
      'customer action is required.'];
  }

  if (status === AUTOMATED) {
    return ['pending-stalled',
      `PENDING for ${age.toFixed(1)} day(s), past the ${stallDays} day ` +
      'threshold. Automated validation does not take this long; nothing here ' +
      'will change on its own.'];
  }
  return ['in-review-long',
    `IN_REVIEW for ${age.toFixed(1)} day(s). Still the correct state, still ` +
    'nothing to submit, but long enough to plan around rather than wait on.'];
}

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 the brand list. This resource returns items under `data`. */
export async function listBrands(auth, limit = 500) {
  let url = `${MSG}/a2p/BrandRegistrations`;
  const out = [];
  while (url && out.length < limit) {
    const page = await get(auth, url, { PageSize: 50 });
    out.push(...(page.data ?? []));
    url = page.meta?.next_page_url ?? null;
  }
  return out.slice(0, limit);
}

async function main() {
  const stallDays = Number(process.env.STALL_DAYS ?? 7);
  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 brands = await listBrands(auth);
  if (brands.length === 0) {
    console.log('no A2P brand registrations on this account');
    return;
  }

  const now = Date.now();
  let stalled = 0;
  for (const brand of brands) {
    const [state, detail] = verdict(brand, now, stallDays);
    const sid = brand.sid ?? '?';
    const line = `${state.padEnd(19)} ${sid}  ${detail}`;
    if (['pending', 'in-review', 'settled', 'deleting'].includes(state)) {
      console.log(line);
      continue;
    }
    stalled += 1;
    console.warn(line);
    if (state === 'pending-stalled') {
      console.warn(`  repair: none by API. Open a Twilio Support ticket quoting ` +
                   `brand ${sid}. Do not register a second brand on the same ` +
                   'EIN, which is rejected with 30898');
    } else if (state === 'in-review-long') {
      console.warn('  repair: none, and none wanted. Gate the launch on status ' +
                   'APPROVED and send US traffic over a verified toll-free ' +
                   'number until then');
    }
  }

  for (const bundle of duplicateBundles(brands)) {
    stalled += 1;
    console.warn(`duplicate-bundle    ${bundle}  more than one brand points at ` +
                 'this Customer Profile. Duplicates on one EIN are rejected with ' +
                 '30898; keep the oldest and delete the rest');
  }

  console.log(`${brands.length} brand(s), ${stalled} stalled in review`);
  process.exitCode = stalled ? 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 cases worth pinning are the ones where a clock is involved: the same brand is fine on day two and a support ticket on day nine, and the classifier has to say so without reading the system clock. The other two are the brand whose date_created will not parse, which must not silently become zero days old, and IN_REVIEW past the threshold, which is reported but is still not something to act on.

test_twilio_a2p_brand_stall_audit.py
from datetime import datetime, timezone

from twilio_a2p_brand_stall_audit import (age_days, duplicate_bundles,
                                          parsed_time, verdict)

NOW = datetime(2026, 3, 10, tzinfo=timezone.utc)


def brand(**kw):
    base = {"sid": "BN0123456789", "status": "PENDING",
            "date_created": "2026-03-09T12:00:00Z", "tcr_id": None}
    base.update(kw)
    return base


def test_pending_inside_the_window_is_not_a_finding():
    state, _ = verdict(brand(), NOW)
    assert state == "pending"


def test_the_same_brand_is_a_finding_nine_days_later():
    # Nothing about the object changes. Only the clock does, which is why the
    # classifier takes now as an argument instead of reading it.
    state, detail = verdict(brand(date_created="2026-03-01T00:00:00Z"), NOW)
    assert state == "pending-stalled"
    assert "9.0 day(s)" in detail


def test_in_review_past_the_threshold_is_reported_but_kept_separate():
    state, detail = verdict(
        brand(status="IN_REVIEW", date_created="2026-02-01T00:00:00Z"), NOW)
    assert state == "in-review-long"
    assert "nothing to submit" in detail


def test_an_unparseable_date_is_not_treated_as_zero_days_old():
    assert age_days(brand(date_created="last tuesday"), NOW) is None
    assert verdict(brand(date_created=""), NOW)[0] == "undated"


def test_a_naive_timestamp_is_read_as_utc():
    when = parsed_time("2026-03-09T12:00:00")
    assert when == datetime(2026, 3, 9, 12, tzinfo=timezone.utc)


def test_waiting_with_a_tcr_id_is_a_disagreement_not_a_wait():
    state, detail = verdict(brand(tcr_id="BXXXXXXX"), NOW)
    assert state == "waiting-with-tcr-id"
    assert "picking a side" in detail


def test_a_settled_brand_belongs_to_a_different_report():
    assert verdict(brand(status="FAILED"), NOW)[0] == "settled"
    assert verdict(brand(status="APPROVED"), NOW)[0] == "settled"


def test_two_brands_on_one_customer_profile_are_reported():
    brands = [brand(sid="BN1", customer_profile_bundle_sid="BU1"),
              brand(sid="BN2", customer_profile_bundle_sid="BU1"),
              brand(sid="BN3", customer_profile_bundle_sid="BU2")]
    assert duplicate_bundles(brands) == ["BU1"]


def test_brands_with_no_bundle_are_not_counted_as_duplicates_of_each_other():
    assert duplicate_bundles([brand(sid="BN1"), brand(sid="BN2")]) == []
twilio-a2p-brand-stall-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ageDays, duplicateBundles, parsedTime, verdict }
  from './twilio-a2p-brand-stall-audit.mjs';

const NOW = Date.parse('2026-03-10T00:00:00Z');

function brand(over = {}) {
  return { sid: 'BN0123456789', status: 'PENDING',
           date_created: '2026-03-09T12:00:00Z', tcr_id: null, ...over };
}

test('pending inside the window is not a finding', () => {
  assert.equal(verdict(brand(), NOW)[0], 'pending');
});

test('the same brand is a finding nine days later', () => {
  const [state, detail] = verdict(brand({ date_created: '2026-03-01T00:00:00Z' }),
                                  NOW);
  assert.equal(state, 'pending-stalled');
  assert.match(detail, /9\.0 day\(s\)/);
});

test('in review past the threshold is reported but kept separate', () => {
  const [state, detail] = verdict(
    brand({ status: 'IN_REVIEW', date_created: '2026-02-01T00:00:00Z' }), NOW);
  assert.equal(state, 'in-review-long');
  assert.match(detail, /nothing to submit/);
});

test('an unparseable date is not treated as zero days old', () => {
  assert.equal(ageDays(brand({ date_created: 'last tuesday' }), NOW), null);
  assert.equal(verdict(brand({ date_created: '' }), NOW)[0], 'undated');
});

test('a valid timestamp parses to epoch milliseconds', () => {
  assert.equal(parsedTime('2026-03-09T12:00:00Z'),
               Date.parse('2026-03-09T12:00:00Z'));
});

test('waiting with a tcr_id is a disagreement, not a wait', () => {
  const [state, detail] = verdict(brand({ tcr_id: 'BXXXXXXX' }), NOW);
  assert.equal(state, 'waiting-with-tcr-id');
  assert.match(detail, /picking a side/);
});

test('a settled brand belongs to a different report', () => {
  assert.equal(verdict(brand({ status: 'FAILED' }), NOW)[0], 'settled');
  assert.equal(verdict(brand({ status: 'APPROVED' }), NOW)[0], 'settled');
});

test('two brands on one customer profile are reported', () => {
  assert.deepEqual(duplicateBundles([
    brand({ sid: 'BN1', customer_profile_bundle_sid: 'BU1' }),
    brand({ sid: 'BN2', customer_profile_bundle_sid: 'BU1' }),
    brand({ sid: 'BN3', customer_profile_bundle_sid: 'BU2' }),
  ]), ['BU1']);
});

test('brands with no bundle are not duplicates of each other', () => {
  assert.deepEqual(duplicateBundles([brand({ sid: 'BN1' }), brand({ sid: 'BN2' })]),
                   []);
});

FAQ

How long is too long for a brand to sit at PENDING?

PENDING is automated registry validation and normally completes in minutes. Twilio's own guidance treats more than seven days as abnormal, which is why the script defaults there and takes --stall-days so you can tighten it. IN_REVIEW is different: it is manual vetting and days are ordinary.

Is there any API call that speeds this up?

No. There is no resubmit that helps a brand which has not been reviewed yet, and no field you can set to escalate. For PENDING past a week the only route is a Twilio Support ticket quoting the BN SID; for IN_REVIEW there is genuinely nothing to do.

Why does tcr_id matter if status already says PENDING?

Because it is a second opinion from a different system. tcr_id is assigned by The Campaign Registry when it accepts the brand, and brand_score is populated later still. Both null alongside PENDING is a consistent story. A tcr_id present alongside PENDING is not, and that is worth surfacing rather than smoothing over.

Should I register a second brand while the first is stuck?

No. Two brands on the same EIN get the duplicate rejected with 30898, and you now have two registrations to unpick instead of one stall to chase. The script reports brands sharing a Customer Profile bundle for exactly this reason.

We wired a status callback. Why poll at all?

A brand transition fires the callback once. If the endpoint was down, mid-deploy, or rejecting the request behind a signature check, the transition still happened and you did not hear about it. A missed callback and an ongoing review look identical from your side; one GET tells them apart.

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.