Sent but never delivered: triaging carrier filtering on SMS
A triage order for notification SMS that vanishes: suppression check, delivery state, sender registration, content rules — and when a resend is money burned.
A notification SMS that the API accepted and the handset never showed is nearly always one of four things: the number is on your own suppression list, the sender identity isn’t registered for that destination, the carrier’s spam filter ate the content, or the message is still queued and you asked too early. Infrai exposes each of those as a separate free read, so triage is a sequence of cheap questions rather than a support ticket.
Do them in cost order — the free checks first, the paid resend last. A resend bills a full message, and resending into a carrier filter produces the same filtered result with a second charge attached.
Triage order
| Step | Call | What a hit means |
|---|---|---|
| 1. Is the number suppressed? | POST /v1/sms/suppression/check | You stopped sending; nothing left the gateway |
| 2. What state is the message in? | GET /v1/sms/status/{id} | queued means wait; failed gives failed_reason |
| 3. What happened along the way? | GET /v1/sms/events/{id} | Timeline with carrier-level detail |
| 4. Is the sender identity approved? | GET /v1/sms/signature/list | review_state other than approved blocks delivery |
| 5. Only then | POST /v1/sms/resend/{id} | Costs a full message; fixes nothing structural |
Step one is free and answers a third of cases
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/sms/suppression/check" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"phone": "+14155550142"}'
{
"ok": true,
"data": {
"phone": "+14155550142",
"suppressed": false
}
}
If that returns true, stop. Someone replied STOP months ago on an unrelated campaign, the platform recorded it, and every send since has been a no-op with a receipt. Resending is pointless and, depending on your jurisdiction, unlawful.
Reading state, and what the reasons mean
curl -sS "https://api.infrai.cc/v1/sms/status/sms_Yh3Ln8Vd41Qs" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"message_id": "sms_Yh3Ln8Vd41Qs",
"state": "failed",
"vendor": "tencent_sms",
"attempt": 1,
"last_event": "carrier_rejected",
"delivered_at": null,
"failed_reason": "content_filtered"
}
}
The taxonomy underneath failed_reason maps onto the same failure sources every aggregator publishes — Bird’s extended error code list is a useful cross-reference when you’re deciding whether a reason is yours to fix or the carrier’s to explain.
failed_reason | Root cause | Does a resend help? |
|---|---|---|
content_filtered | Spam heuristics: shortened URL, ALL CAPS, no opt-out text | No — change the copy first |
sender_not_registered | Sender identity unapproved for that country | No — finish registration |
handset_unreachable | Phone off, out of coverage, roaming | Sometimes, after a delay |
invalid_number | Not a mobile line, or wrong country code | Never |
carrier_rejected | Route-level block, often prefix-based | No — change route or destination |
Only one row in that table is a genuine retry candidate. That’s the whole argument against automatic resend logic on a notification channel.
The events feed, and its caveat
curl -sS "https://api.infrai.cc/v1/sms/events/sms_Yh3Ln8Vd41Qs" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": false,
"error": {
"code": "VENDOR_NOT_CONFIGURED",
"http_status": 503,
"message": "sms vendor not configured; hydrate a usable sms vendor key (sms.events depends on a real sms vendor)"
}
}
Worth flagging, because the capability catalogue lists this route as live: the timeline depends on an events-capable vendor key being hydrated on your account, and without one it answers VENDOR_NOT_CONFIGURED rather than an empty list. Design your triage so GET /v1/sms/status/{id} is the load-bearing read and the timeline is enrichment. That’s the right dependency ordering anyway — status is the field your on-call engineer needs at 2am.
Sender registration is a calendar problem
Content and code you can fix this afternoon. Sender identity you cannot.
The rules differ by region in a way that catches teams shipping to both. In much of the EU an alphanumeric sender ID like ACMEOPS is normal and recognisable. In the US alphanumeric senders are not deliverable for application traffic at all — you send from a registered 10DLC long code or a verified toll-free number, and registration takes days to weeks depending on the carrier. Ship US notifications from an unregistered identity and the carrier filters them silently, which is why “it works in staging with my Irish test number” is such a common opening line.
Register the identity through the signature route, then poll its review state:
curl -sS -X POST "https://api.infrai.cc/v1/sms/signature/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "ACMEOPS",
"type": "company",
"proof_url": "https://acme.example.com/legal/business-licence.pdf"
}'
curl -sS "https://api.infrai.cc/v1/sms/signature/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Anything other than review_state: "approved" means your notifications are going out under an identity the destination carrier hasn’t blessed. For China destinations the requirement is stricter still — an approved signature and an approved template, with inline message bodies rejected outright.
Triage in Node 22
This script takes a message id, runs the free checks in order, and prints a recommendation. It deliberately does not resend anything.
// sms-triage.mjs — Node 22 ESM
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const RESENDABLE = new Set(["handset_unreachable", "deadline_exceeded"]);
async function call(path, init = {}) {
const res = await fetch(BASE + path, {
...init,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
});
const payload = await res.json().catch(() => ({}));
if (res.ok && payload.ok !== false) return { ok: true, data: payload.data ?? payload };
return { ok: false, error: payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText } };
}
async function triage(messageId, phone) {
const suppression = await call("/v1/sms/suppression/check", {
method: "POST",
body: JSON.stringify({ phone }),
});
if (suppression.ok && suppression.data.suppressed) {
return { verdict: "suppressed", action: "remove from the list or stop targeting this number" };
}
const status = await call(`/v1/sms/status/${messageId}`);
if (!status.ok) return { verdict: "unreadable", action: status.error.code };
const { state, failed_reason: reason, last_event: lastEvent } = status.data;
if (state === "delivered") return { verdict: "delivered", action: "the carrier took it; check the handset, not the API" };
if (state !== "failed") return { verdict: state, action: `still in flight (${lastEvent ?? "no events yet"}); poll again in 30s` };
const signatures = await call("/v1/sms/signature/list");
const approved = signatures.ok ? (signatures.data.items ?? []).filter((s) => s.review_state === "approved") : [];
if (reason === "sender_not_registered" || approved.length === 0) {
return { verdict: "sender", action: `${approved.length} approved signature(s); finish registration before resending` };
}
return {
verdict: reason ?? "failed",
action: RESENDABLE.has(reason) ? "a delayed resend is reasonable" : "resending will fail the same way — fix the cause",
};
}
console.log(await triage("sms_Yh3Ln8Vd41Qs", "+14155550142"));
Run it with INFRAI_API_KEY=your_infrai_api_key node sms-triage.mjs. Every call it makes is free; only the resend it refuses to make would have cost anything.
The economics of resending blindly
A send or resend is about $0.007475 per message, verified 2026-07-26 and approximate since destination and vendor move it; status, events, suppression and signature reads are free but rate-limited. New accounts get $2, roughly 267 messages. A retry loop that resends three times into a content filter turns one wasted cent into three, per recipient, per notification — at 20,000 notifications a month that’s real money for zero delivered messages. Rates trend downward over time and discounts run, so check the live figure:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id == "sms.resend" or .id == "sms.send") | {id, price: .billing.price_usd, unit: .billing.unit}'
Where another vendor helps more
If your problem is chiefly US registration, Twilio’s 10DLC brand and campaign tooling walks you through the paperwork with better error messages than anyone, and that’s worth paying for during onboarding. Plivo is a reasonable pick when you want per-destination routing control and detailed carrier-level reporting on every hop.
The Infrai case is different in kind. The notification, the queue that scheduled it, the cron sweep that re-runs this triage nightly, the error tracker that catches the failure and the usage view that prices it belong to one account and one key — and when the answer to a delivery mystery is “check the suppression list”, you don’t need a second vendor’s dashboard to find out.