The simplest SMS OTP API for SaaS login: two calls, one key

Measure OTP providers by the parts you don't have to own, then wire phone-code login into an Express app with two Infrai routes and no code storage.

The simplest phone-code login is the one where your server never holds a code. Ask the gateway to send one, then ask it whether the digits a user typed are right — two calls, no secret in your database, nothing to expire on a cron. Infrai does that with POST /v1/sms/otp and POST /v1/sms/verify, and because it’s the same key that already reaches your email, queue and storage, adding login codes doesn’t add a vendor.

“Simplest” deserves a definition sharper than vibes, though, because every provider’s landing page claims it. Count parts instead: how many things must exist in your codebase, your database and your vendor list before a user can log in?

Count what you’d otherwise own

ConcernRaw SMS API + your own codesManaged OTP route
Code generationCSPRNG, fixed length, no biasGateway
StorageHashed, salted, per-user rowGateway
ExpiryTTL column plus a sweeperGateway
Attempt capCounter, plus a lock on the rowGateway
Replay preventionMark consumed atomicallyGateway
Send throttle per numberYoursYours
Sender registrationYoursYours
Copy and localisationYoursTemplate you register

Five rows move. That’s the entire pitch for a managed route, and it’s a real saving — the storage row alone is where most home-grown implementations acquire their first vulnerability.

The two rows that stay yours are the ones people forget when they estimate this at half a day.

Two calls, end to end

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": "+441632960541", "template": "login_code_en"}'
{
  "ok": true,
  "data": {
    "request_id": "otpreq_5a9e33b170",
    "sent": true
  }
}

The response carries no code and no message id. That’s deliberate: there’s nothing to leak into a log aggregator, and nothing for a support engineer to read out loud. When the user types the digits back, you hand them straight to the verify route:

curl -sS -X POST "https://api.infrai.cc/v1/sms/verify" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to": "+441632960541", "code": "739204"}'
{
  "ok": true,
  "data": {
    "verified": true,
    "to": "+441632960541"
  }
}

Two calls. No table, no sweeper, no salt.

The Express integration

mkdir otp-login && cd otp-login
npm init -y
npm pkg set type=module
npm install express
// server.mjs — Node 22 ESM, Express 4
import express from "express";

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 COOLDOWN_MS = 45_000;
const lastSend = new Map(); // phone -> epoch ms; use Redis with more than one instance

const app = express();
app.use(express.json());

