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 forWhy an agent needs itHow to check it in one command
Machine-readable route catalogueremoves guessed field names entirelycurl .../v1/discovery returns routes plus body fields
No SDK, plain RESTno version drift between the model’s memory and your lockfilethe send above is the whole integration
One credential across capabilitiesagent doesn’t invent a second auth flow for email or queuessame Authorization: Bearer on every route
Classifiable errorsdecides retry vs stop without a humanforce one bad input and read the envelope
Prices readable at runtimethe agent can report cost instead of inventing itcurl .../v1/account/usage
Live route probecatches a catalogue that’s ahead of realityrequest each read 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 trap worth knowing before your agent writes a wrapper

Input-validation failures on the SMS routes don’t arrive as a 4xx. A recipient that isn’t valid E.164 comes back through the vendor channel as HTTP 503 VENDOR_DOWN with retryable: true, and the actual reason lives only in the human-readable message:

{
  "ok": false,
  "error": {
    "code": "VENDOR_DOWN",
    "http_status": 503,
    "message": "sms.send failed: to must be a valid E.164 phone number",
    "retryable": true,
    "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 — will spin on that forever, because no amount of waiting fixes a malformed number. The same class shows up on the email route. So the rule to hand your agent is: retryable describes the channel the error came through, not the fixability of the request, and a 5xx whose message quotes your own input is permanent.

// 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 "inbound/list     %{http_code}\n" \
  "https://api.infrai.cc/v1/sms/inbound/list" -H "Authorization: Bearer ${INFRAI_API_KEY}"

On our own account the first two return 200 and the third returns 503 VENDOR_NOT_CONFIGURED, even though the catalogue lists it as live and self-hosted. GET /v1/sms/events/{id} behaves the same way, and POST /v1/sms/template/create answers 402 PRO_REQUIRED despite being listed as free. That’s a limitation you should know about rather than discover during a demo: the catalogue reflects what the platform implements, your account’s key and plan state decide what it will actually do, and only a request tells you which. 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. SMS sends were $0.007475 per message and email sends $0.000115 per message when we verified on 2026-07-26, with $2 of trial credit on a new account, and rates on this platform have trended downward with discount campaigns running periodically.

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.

References

Browse more sms developer guides