Skip to content

Diagnostic Twilio

brand failed 30799: the EIN does not match the legal name

The company has traded under one name since 2019 and everybody, including the people who filled in the registration, calls it that. The IRS calls it something else, ending in , Inc., and The Campaign Registry only reads the IRS. That single disagreement is 30799, and it is the reason behind most Standard brand rejections.

Read-only key Python and Node.js Tests included
Man stands by a typewriter stand under a colorful umbrella.
Photo by Abhishek Ravi on Unsplash
The short answer

Read GET https://messaging.twilio.com/v1/a2p/BrandRegistrations/{BrandSid} and look in errors[] for 30799. Each entry carries code, description, fields and url; fields names the attributes the registry objected to.

The edit does not happen on the brand. The brand is assembled from the Trust Hub Customer Profile named in customer_profile_bundle_sid, so read that bundle's business End-User with GET https://trusthub.twilio.com/v1/CustomerProfiles/{BundleSid}/EntityAssignments, correct the legal name, address and business_registration_identifier there to match the IRS or CRA record exactly, and only then resubmit the brand.

The problem in plain words

30799 is the least specific-sounding of the brand codes — "unable to verify registration details" — and it is the most specific in practice. The registry is doing one thing: taking the tax identifier you supplied and looking it up against public records, then requiring that the legal name and address on that record match what you sent. It is a string comparison against a government database, and it does not know about trading names.

So the rejection lands on a brand that everybody involved believes is correct, and the natural response is to resubmit it. There are three free resubmissions before 21724 starts refusing them, and a resubmission of the same Customer Profile produces the same lookup and the same answer. The information that would have stopped that round trip is the fields array, which names whether the objection was to the identifier, the name or the address.

Profilesubmittedthe name everyoneusesRegistry looksup EINagainst publicrecordsLegal namedifferssuffix, address orDBABrand FAILED30799unable to verifyResubmittedunchangedsame lookup, sameanswer
Every value submitted is correct as far as the people submitting it know. The registry compares against the IRS file and nothing else.

Why it happens

The name people use is not the name on the record. A DBA, a shortened form, a dropped LLC or Inc., a merged entity still trading under the acquired brand: all of them are the real company and none of them is what the IRS has on file. The registry compares against the file.

The identifier is often the wrong kind of number. A sole trader supplying an SSN where an EIN is required, a Canadian company supplying a provincial number rather than the federal BN, a public company whose ticker cannot be verified. Each fails as an identity mismatch, and none of them looks wrong on inspection.

Government and nonprofit brands fail the same way for a different reason. A nonprofit with the wrong 501(c) subsection code, or an entity registered with the wrong company_type, produces the same code from the same lookup. The fix is still a Customer Profile edit, just on a different attribute.

The object you edit is not the object that failed. The error is on the brand; the data is on the Trust Hub Customer Profile bundle. Editing anything on the brand itself changes nothing, which makes the first attempt at a fix feel like the API is ignoring you.

The fix, as a flow

The script prints the Customer Profile bundle rather than following it into an edit, because the repair is a person correcting a business record against a tax filing and no script should do that on a schedule.

GET the brand, read errors[]code, fields, bundle sidAPPROVED and verifiedidentity matchedFAILED on another codea different profile30799 with fieldsedit exactly those30799, no fieldsname, address, tax id
The error is on the brand and the data is on the Trust Hub profile, so the object to read is never the object to edit.

How to fix it

Fetch the brand and read errors[] for 30799

GET /v1/a2p/BrandRegistrations/{BrandSid}. Note that the brand resource spells the key code where the campaign resource spells it error_code; read both or you will report every rejection as unrecognised. Ignore failure_reason and brand_feedback, which are deprecated.

Take the objection from fields, not from the description

fields is a list of the attributes that triggered the code. It is the difference between re-checking three things and re-checking one. When it is absent, fall back to the identity triple — legal company name, registered address and business_registration_identifier — because those are what the lookup compares.

