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 numberA 400 at request time — nothing reaches a carrierEverywhereNormalise to E.164 before you call

Row five is the only one of the five your code can settle on its own (the other four need a registration form, a content rewrite, or a human at a carrier), and it’s worth showing because it tells you how to write the retry policy for the rest. A badly formatted recipient is rejected at request time, with the error code, the offending value and an explicit retryable: false:

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": "INVALID_PHONE_NUMBER",
    "http_status": 400,
    "message": "recipient not in E.164 format: '07700900123'",
    "docs_url": "https://docs.infrai.cc/errors/INVALID_PHONE_NUMBER",
    "retryable": false,
    "hint": "Phone number must use E.164 format."
  }
}

So the boring rule holds and you can build on it: retry on 5xx with backoff, never on 4xx, and branch on retryable when you’re not sure. A national-format number with a leading zero is the single most common cause of this one — normalise at the edge of your system, not in the send function.

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 — give it 5 seconds before the first read. GET /v1/sms/events/{id} gives the fuller per-attempt timeline behind the same id when the summary isn’t enough.

One shape to know before you write the poller: POST /v1/sms/otp hands back {request_id, sent}, and the status read is keyed on the message_id that only POST /v1/sms/send returns. Managed OTP owns the code, the expiry and the attempt counter for you; per-message triage lives on the send route. If delivery forensics is what you’re building, send the code yourself.

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}"

Triage itself is free — status, events and signature reads are all unmetered on Infrai, so you can poll a failing number as hard as the rate limiter allows without adding to the bill. The retries are what cost, and here is today’s reading:

CallRate
POST /v1/sms/otp$0.008395 per message
POST /v1/sms/verify$0.005 per call
GET /v1/sms/status/{id}free

Worth flagging: a verify is billed whether or not it returns verified: true, so a resend loop and a mistyping bot both spend real money (the bot is the expensive one, because it never gives up after three tries). That’s the whole argument for per-number and per-IP caps in front of both write routes — the caps are cheaper than the codes. New accounts get $2 of free credit. Per-message rates move with carrier deals, and that table was read on 2026-07-27, so pull GET /v1/discovery for the current figure rather than quoting it back at anyone.

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. Buy one of them if the carrier relationship is something you intend to manage rather than delegate. Infrai doesn’t support an outbound delivery webhook on this surface, so triage is a read you make, not a push you receive, and route selection isn’t yours to tune.

What it does give you is that the rest of the triage loop needs no second account. The email fallback for a number that keeps failing is POST /v1/email/send, the retry buffer is POST /v1/queue/publish, the spike alert is POST /v1/errors/capture, and the delivery-rate counter you actually watch is POST /v1/metrics/report — same key, one bill, and one usage query when you want to know which tenant’s logins are burning the credit.

References

Browse more sms developer guides