Skip to content

Diagnostic Twilio

a rejected WhatsApp content template blocks every send

The template was approved in March and the notifications have gone out every day since. This morning they stopped. Nobody changed the code, nobody changed the template, and whatsapp.status now reads paused — Meta paused it on user feedback, and every send that uses it comes back 63041 until it comes back on its own.

Read-only key Python and Node.js Tests included
Closeup photo of turned-on blue and white laptop computer
Photo by Philipp Katzenberger on Unsplash
The short answer

List your templates with GET https://content.twilio.com/v1/Content, then read each one's approval with GET https://content.twilio.com/v1/Content/{ContentSid}/ApprovalRequests. The field is whatsapp.status, and rejected comes with a whatsapp.rejection_reason that says what Meta objected to.

Four statuses are not sendable and each has its own error code: rejected is 63040, paused is 63041, disabled is 63042, and a template that was never submitted leaves you sending freeform text, which is 63016 the moment you are outside the 24-hour customer-service window. Sweep GET https://monitor.twilio.com/v1/Alerts?LogLevel=error for those four codes to see which of them your account is hitting now.

The problem in plain words

Template state is not yours. Meta reviews every template, and it keeps reviewing them after approval: a template that has been fine for months can be paused because enough recipients blocked or reported the messages it was used for. Nothing in your account changes, nothing in your deploy pipeline changes, and the sends simply start failing. The state that decides whether your notifications go out lives behind two APIs and is edited by a third party.

What makes this worse than an ordinary outage is the fallback everyone reaches for. When a templated send fails, the instinct is to send the message as plain text instead. That works in testing, because whoever is testing has just messaged the sandbox and is inside the 24-hour window where freeform text is allowed. In production the recipients have not messaged you in days, so the fallback fails too, with 63016, and now you have two failure codes and one broken feature.

Templateapprovedmonths of cleansendsRecipientsreport itfeedback reachesMetaStatus becomespaused63041 on everysendCode falls backto textworked in testing63016 outsidethe windowtwo codes, onefeature
Nothing on your side changed. The state that decides delivery is edited by a third party and revoked without a notification.

Why it happens

Approval is a third party's decision and it is revocable. approved is the state today, not a property of the template. Paused and disabled are both reached without you doing anything, on feedback from the people receiving your messages, and neither arrives as a notification you have subscribed to.

The four codes look like one problem and are four. 63040 needs a rewrite and a resubmission. 63041 lifts by itself if the feedback stops. 63042 is terminal for that template. 63016 is not about the template at all — it is about the window. Treating them as "WhatsApp is broken" gets you the wrong repair three times out of four.

Rejection reasons are specific and are not in the error. The alert tells you the send failed. whatsapp.rejection_reason on the approval request tells you it was the placeholder at the start of the body, or the category you picked, or copy that reads as promotional in a utility template. That string is the entire content of the fix and it is only on the Content API.

The 24-hour window is invisible from your side. Nothing in your code knows when a given recipient last messaged you unless you track it. So the freeform fallback that worked during development is a coin toss in production, and it fails for exactly the recipients who most need the notification: the quiet ones.

The fix, as a flow

The script reads the approval per template and counts the four WhatsApp error codes separately, because rejected, paused, disabled and the 24 hour window are four repairs that look like one outage.

GET Content andApprovalRequestsplus the four codes in AlertsApproved, no 63016sendablePending reviewnot usable yetRejected or disabledrewrite and resubmitApproved, 63016 loggedyour code, not the template
An approved template on an account logging 63016 is a bug in the sending code, and resubmitting the template would fix nothing.

How to fix it

Enumerate the templates rather than the ones you remember

GET https://content.twilio.com/v1/Content, following meta.next_page_url. Templates accumulate the way Messaging Services do: one per language, one per campaign, several from experiments. The one that broke this morning is not necessarily the one you would have checked.

Read the approval per template, not the template body