async function infrai(path, body) {
  const res = await fetch(BASE + path, {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const payload = await res.json().catch(() => ({}));
  if (res.ok && payload.ok !== false) return payload.data ?? payload;
  const err = payload.error ?? {};
  const badInput = /e\.164|invalid|malformed|recipient/i.test(err.message ?? "");
  throw Object.assign(new Error(err.message ?? `HTTP ${res.status}`), {
    code: err.code ?? `HTTP_${res.status}`,
    permanent: badInput || (res.status < 500 && res.status !== 429),
  });
}

app.post("/auth/start", async (req, res) => {
  const phone = String(req.body.phone ?? "");
  if (!/^\+[1-9]\d{7,14}$/.test(phone)) return res.status(400).json({ error: "phone must be E.164" });
  const since = Date.now() - (lastSend.get(phone) ?? 0);
  if (since < COOLDOWN_MS) return res.status(429).json({ error: "cooldown", retryAfterSec: Math.ceil((COOLDOWN_MS - since) / 1000) });
  try {
    await infrai("/v1/sms/otp", { to: phone, template: "login_code_en" });
    lastSend.set(phone, Date.now());
    res.json({ status: "sent" });
  } catch (error) {
    console.error("otp send failed", error.code, error.message);
    res.status(error.permanent ? 400 : 503).json({ error: error.code });
  }
});

app.post("/auth/check", async (req, res) => {
  const phone = String(req.body.phone ?? "");
  const code = String(req.body.code ?? "");
  if (!lastSend.has(phone)) return res.status(400).json({ error: "no_challenge" });
  try {
    const data = await infrai("/v1/sms/verify", { to: phone, code });
    if (!data.verified) return res.status(401).json({ error: data.reason ?? "wrong_code" });
    lastSend.delete(phone);
    res.json({ status: "ok", session: "issue your JWT or cookie here" });
  } catch (error) {
    res.status(503).json({ error: error.code });
  }
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));

Start it with INFRAI_API_KEY=your_infrai_api_key node server.mjs and drive it from another terminal:

curl -sS -X POST http://localhost:3000/auth/start \
  -H "Content-Type: application/json" \
  -d '{"phone": "+441632960541"}'

curl -sS -X POST http://localhost:3000/auth/check \
  -H "Content-Type: application/json" \
  -d '{"phone": "+441632960541", "code": "739204"}'

The no_challenge guard on /auth/check isn’t cosmetic. Verifying a number that was never sent a code still bills $0.005 and answers verified: false with reason: "no_code_issued" — a paid non-answer. That one lastSend.has() line is the difference between a bounded cost and an open tab.

Rate limits and retries, realistically

There are no X-RateLimit-* headers and no Retry-After on these routes, so you can’t see how close you are to the ceiling; you learn about it when SMS_RATE_LIMIT comes back with HTTP 429. Plan for reactive handling — exponential backoff on 429, and your own per-phone cooldown in front so the platform limit is never the first thing that stops you.

The retry rule that matters more is knowing when not to. A recipient that isn’t E.164 comes back as HTTP 503 with code VENDOR_DOWN and retryable: true:

{
  "ok": false,
  "error": {
    "code": "VENDOR_DOWN",
    "http_status": 503,
    "message": "recipient must be E.164 (got '07700900461')",
    "retryable": true
  }
}

A stock “retry all 5xx” wrapper will spin on that until something times out, and your user sits watching a spinner. The permanent flag in the Express handler above exists precisely for this: read the message, don’t trust the flag alone. It’s a caveat worth knowing before you copy a generic HTTP client into your auth path.

Shipping to the US and the EU from one codebase

The two rows the table left with you bite differently on each side of the Atlantic. US application traffic has to originate from a registered sender, and registration is measured in days, so start it in week one of the project rather than the week before launch. EU destinations are friendlier to an alphanumeric identity but stricter about the data: a phone number collected for authentication is personal data with a purpose limit, which in practice means don’t quietly reuse the login number for marketing, and give it a retention period in your own schema.

Neither of those is an API problem, which is exactly why they get missed in a provider comparison. Budget for them in the plan, not the sprint.

What it costs

An OTP send runs about $0.007475 per message and a verify call $0.005, both read on 2026-07-26; the send figure is approximate because destination and vendor move it, while the verify price is flat. Status, suppression and signature reads are free but rate-limited. New accounts get $2 of credit — roughly 267 sends or 399 verifies — which is enough to test both US and EU delivery properly before spending anything. Rates here move downward over time and discount campaigns run, so read today’s rather than believing this paragraph:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id == "sms.otp" or .id == "sms.verify")
        | {id, price: .billing.price_usd, unit: .billing.unit, trial: .billing.new_account_trial_uses}'

A quick sanity read before you blame delivery for a failed login:

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

Picking between the real options

OptionBest whenCost shape
Infrai managed OTPPhone codes are one capability among many you needPer message plus per verify
Twilio VerifyYou want voice, WhatsApp and email fallback orchestrated for youPer verification attempt
Vonage VerifyYou want a built-in multi-step escalation across channelsPer verification workflow
Raw SMS + your own codesYou need unusual code semantics or offline verificationPer message only, plus your engineering time

Twilio Verify is the better buy if a second channel is a launch requirement rather than a later nice-to-have — Infrai doesn’t support voice or WhatsApp delivery of codes, and pretending otherwise would waste your week. Vonage suits teams who want the escalation sequence itself to be configuration.

Where this route wins is the second question. Once login works, you’ll want to email a security notice on new-device sign-in, queue an audit event, track the errors your auth handler throws, and answer “what did authentication cost tenant 4471 last month” — all of which are the same key, the same bill and one usage query here, versus four vendors and four rotation schedules elsewhere.

References

Browse more sms developer guides