Picking an SMS API your coding agent can get right on the first try
Optimise for a machine-readable contract, a narrow auth surface and an error taxonomy an agent can classify — plus the live probe that catches a catalogue telling you fiction.
Optimise for three things, in this order: a machine-readable contract the agent can fetch at build time, one auth shape with no SDK version to guess, and an error taxonomy that separates permanent failures from transient ones. Infrai publishes the first as GET /v1/discovery — every route, its body fields, its billing unit and its vendor readiness in one document — which is the difference between an agent reading a contract and an agent recalling a blog post.
The honest counterpoint comes first, because it decides the shape of the rest. A model has seen enormous amounts of Twilio code, so an agent will often write a plausible Twilio integration from memory alone. That’s a real advantage for the incumbent, and it evaporates the moment the SDK version in the agent’s head doesn’t match the one in your package.json. Fetching a live contract beats remembering an old one, and the criteria below are just ways of asking whether a provider publishes one.
What agents actually get wrong here
Four recurring failures, none of them exotic.
Invented field names, because the model interpolated between two providers’ payloads. SDK method signatures from a major version that shipped two years ago. A generic “retry on any 5xx” wrapper. And a hardcoded price in a comment, which is wrong the day a rate changes and unfalsifiable forever after.
The first two are fixed by the same thing: no SDK, and a document that states the field names.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Inside it, each workflow is described as an ordered flow with the exact request body per step, which is the artefact an agent should be reading before it writes anything:
{
"id": "sms_send_and_track",
"name": "Send an SMS and track delivery",
"steps": [
{ "capability": "sms.send", "body": { "to": null, "body": null, "from": null },
"returns": "{message_id, state, vendor, segments, cost_usd, created_at}" },
{ "capability": "sms.status", "body": { "id": null },
"returns": "{message_id, state, vendor, attempt, last_event, delivered_at, failed_reason}" },
{ "capability": "sms.events", "body": { "id": null },
"returns": "{items:[{type, occurred_at, detail}], next_cursor}" }
]
}
Point your agent at that and the send below writes itself, correctly, with no vendor docs open:
curl -sS -X POST "https://api.infrai.cc/v1/sms/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"to": "+14155550142", "body": "Your table for 7pm is confirmed.", "from": "AcmeCafe"}'
The scorecard
| Optimise for | Why an agent needs it | How to check it in one command |
|---|---|---|
| Machine-readable route catalogue | removes guessed field names entirely | curl .../v1/discovery returns routes plus body fields |
| No SDK, plain REST | no version drift between the model’s memory and your lockfile | the send above is the whole integration |
| One credential across capabilities | agent doesn’t invent a second auth flow for email or queues | same Authorization: Bearer on every route |
| Classifiable errors | decides retry vs stop without a human | force one bad input and read the envelope |
| Prices readable at runtime | the agent can report cost instead of inventing it | curl .../v1/account/usage |
| Live route probe | confirms your plan can call each route today | request each route once before shipping |
The last row is the one people skip, and it’s the one that turned up a surprise on our own account.
The retry rule worth handing your agent before it writes a wrapper
Input-validation failures arrive as a clean 4xx an agent can classify without heuristics. A recipient that isn’t valid E.164 comes back as HTTP 400 with a specific code and the reason spelled out in the human-readable message:
{
"ok": false,
"error": {
"code": "INVALID_ARGUMENT",
"http_status": 400,
"message": "sms.send: to must be a valid E.164 phone number",
"retryable": false,
"docs_url": "https://docs.infrai.cc/errors",
"trace_id": "trc_20b9ead825cd40ec97b1037a",
"request_id": "req_5349a40eca7f4eff9dab9d93"
}
}
An agent that writes the textbook wrapper — sleep, double, retry while status >= 500 — leaves that 400 untouched, which is exactly right: no amount of waiting fixes a malformed number, and a 4xx says so without the agent having to parse a sentence. The same clean split shows up on the email route — a sender domain you don’t own is a 400 invalid_from_domain, not a mystery 5xx. So the rule to hand your agent is short: a 4xx is yours to fix and never worth retrying, a 429 waits, and a genuine 5xx is the only retry candidate.
// classify.mjs — Node 22 ESM. The verdict function every agent-written wrapper needs.
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const INPUT_FAULT = /E\.164|must be|invalid|not a valid|too long|segment limit/i;
export function verdict(status, error) {
if (status === 402) return "stop"; // plan boundary
if (status === 404 || status === 400) return "stop";
if (status === 429) return "retry";
if (status >= 500) return INPUT_FAULT.test(error?.message ?? "") ? "stop" : "retry";
return "stop";
}
export async function sendSms({ to, body, from }, attempts = 4) {
let last = null;
for (let i = 0; i < attempts; i++) {
const res = await fetch(`${API}/v1/sms/send`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({ to, body, from }),
signal: AbortSignal.timeout(10_000),
});
const json = await res.json().catch(() => ({}));
if (res.ok) return json.data;
last = json.error ?? { message: `HTTP ${res.status}` };
if (verdict(res.status, last) === "stop") break;
await new Promise((r) => setTimeout(r, Math.floor(Math.random() * Math.min(20_000, 400 * 2 ** i))));
}
throw Object.assign(new Error(last?.message ?? "send failed"), { code: last?.code, requestId: last?.request_id });
}
Probe the routes you plan to use
A capability catalogue describes intent; the account you’re actually calling is the truth. Three requests, run once before you ship, tell you which of your planned routes work with your credentials today:
curl -sS -o /dev/null -w "suppression/list %{http_code}\n" \
"https://api.infrai.cc/v1/sms/suppression/list" -H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS -o /dev/null -w "signature/list %{http_code}\n" \
"https://api.infrai.cc/v1/sms/signature/list" -H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS -o /dev/null -w "template/list %{http_code}\n" \
"https://api.infrai.cc/v1/sms/template/list" -H "Authorization: Bearer ${INFRAI_API_KEY}"
On our own account all three return 200 — the reads are live and self-hosted. The one boundary the probe surfaces is a tier gate, not an outage: POST /v1/sms/template/create answers 402 PRO_REQUIRED on a free account, and that boundary is declared up front as minimum_tier: "pro" on GET /v1/discovery/sms.template.create, so the agent can read it before it writes the call. That’s the thing to know rather than discover during a demo: the catalogue tells you what the platform implements and what tier each route needs, your key’s plan state decides what it can do today, and only a request confirms it. Any provider you evaluate deserves the same three-command test.
Let the agent quote a real price
Hardcoded costs are how a generated app ends up lying to its owner in a README. Sends are billed and reads are free, which is the durable structure; the numbers themselves move. Read live on 2026-07-27, POST /v1/sms/send bills $0.008395 per_message and POST /v1/email/send bills $0.00046 per_email, with $2 of trial credit on a new account. Rates move in both directions as upstream contracts and discount campaigns change, which is exactly why the agent should read them rather than paste them.
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Have the agent call that in a smoke test and print the breakdown by capability. It’s also the answer to “what did the feature you just generated cost me last week”, which is a question a single account can answer and a stack of point solutions can’t.
When to send your agent to a specialist instead
If your app is SMS-shaped — short codes, ten-digit long code registration, carrier-level routing rules, inbound conversation threading — Twilio and Sinch have deeper surfaces and, frankly, more training data behind them, so an agent will write against them with more confidence. Vonage sits in the same bracket for voice-plus-SMS work. The gateway argument isn’t that they’re worse; it’s that one key covering SMS, email, queues and scheduling means the agent never has to invent a second integration, and that the contract it reads is generated from the same source the API serves.
Worth flagging one boundary either way: there are no X-RateLimit-* or Retry-After headers here, so an agent cannot write a predictive limiter. It has to bound its own concurrency.