Follow customer_profile_bundle_sid to the data that actually failed

GET https://trusthub.twilio.com/v1/CustomerProfiles/{BundleSid}/EntityAssignments lists the End-User and supporting document objects attached to the profile. The business End-User holds the name, address and registration identifier that were submitted, and it is the object somebody has to edit.

Separate 30799 from the other reasons a brand can fail

A brand can be FAILED for reasons that have nothing to do with identity, and those want a different page and a different fix. Reporting "failed" for both wastes the reader's time on the wrong bundle; reporting the code and the named fields does not.

Correct the profile, then resubmit once

Console → Trust Hub → Customer Profiles, edit the business End-User so legal name, address and identifier match the IRS or CRA record character for character, then resubmit the brand. Three resubmissions are free and a fourth returns 21724, so spend them on a change you have verified rather than on a guess.

How to check it worked

Re-run the script. No brand should be carrying 30799, and every approved brand should have an identity status above self-declared.

python3 twilio_a2p_brand_identity_audit.py
# 2 brand(s), 0 with an identity mismatch

The full code

One paginated GET over the brands, and the Customer Profile bundle SID printed rather than followed — the repair is a human editing a business record, and no script should be doing that on a schedule. The classifier is pure and singles out 30799, because a report that says only "failed" sends the reader to re-check the whole profile when the API already named the field.

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_identity_audit.py
"""Report A2P brands rejected because the tax ID and legal name disagree.

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

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

MISMATCH = "30799"

# What the registry actually compares when it resolves a tax identifier against
# public records. Used only when errors[] names no fields, so the report still
# says where to look instead of saying "failed".
IDENTITY_TRIPLE = ("legal company name", "registered business address",
                   "business_registration_identifier")

# An APPROVED brand can still be carrying an identity that was never checked
# against anything external, which is a weaker position than it looks.
WEAK_IDENTITY = ("SELF_DECLARED", "UNVERIFIED")


def error_code(err):
    """Read the code off one errors[] entry, as a string.

    The brand resource spells the key code and the campaign resource spells it
    error_code. Reading both costs one loop and removes a whole class of silent
    misreport.
    """
    for k in ("error_code", "code"):
        v = err.get(k)
        if v not in (None, ""):
            return str(v)
    return ""


def edit_targets(errors):
    """What to correct on the Customer Profile, from the 30799 entries. Pure.

    Prefers what the API named in `fields`, because that is one thing to check
    rather than three. Falls back to the identity triple only when the entry
    named nothing, so the report is never reduced to the word "failed".
    """
    named = []
    saw_mismatch = False
    for err in errors:
        if error_code(err) != MISMATCH:
            continue
        saw_mismatch = True
        for f in (err.get("fields") or []):
            text = str(f).strip()
            if text and text not in named:
                named.append(text)
    if named:
        return named
    return list(IDENTITY_TRIPLE) if saw_mismatch else []


def verdict(brand):
    """Classify one BrandRegistration by what it says about identity. Pure.

    Returns (state, detail).
    """
    status = str(brand.get("status") or "").upper()
    errors = brand.get("errors") or []
    codes = [error_code(e) for e in errors]
    identity = str(brand.get("identity_status") or "").upper()

    if MISMATCH in codes:
        targets = ", ".join(edit_targets(errors))
        return ("identity-mismatch",
                "%s: the registry could not match the submitted identity against "
                "public records. Correct %s on the Customer Profile, not on the "
                "brand." % (MISMATCH, targets))

    if status == "FAILED":
        other = ", ".join(c for c in codes if c) or "no code"
        return ("failed-elsewhere",
                "FAILED on %s, which is not an identity mismatch. The Customer "
                "Profile business details are not the thing to re-check."
                % other)

    if status == "SUSPENDED":
        return ("suspended",
                "brand is SUSPENDED, which is a compliance decision rather than "
                "an identity check. Nothing here is fixed by editing the "
                "profile.")

    if status in ("PENDING", "IN_REVIEW"):
        return ("in-review",
                "brand is %s: the identity lookup has not returned a verdict "
                "yet." % status)

    if status == "APPROVED":
        if identity in WEAK_IDENTITY:
            return ("approved-unverified-identity",
                    "APPROVED with identity_status %s, so the business identity "
                    "was taken as declared rather than matched to a record. A "
                    "later re-vet can still turn up %s." % (identity, MISMATCH))
        return ("approved",
                "APPROVED with identity_status %s" % (identity or "unset"))

    return ("unknown-status",
            "status is %s, which this script does not recognise."
            % (status or "unset"))


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. Items come back under `data` on this resource."""
    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("--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

    bad = 0
    for brand in brands:
        state, detail = verdict(brand)
        sid = brand.get("sid", "?")
        line = "%-28s %s  %s" % (state, sid, detail)
        if state in ("approved", "in-review"):
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        for err in brand.get("errors") or []:
            if err.get("url"):
                log.warning("  %s -> %s", error_code(err) or "?", err["url"])
        if state == "identity-mismatch":
            bundle = brand.get("customer_profile_bundle_sid", "BU...")
            log.warning("  read: GET %s/CustomerProfiles/%s/EntityAssignments to "
                        "find the business End-User holding those fields",
                        TRUSTHUB, bundle)
            log.warning("  repair: edit that End-User in Trust Hub so the legal "
                        "name, address and registration identifier match the "
                        "IRS or CRA record exactly, then resubmit brand %s. "
                        "Three resubmissions are free; a fourth returns 21724",
                        sid)

    log.info("%d brand(s), %d with an identity mismatch", len(brands), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-a2p-brand-identity-audit.mjs
/**
 * Report A2P brands rejected because the tax ID and legal name disagree.
 *
 * 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';
const TRUSTHUB = 'https://trusthub.twilio.com/v1';

const MISMATCH = '30799';

// What the registry compares when it resolves a tax identifier against public
// records. Used only when errors[] names no fields.
const IDENTITY_TRIPLE = ['legal company name', 'registered business address',
                         'business_registration_identifier'];

const WEAK_IDENTITY = ['SELF_DECLARED', 'UNVERIFIED'];

/**
 * Read the code off one errors[] entry, as a string. The brand resource spells
 * the key code and the campaign resource spells it error_code.
 */
export function errorCode(err) {
  for (const k of ['error_code', 'code']) {
    const v = err[k];
    if (v !== undefined && v !== null && v !== '') return String(v);
  }
  return '';
}

/**
 * What to correct on the Customer Profile, from the 30799 entries. Pure.
 * Prefers what the API named in `fields`; falls back to the identity triple
 * only when the entry named nothing.
 */
export function editTargets(errors) {
  const named = [];
  let sawMismatch = false;
  for (const err of errors) {
    if (errorCode(err) !== MISMATCH) continue;
    sawMismatch = true;
    for (const f of err.fields ?? []) {
      const text = String(f).trim();
      if (text && !named.includes(text)) named.push(text);
    }
  }
  if (named.length) return named;
  return sawMismatch ? [...IDENTITY_TRIPLE] : [];
}

/**
 * Classify one BrandRegistration by what it says about identity. Pure.
 * Returns [state, detail].
 */
export function verdict(brand) {
  const status = String(brand.status ?? '').toUpperCase();
  const errors = brand.errors ?? [];
  const codes = errors.map(errorCode);
  const identity = String(brand.identity_status ?? '').toUpperCase();

  if (codes.includes(MISMATCH)) {
    const targets = editTargets(errors).join(', ');
    return ['identity-mismatch',
      `${MISMATCH}: the registry could not match the submitted identity against ` +
      `public records. Correct ${targets} on the Customer Profile, not on the ` +
      'brand.'];
  }

  if (status === 'FAILED') {
    const other = codes.filter(Boolean).join(', ') || 'no code';
    return ['failed-elsewhere',
      `FAILED on ${other}, which is not an identity mismatch. The Customer ` +
      'Profile business details are not the thing to re-check.'];
  }

  if (status === 'SUSPENDED') {
    return ['suspended',
      'brand is SUSPENDED, which is a compliance decision rather than an ' +
      'identity check. Nothing here is fixed by editing the profile.'];
  }

  if (status === 'PENDING' || status === 'IN_REVIEW') {
    return ['in-review',
      `brand is ${status}: the identity lookup has not returned a verdict yet.`];
  }

  if (status === 'APPROVED') {
    if (WEAK_IDENTITY.includes(identity)) {
      return ['approved-unverified-identity',
        `APPROVED with identity_status ${identity}, so the business identity was ` +
        `taken as declared rather than matched to a record. A later re-vet can ` +
        `still turn up ${MISMATCH}.`];
    }
    return ['approved', `APPROVED with identity_status ${identity || 'unset'}`];
  }

  return ['unknown-status',
    `status is ${status || 'unset'}, which this script does not recognise.`];
}

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. Items come back under `data` on this resource. */
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 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;
  }

  let bad = 0;
  for (const brand of brands) {
    const [state, detail] = verdict(brand);
    const sid = brand.sid ?? '?';
    const line = `${state.padEnd(28)} ${sid}  ${detail}`;
    if (state === 'approved' || state === 'in-review') {
      console.log(line);
      continue;
    }
    bad += 1;
    console.warn(line);
    for (const err of brand.errors ?? []) {
      if (err.url) console.warn(`  ${errorCode(err) || '?'} -> ${err.url}`);
    }
    if (state === 'identity-mismatch') {
      const bundle = brand.customer_profile_bundle_sid ?? 'BU...';
      console.warn(`  read: GET ${TRUSTHUB}/CustomerProfiles/${bundle}/` +
                   'EntityAssignments to find the business End-User holding ' +
                   'those fields');
      console.warn('  repair: edit that End-User in Trust Hub so the legal name, ' +
                   'address and registration identifier match the IRS or CRA ' +
                   `record exactly, then resubmit brand ${sid}. Three ` +
                   'resubmissions are free; a fourth returns 21724');
    }
  }

  console.log(`${brands.length} brand(s), ${bad} with an identity mismatch`);
  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 are easy to get wrong here and both are pinned below. The brand resource spells the key code where the campaign resource spells it error_code, so a classifier reading one of them reports 30799 as unrecognised. And a FAILED brand carrying some other code must not be reported as an identity mismatch, because that sends somebody to re-check a Customer Profile that was never the problem.

test_twilio_a2p_brand_identity_audit.py
from twilio_a2p_brand_identity_audit import edit_targets, error_code, verdict

FAILED = {"sid": "BN0123456789", "status": "FAILED"}


def test_30799_is_reported_as_an_identity_mismatch():
    state, detail = verdict(dict(FAILED, errors=[{"code": "30799"}]))
    assert state == "identity-mismatch"
    assert "Customer Profile" in detail


def test_the_brand_resource_spells_the_key_code_not_error_code():
    # The campaign resource says error_code. A classifier that reads only one of
    # them reports every brand rejection as unrecognised.
    assert error_code({"code": 30799}) == "30799"
    assert error_code({"error_code": 30799}) == "30799"


def test_named_fields_win_over_the_identity_triple():
    errors = [{"code": "30799", "fields": ["business_registration_identifier"]}]
    assert edit_targets(errors) == ["business_registration_identifier"]


def test_a_30799_with_no_fields_still_says_where_to_look():
    assert edit_targets([{"code": "30799"}]) == [
        "legal company name", "registered business address",
        "business_registration_identifier"]


def test_other_codes_contribute_no_edit_targets():
    assert edit_targets([{"code": "30898"}]) == []


def test_a_brand_failed_on_another_code_is_not_an_identity_mismatch():
    state, detail = verdict(dict(FAILED, errors=[{"code": "30898"}]))
    assert state == "failed-elsewhere"
    assert "30898" in detail


def test_approved_but_self_declared_is_reported():
    state, detail = verdict({"sid": "BN1", "status": "APPROVED",
                             "identity_status": "SELF_DECLARED"})
    assert state == "approved-unverified-identity"
    assert "30799" in detail


def test_a_vetted_brand_is_clean():
    state, _ = verdict({"sid": "BN1", "status": "APPROVED",
                        "identity_status": "VETTED_VERIFIED"})
    assert state == "approved"


def test_suspension_is_not_an_identity_problem():
    state, detail = verdict({"sid": "BN1", "status": "SUSPENDED"})
    assert state == "suspended"
    assert "compliance decision" in detail
twilio-a2p-brand-identity-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { editTargets, errorCode, verdict }
  from './twilio-a2p-brand-identity-audit.mjs';

const FAILED = { sid: 'BN0123456789', status: 'FAILED' };

test('30799 is reported as an identity mismatch', () => {
  const [state, detail] = verdict({ ...FAILED, errors: [{ code: '30799' }] });
  assert.equal(state, 'identity-mismatch');
  assert.match(detail, /Customer Profile/);
});

test('the brand resource spells the key code, not error_code', () => {
  assert.equal(errorCode({ code: 30799 }), '30799');
  assert.equal(errorCode({ error_code: 30799 }), '30799');
});

test('named fields win over the identity triple', () => {
  assert.deepEqual(
    editTargets([{ code: '30799', fields: ['business_registration_identifier'] }]),
    ['business_registration_identifier']);
});

test('a 30799 with no fields still says where to look', () => {
  assert.deepEqual(editTargets([{ code: '30799' }]),
    ['legal company name', 'registered business address',
     'business_registration_identifier']);
});

test('other codes contribute no edit targets', () => {
  assert.deepEqual(editTargets([{ code: '30898' }]), []);
});

test('a brand failed on another code is not an identity mismatch', () => {
  const [state, detail] = verdict({ ...FAILED, errors: [{ code: '30898' }] });
  assert.equal(state, 'failed-elsewhere');
  assert.match(detail, /30898/);
});

test('approved but self declared is reported', () => {
  const [state, detail] = verdict({ sid: 'BN1', status: 'APPROVED',
                                    identity_status: 'SELF_DECLARED' });
  assert.equal(state, 'approved-unverified-identity');
  assert.match(detail, /30799/);
});

test('a vetted brand is clean', () => {
  assert.equal(verdict({ sid: 'BN1', status: 'APPROVED',
                         identity_status: 'VETTED_VERIFIED' })[0], 'approved');
});

test('suspension is not an identity problem', () => {
  const [state, detail] = verdict({ sid: 'BN1', status: 'SUSPENDED' });
  assert.equal(state, 'suspended');
  assert.match(detail, /compliance decision/);
});

FAQ

What does 30799 actually mean?

That the registry could not verify the business identity you submitted. In practice it took the tax identifier, looked it up against public records, and found the legal name or address on that record did not match what was sent. It is a lookup failure, not a formatting error, so the value can be perfectly valid and still fail.

Which name does the registry want?

The legal name exactly as it appears on the IRS or CRA record for that identifier, including the entity suffix. A DBA, a trading name, a shortened form or the name of an acquired company that is still on the signage will all fail, however well known they are.

Can I fix this on the brand resource?

No, and this is the part that wastes the most time. The brand is built from the Trust Hub Customer Profile bundle named in customer_profile_bundle_sid. The business End-User inside that bundle holds the name, address and registration identifier, and that is the object to edit before resubmitting the brand.

How many times can I resubmit?

Three, free. The fourth returns 21724. So a resubmission is worth spending only on a change you have actually verified against the tax record, not on a plausible-looking retype of the same details.

Do nonprofits and government entities fail differently?

They fail with the same code from the same lookup, but the attribute is usually different: a wrong 501(c) subsection code, or an entity submitted under the wrong company type. The fix is still a Customer Profile edit, so read the fields array rather than assuming it is the EIN.

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.