GET https://content.twilio.com/v1/Content/{ContentSid}/ApprovalRequests returns the whatsapp block with status, name, category and rejection_reason. A template with no approval request at all answers a different question: it was never submitted, so it has never been usable outside the 24-hour window.

Sweep the four error codes for what is failing right now

GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate={ISO8601}, counting 63016, 63040, 63041 and 63042. The alerts do not carry a ContentSid, so this is account-level context rather than per-template attribution — useful for ranking, not for blaming a specific template.

Separate the template problem from the window problem

63016 alongside an approved template means something in your code is sending freeform text where it should be sending the template. That is a code fix, not a resubmission, and it will not be helped by anything you do to the template.

Resubmit, then wait for approved before sending again

Fix the body and POST https://content.twilio.com/v1/Content/{ContentSid}/ApprovalRequests/whatsapp with Name and Category. Re-run this script until whatsapp.status reads approved; sending against a template still in review fails exactly as it did before, and each failed send is more negative signal.

How to check it worked

Re-run the script. Every template you send from should report approved, and the 63040 family count should be zero.

python3 twilio_whatsapp_template_audit.py
# 9 template(s), 0 not usable

The full code

One paginated GET for the templates, one per template for its approval, and one over the alerts for the error codes — reads only, with an API Key that has read access. The classifier is pure and takes the alert counts alongside the approval, because an approved template plus a pile of 63016 is a bug in your sending code and looks nothing like a rejected one.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 117 Twilio fixes, free and open source.
twilio_whatsapp_template_audit.py
"""Report WhatsApp content templates that cannot currently be sent.

Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The resubmission 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_whatsapp_template_audit")

CONTENT = "https://content.twilio.com/v1"
MONITOR = "https://monitor.twilio.com/v1"

WA_CODES = {
    63016: "freeform message sent outside the 24 hour customer service window",
    63040: "template rejected",
    63041: "template paused",
    63042: "template disabled",
}
BLOCKING = (63040, 63041, 63042)


def code_of(alert):
    """error_code arrives as a string on some alerts and an int on others."""
    try:
        return int(alert.get("error_code"))
    except (TypeError, ValueError):
        return None


def whatsapp_status(approval):
    """Pull status and rejection_reason out of an approval request. Pure.

    An absent approval is not an error: it means nobody ever submitted this
    template, which is a different finding from one that was refused.
    """
    wa = ((approval or {}).get("whatsapp") or {})
    status = str(wa.get("status") or "unsubmitted").strip().lower()
    return status, str(wa.get("rejection_reason") or "").strip()


def explain_code(code):
    """What one of the four WhatsApp codes actually means. Pure."""
    return WA_CODES.get(code, "unrecognised WhatsApp error code")


def verdict(content, approval, code_hits=None):
    """Classify one Content template. Pure, so every status can be tested
    without a network.

    `code_hits` maps WhatsApp error codes to counts seen in the alert window.
    Alerts do not carry a ContentSid, so those counts are account-level context
    and the classifier says so rather than pretending to attribute them.
    Returns (state, detail).
    """
    hits = code_hits or {}
    status, reason = whatsapp_status(approval)

    blocked = sum(hits.get(c, 0) for c in BLOCKING)
    context = ""
    if blocked:
        context = (" Alerts logged %d blocked-template error(s) on this account "
                   "in the window; they carry no ContentSid, so treat that as "
                   "context rather than attribution." % blocked)

    if status == "rejected":
        return ("rejected",
                "whatsapp.status is rejected: %s. Every send using this template "
                "returns 63040 until it is rewritten, resubmitted and approved.%s"
                % (reason or "no rejection_reason given", context))

    if status == "paused":
        return ("paused",
                "whatsapp.status is paused, so sends return 63041. Meta pauses a "
                "template on negative feedback; it lifts on its own if the "
                "feedback stops, and does not if it does not.%s" % context)

    if status == "disabled":
        return ("disabled",
                "whatsapp.status is disabled, so sends return 63042. This is "
                "terminal for this template: build a new one rather than waiting."
                "%s" % context)

    if status == "pending":
        return ("pending",
                "submitted and not yet reviewed. It is not usable outside the 24 "
                "hour window yet, and sending against it now just adds failures.")

    if status == "unsubmitted":
        return ("unsubmitted",
                "no WhatsApp approval request exists for this template, so it "
                "has never been sendable outside the 24 hour window. Anything "
                "falling back to freeform text there returns 63016.")

    if status == "approved":
        freeform = hits.get(63016, 0)
        if freeform:
            return ("approved-but-freeform",
                    "approved, but the account logged %d 63016 in the window: "
                    "something is sending plain text outside the 24 hour window "
                    "instead of this template. That is a code fix, not a "
                    "resubmission." % freeform)
        return ("approved", "approved and sendable.")

    return ("unknown-status",
            "whatsapp.status is %s, which this script does not recognise: read "
            "the approval request before acting." % (status or "empty"))


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_v1(session, url, key, limit=1000, **params):
    """Page a v1 list. meta.next_page_url is absolute."""
    out = []
    params.setdefault("PageSize", 100)
    while url and len(out) < limit:
        page = get(session, url, **params)
        out.extend(page.get(key, []))
        url, params = (page.get("meta") or {}).get("next_page_url"), {}
    return out[:limit]


def approval_for(session, content_sid):
    """A template with no approval request answers 404, and that is a finding
    rather than an error."""
    r = session.get("%s/Content/%s/ApprovalRequests" % (CONTENT, content_sid),
                    timeout=30)
    if r.status_code == 404:
        return None
    if r.status_code in (401, 403):
        raise SystemExit("%d from Twilio: the API key needs read access to Content"
                         % r.status_code)
    r.raise_for_status()
    return r.json()


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=7, help="alert window, max 30")
    ap.add_argument("--max-templates", 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)

    contents = list_v1(session, CONTENT + "/Content", "contents", args.max_templates)
    if not contents:
        log.info("no Content templates on this account")
        return 0

    since = (dt.datetime.now(dt.timezone.utc)
             - dt.timedelta(days=min(args.days, 30))).strftime("%Y-%m-%dT%H:%M:%SZ")
    hits = {}
    for a in list_v1(session, MONITOR + "/Alerts", "alerts", 10000,
                     LogLevel="error", StartDate=since):
        c = code_of(a)
        if c in WA_CODES:
            hits[c] = hits.get(c, 0) + 1
    for c, n in sorted(hits.items()):
        log.info("%d alert(s) of %d: %s", n, c, explain_code(c))

    bad = 0
    for content in contents:
        state, detail = verdict(content, approval_for(session, content.get("sid")), hits)
        line = "%-21s %s  %s" % (state, content.get("friendly_name",
                                                    content.get("sid")), detail)
        if state == "approved":
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        if state in ("rejected", "disabled", "unsubmitted"):
            log.warning("  repair: fix the body, then POST %s/Content/%s/"
                        "ApprovalRequests/whatsapp with Name and Category, and "
                        "wait for approved before sending", CONTENT,
                        content.get("sid"))

    log.info("%d template(s), %d not usable", len(contents), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-whatsapp-template-audit.mjs
/**
 * Report WhatsApp content templates that cannot currently be sent.
 *
 * Read only. GET requests and nothing else: give this an API Key with read
 * access rather than the account auth token. The resubmission is printed, never
 * performed.
 */
const CONTENT = 'https://content.twilio.com/v1';
const MONITOR = 'https://monitor.twilio.com/v1';

const WA_CODES = new Map([
  [63016, 'freeform message sent outside the 24 hour customer service window'],
  [63040, 'template rejected'],
  [63041, 'template paused'],
  [63042, 'template disabled'],
]);
const BLOCKING = [63040, 63041, 63042];

/** error_code arrives as a string on some alerts and a number on others. */
export function codeOf(alert) {
  const n = Number(alert?.error_code);
  return Number.isFinite(n) ? n : null;
}

/**
 * Pull status and rejection_reason out of an approval request. Pure. An absent
 * approval is not an error: it means nobody ever submitted this template.
 */
export function whatsappStatus(approval) {
  const wa = approval?.whatsapp ?? {};
  const status = String(wa.status ?? 'unsubmitted').trim().toLowerCase();
  return [status, String(wa.rejection_reason ?? '').trim()];
}

/** What one of the four WhatsApp codes actually means. Pure. */
export function explainCode(code) {
  return WA_CODES.get(code) ?? 'unrecognised WhatsApp error code';
}

/**
 * Classify one Content template. Pure, so every status can be tested without a
 * network. `codeHits` maps WhatsApp error codes to counts seen in the alert
 * window; alerts carry no ContentSid, so the classifier reports those as
 * context rather than attribution. Returns [state, detail].
 */
export function verdict(content, approval, codeHits = null) {
  const hits = codeHits ?? {};
  const [status, reason] = whatsappStatus(approval);

  const blocked = BLOCKING.reduce((n, c) => n + (hits[c] ?? 0), 0);
  const context = blocked
    ? ` Alerts logged ${blocked} blocked-template error(s) on this account in ` +
      'the window; they carry no ContentSid, so treat that as context rather ' +
      'than attribution.'
    : '';

  if (status === 'rejected') {
    return ['rejected',
      `whatsapp.status is rejected: ${reason || 'no rejection_reason given'}. ` +
      'Every send using this template returns 63040 until it is rewritten, ' +
      `resubmitted and approved.${context}`];
  }

  if (status === 'paused') {
    return ['paused',
      'whatsapp.status is paused, so sends return 63041. Meta pauses a template ' +
      'on negative feedback; it lifts on its own if the feedback stops, and ' +
      `does not if it does not.${context}`];
  }

  if (status === 'disabled') {
    return ['disabled',
      'whatsapp.status is disabled, so sends return 63042. This is terminal for ' +
      `this template: build a new one rather than waiting.${context}`];
  }

  if (status === 'pending') {
    return ['pending',
      'submitted and not yet reviewed. It is not usable outside the 24 hour ' +
      'window yet, and sending against it now just adds failures.'];
  }

  if (status === 'unsubmitted') {
    return ['unsubmitted',
      'no WhatsApp approval request exists for this template, so it has never ' +
      'been sendable outside the 24 hour window. Anything falling back to ' +
      'freeform text there returns 63016.'];
  }

  if (status === 'approved') {
    const freeform = hits[63016] ?? 0;
    if (freeform) {
      return ['approved-but-freeform',
        `approved, but the account logged ${freeform} 63016 in the window: ` +
        'something is sending plain text outside the 24 hour window instead of ' +
        'this template. That is a code fix, not a resubmission.'];
    }
    return ['approved', 'approved and sendable.'];
  }

  return ['unknown-status',
    `whatsapp.status is ${status || 'empty'}, which this script does not ` +
    'recognise: read the approval request before acting.'];
}

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 listV1(auth, url, key, limit = 1000, params = {}) {
  const out = [];
  let next = url;
  let query = { PageSize: 100, ...params };
  while (next && out.length < limit) {
    const page = await get(auth, next, query);
    out.push(...(page[key] ?? []));
    next = page.meta?.next_page_url ?? null;
    query = {};
  }
  return out.slice(0, limit);
}

export async function approvalFor(auth, contentSid) {
  const res = await fetch(`${CONTENT}/Content/${contentSid}/ApprovalRequests`,
                          { headers: { Authorization: auth } });
  if (res.status === 404) return null;
  if (res.status === 401 || res.status === 403) {
    throw new Error(`${res.status} from Twilio: the API key needs read access to Content`);
  }
  if (!res.ok) throw new Error(`${res.status} reading approvals for ${contentSid}`);
  return res.json();
}

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 = Math.min(Number(process.env.DAYS ?? 7), 30);

  const contents = await listV1(auth, `${CONTENT}/Content`, 'contents', 500);
  if (contents.length === 0) {
    console.log('no Content templates on this account');
    return;
  }

  const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 19) + 'Z';
  const hits = {};
  for (const a of await listV1(auth, `${MONITOR}/Alerts`, 'alerts', 10000,
                               { LogLevel: 'error', StartDate: since })) {
    const c = codeOf(a);
    if (WA_CODES.has(c)) hits[c] = (hits[c] ?? 0) + 1;
  }
  for (const [c, n] of Object.entries(hits).sort()) {
    console.log(`${n} alert(s) of ${c}: ${explainCode(Number(c))}`);
  }

  let bad = 0;
  for (const content of contents) {
    const [state, detail] = verdict(content, await approvalFor(auth, content.sid), hits);
    const line = `${state.padEnd(21)} ${content.friendly_name ?? content.sid}  ${detail}`;
    if (state === 'approved') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    if (state === 'rejected' || state === 'disabled' || state === 'unsubmitted') {
      console.warn(`  repair: fix the body, then POST ${CONTENT}/Content/${content.sid}` +
                   '/ApprovalRequests/whatsapp with Name and Category, and wait ' +
                   'for approved before sending');
    }
  }

  console.log(`${contents.length} template(s), ${bad} not usable`);
  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 two cases that carry the note are the ones that look like each other and are not. A missing approval request is a template nobody ever submitted, which is not the same as one that was refused, and the classifier has to say so rather than defaulting to rejected. And an approved template on an account logging 63016 is a bug in your sending code — resubmitting the template would be a week spent on the wrong thing.

test_twilio_whatsapp_template_audit.py
from twilio_whatsapp_template_audit import explain_code, verdict, whatsapp_status

TPL = {"sid": "HX0123456789", "friendly_name": "order_shipped_en"}


def approval(status, reason=""):
    return {"whatsapp": {"type": "whatsapp", "status": status,
                         "rejection_reason": reason}}


def test_rejected_carries_the_reason_meta_gave():
    state, detail = verdict(TPL, approval("rejected", "variable at start of body"))
    assert state == "rejected"
    assert "variable at start of body" in detail
    assert "63040" in detail


def test_paused_and_disabled_are_different_repairs():
    paused, pdetail = verdict(TPL, approval("paused"))
    disabled, ddetail = verdict(TPL, approval("disabled"))
    assert (paused, disabled) == ("paused", "disabled")
    assert "lifts on its own" in pdetail
    assert "terminal" in ddetail


def test_no_approval_request_is_unsubmitted_not_rejected():
    # 404 from ApprovalRequests: nobody ever submitted it.
    state, detail = verdict(TPL, None)
    assert state == "unsubmitted"
    assert "63016" in detail


def test_an_approved_template_on_an_account_logging_63016_is_a_code_bug():
    state, detail = verdict(TPL, approval("approved"), {63016: 84})
    assert state == "approved-but-freeform"
    assert "code fix, not a resubmission" in detail


def test_a_clean_approved_template_is_the_only_healthy_state():
    assert verdict(TPL, approval("approved"))[0] == "approved"
    assert verdict(TPL, approval("APPROVED"))[0] == "approved"


def test_blocking_counts_are_labelled_as_context_not_attribution():
    state, detail = verdict(TPL, approval("rejected"), {63040: 3, 63041: 2})
    assert state == "rejected"
    assert "5 blocked-template error(s)" in detail
    assert "context rather than attribution" in detail


def test_status_and_codes_are_read_defensively():
    assert whatsapp_status({}) == ("unsubmitted", "")
    assert whatsapp_status({"whatsapp": {"status": "Pending"}})[0] == "pending"
    assert verdict(TPL, {"whatsapp": {"status": "in_appeal"}})[0] == "unknown-status"
    assert explain_code(63042) == "template disabled"
    assert "unrecognised" in explain_code(12345)
twilio-whatsapp-template-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { explainCode, verdict, whatsappStatus } from './twilio-whatsapp-template-audit.mjs';

const TPL = { sid: 'HX0123456789', friendly_name: 'order_shipped_en' };
const approval = (status, reason = '') =>
  ({ whatsapp: { type: 'whatsapp', status, rejection_reason: reason } });

test('rejected carries the reason Meta gave', () => {
  const [state, detail] = verdict(TPL, approval('rejected', 'variable at start of body'));
  assert.equal(state, 'rejected');
  assert.match(detail, /variable at start of body/);
  assert.match(detail, /63040/);
});

test('paused and disabled are different repairs', () => {
  const [paused, pdetail] = verdict(TPL, approval('paused'));
  const [disabled, ddetail] = verdict(TPL, approval('disabled'));
  assert.deepEqual([paused, disabled], ['paused', 'disabled']);
  assert.match(pdetail, /lifts on its own/);
  assert.match(ddetail, /terminal/);
});

test('no approval request is unsubmitted, not rejected', () => {
  const [state, detail] = verdict(TPL, null);
  assert.equal(state, 'unsubmitted');
  assert.match(detail, /63016/);
});

test('an approved template on an account logging 63016 is a code bug', () => {
  const [state, detail] = verdict(TPL, approval('approved'), { 63016: 84 });
  assert.equal(state, 'approved-but-freeform');
  assert.match(detail, /code fix, not a resubmission/);
});

test('a clean approved template is the only healthy state', () => {
  assert.equal(verdict(TPL, approval('approved'))[0], 'approved');
  assert.equal(verdict(TPL, approval('APPROVED'))[0], 'approved');
});

test('blocking counts are labelled as context, not attribution', () => {
  const [state, detail] = verdict(TPL, approval('rejected'), { 63040: 3, 63041: 2 });
  assert.equal(state, 'rejected');
  assert.match(detail, /5 blocked-template error\(s\)/);
  assert.match(detail, /context rather than attribution/);
});

test('status and codes are read defensively', () => {
  assert.deepEqual(whatsappStatus({}), ['unsubmitted', '']);
  assert.equal(whatsappStatus({ whatsapp: { status: 'Pending' } })[0], 'pending');
  assert.equal(verdict(TPL, { whatsapp: { status: 'in_appeal' } })[0], 'unknown-status');
  assert.equal(explainCode(63042), 'template disabled');
  assert.match(explainCode(12345), /unrecognised/);
});

FAQ

The template was approved for months. Why did it stop working?

Meta keeps reviewing after approval. Enough recipients blocking or reporting messages sent with a template moves it to paused, and a paused template that keeps collecting negative feedback becomes disabled. Nothing in your account changed; the state that decides delivery is edited by a third party.

Is paused the same as rejected?

No, and the repair is different. Rejected means Meta refused the content and it needs a rewrite and a resubmission. Paused means an approved template is temporarily blocked on feedback and can lift by itself. Disabled is the end of that road and is terminal for that template.

Why do plain-text fallbacks fail in production and work in testing?

The 24-hour customer service window. Whoever is testing has just messaged the sandbox, so freeform text is allowed. Real recipients have not messaged you in days, so the same code returns 63016. It fails hardest for the quiet recipients, who are the ones the notification was for.

Why can the script not tell me which template caused a 63040?

The Monitor alert does not carry a ContentSid. Counting the codes tells you how much the account is losing to blocked templates right now, and the Content API tells you which templates are in a blocked state; joining those two into a claim about causation would be a guess, so the script reports them side by side and says which is which.

Should the script resubmit a rejected template?

No. Resubmitting an unchanged template gets it rejected again and each rejection is more signal against you. The rejection_reason tells you what to change, a person changes it, and then the POST is one command. The script prints that command.

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.