Why the SMS code never arrived: a triage guide for OTP delivery

Carrier filtering, unregistered senders, shared routes and anti-fraud blocks all look identical from your app. How to tell them apart, in US and EU traffic.

When a login code doesn’t arrive, your API almost always reported success. The message was accepted, handed to a carrier, and dropped somewhere you can’t see. Five causes account for nearly all of it: an unregistered sender, content filtering, a shared route poisoned by someone else’s traffic, an anti-fraud block, and a number that was never valid. Infrai surfaces the one field that separates them — failed_reason on the status read — and this is how to act on each value.

Start by accepting that “sent” means almost nothing.

The gateway’s job ends when a carrier accepts the message. Everything after that is the carrier’s policy engine, and on US networks in particular that engine is aggressive, opaque and changes without notice. Your logs will say the send succeeded because it did.

The five failures, and what each one looks like

CauseWhat you observeWhere it bitesThe actual fix
Sender not registeredSilent drops, or throughput throttled to a trickleUS 10DLC, some EU alphanumeric marketsRegister the brand and campaign; wait out review
Content filteredSome recipients get it, some never doUS heaviest, EU moderateRemove links, drop ALL CAPS, use a stable template
Shared route contaminationDelivery falls off a cliff for everyone at onceAnywhere on pooled numbersMove to a dedicated sender identity
Anti-fraud / traffic pumping blockHigh volume to one country range, then blocksPremium-rate destinationsCountry allowlist plus per-number caps
Invalid or non-E.164 numberAn error your retry loop treats as transientEverywhereValidate before send; fail closed

Row five deserves a warning of its own, because we’ve verified the behaviour on the live API: sending to a badly formatted recipient returns HTTP 503 VENDOR_DOWN with retryable: true, and the real reason appears only in the human-readable message. Any client that retries on 5xx will loop forever on input that can never succeed.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/sms/otp" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to": "07700900123", "template": "login"}'
{
  "ok": false,
  "error": {
    "code": "VENDOR_DOWN",
    "http_status": 503,
    "retryable": true,
    "message": "invalid recipient address"
  }
}

Normalise to E.164 before the call — a national-format number with a leading zero is the single most common cause of this — and treat message as authoritative when the code says otherwise.

The read that tells you which one it was

curl -sS "https://api.infrai.cc/v1/sms/status/sms_Kb3xR9tQmZ1pLdN7VwYs" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The response carries state, attempt, last_event, delivered_at and failed_reason. An id that isn’t in your account’s archive comes back as SMS_MESSAGE_NOT_FOUND with HTTP 404, which is also what you’ll see if you poll before the send has landed in the archive. Worth flagging: the richer per-message timeline at GET /v1/sms/events/{id} answers VENDOR_NOT_CONFIGURED with HTTP 503 on an account with no SMS vendor key hydrated, so build triage on the status read.

Turning failed_reason into an action

// triage.mjs — classify one message's failure and pick the remedy.
// Run: INFRAI_API_KEY=your_infrai_api_key node triage.mjs sms_Kb3xR9tQmZ1pLdN7VwYs
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const id = process.argv[2];
if (!id) throw new Error("usage: node triage.mjs <message_id>");

const REMEDY = [
  [/block|reject|filter|spam/i, "content or sender filtering — strip links, verify sender registration"],
  [/unreach|absent|off|handset/i, "handset unreachable — retry once after 5 minutes, then fall back to email"],
  [/invalid|format|unknown_subscriber/i, "bad number — mark unusable, do not retry"],
  [/opt_out|stop|unsubscrib/i, "recipient opted out — add to your own do-not-contact list"],
  [/quota|limit|throttl/i, "throughput capped — registration incomplete or per-second cap hit"],
];

const res = await fetch(`https://api.infrai.cc/v1/sms/status/${id}`, {
  headers: { authorization: `Bearer ${KEY}` },
});
const json = await res.json();

if (json.ok === false) {
  console.error(`${json.error.code}: ${json.error.message}`);
  process.exit(json.error.code === "SMS_MESSAGE_NOT_FOUND" ? 0 : 1);
}

const { state, attempt, failed_reason: reason, delivered_at: at } = json.data;
console.log(`state=${state} attempt=${attempt} delivered_at=${at ?? "-"}`);
if (state !== "failed") {
  console.log("nothing to triage");
} else {
  const hit = REMEDY.find(([pattern]) => pattern.test(reason ?? ""));
  console.log(`reason=${reason ?? "unknown"}`);
  console.log(`action: ${hit ? hit[1] : "unclassified — open a vendor ticket with the message_id"}`);
}

Keep that mapping in one place and log the classification, not the raw string. Vendor reason codes change wording between carriers, and a dashboard built on exact-match strings quietly stops counting the day a route changes.

Sender registration is the slow, boring root cause

Most “our OTPs stopped arriving” incidents trace back here rather than to anything in the code. US carriers require A2P 10DLC brand and campaign registration for application traffic on long codes, and unregistered traffic is filtered or throttled to a trickle rather than rejected loudly. In the EU there’s no single scheme, but several countries require alphanumeric sender IDs to be pre-registered, and unregistered ones get replaced with a random number or dropped.

Read your own registration state before blaming the network.

curl -sS "https://api.infrai.cc/v1/sms/signature/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      {
        "signature_id": "smssig_1qEls0S4HTWfMkuYQqkm",
        "name": "infrai-otp-1784009331",
        "type": "company",
        "review_state": "pending",
        "created_at": "2026-07-14T06:10:31.552784Z"
      }
    ],
    "count": 1
  }
}

review_state: pending is a live answer from a real account, and it’s the state that matters. Until it’s approved, your traffic may go out on a pooled sender you share with strangers — which is exactly the shared-route contamination in row three of the table.

Content rules that survive every carrier

Keep the body under 160 GSM-7 characters so it stays one segment. No URL, no shortened link, no ALL CAPS, no emoji. Name your brand and the action in plain words, include the code, and say nothing else. SMS_CONTENT_REJECTED exists as an error code precisely because bodies that look like marketing get treated as marketing.

Six words and a number beat anything clever.

What the retries cost you

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" | head -c 300

curl -sS "https://api.infrai.cc/v1/account/balance" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Verified 2026-07-26 on Infrai: each attempt is $0.007475 per message and a verify call is $0.005, billed even when it returns verified: false. So a user who requests three codes and mistypes twice costs about $0.033 — cheap individually, ruinous if a bot drives it, which is why per-number and per-IP caps belong in front of both routes. Status and signature reads stay free. New accounts get $2 free. These rates drift downward over time, so pull GET /v1/discovery for the current figure.

When to stop debugging and switch

If SMS delivery is a core product surface and you need per-carrier route selection, number pools, real-time delivery receipts and a dedicated deliverability contact, stick with a specialist — Twilio and Sinch both sell that, and their support can escalate to carriers in a way a general gateway can’t. Infrai’s SMS surface is western-region with Tencent as the ready vendor and Twilio pending, offers no outbound delivery webhook, and doesn’t support inbound retrieval without a hydrated vendor key. What it does give you is one credential where the OTP, the email fallback, the queue that retries and the error tracking that records all of it live on a single bill.

References

Browse more sms developer guides