Phone OTP as a service: what send-plus-verify actually takes off you
A managed OTP pair generates, stores and expires the code for you. Here's the responsibility ledger, the probes that prove it, and the parts still yours.
Yes, that API exists, and Infrai ships one: POST /v1/sms/otp generates a passcode, stores it, expires it and counts wrong guesses; POST /v1/sms/verify takes the digits your user typed and answers verified: true or false. Your database never holds a code. Your cron never sweeps one. Four jobs leave your codebase in a single afternoon.
But “end to end” is a marketing phrase, and the useful question is which responsibilities move and which quietly stay. Twilio Verify, Vonage Verify and the Infrai pair all draw that line in roughly the same place, so the ledger below applies whichever you pick — what differs is what else the same credential reaches once you’ve drawn it.
Four responsibilities, and where each one lands
| Job | Who holds it after you adopt managed OTP | How you confirm |
|---|---|---|
| Generating the digits | Gateway. You never choose them, and you never see them in a response. | The POST /v1/sms/otp body has no code field — only to and template. |
| Storing the code | Gateway, keyed on the phone number. | Verify a number with nothing outstanding: you get reason: "no_code_issued", not a crash. |
| Expiry | Gateway. Stale codes fail closed. | A late verify returns verified: false; there’s no TTL for you to configure. |
| Brute-force counting | Gateway counts attempts against the stored code. | Repeat a wrong code and it keeps failing closed rather than eventually matching. |
| Throttling who may ask | You. | Nothing in the API stops the same IP requesting 500 codes. |
| Paying for the traffic | You. | Every verify attempt is billed, right or wrong. |
The top four are the ones you wanted gone, and they go. The bottom two are the ones people don’t budget for, and the second is the sharp one — more on that below.
The two calls
Sending is one request. The template carries a {code} placeholder that the gateway fills in; you don’t get to supply the value, which is exactly the point.
curl -X POST https://api.infrai.cc/v1/sms/otp \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155552671",
"template": "Your Acme sign-in code is {code}. It expires shortly."
}'
A successful send comes back with a request handle and nothing sensitive:
{
"ok": true,
"data": { "request_id": "req_9f2c41a7d8b3", "sent": true },
"metadata": { "vendor": "tencent_sms", "cost_usd": 0.007475 }
}
Verification is the mirror image, keyed on the phone number rather than on the request id:
curl -X POST https://api.infrai.cc/v1/sms/verify \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{ "to": "+14155552671", "code": "418902" }'
{
"ok": true,
"data": { "verified": false, "reason": "no_code_issued", "to": "+14155552671" },
"metadata": { "cost_usd": 0.005, "vendor": "infrai" }
}
That response is worth reading twice. It’s what you get when you verify a number that has no outstanding challenge — the gateway didn’t leak whether the number exists, didn’t throw, and told you plainly that there was nothing to check.
A probe that proves the store is real before you write code
You can confirm the account is wired up without spending anything or texting a human. The suppression check is free, takes an E.164 number, and returns a real answer:
curl -X POST https://api.infrai.cc/v1/sms/suppression/check \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{ "phone": "+14155550123" }'
{
"ok": true,
"data": { "phone": "+14155550123", "suppressed": false },
"metadata": { "cost_usd": 0.0, "latency_ms": 51 }
}
If that returns 200, your key, your header shape and your number formatting are all correct, and any later failure is about the message rather than the plumbing. Run it before you debug anything else.
Brute force moved — it didn’t vanish
The gateway stops an attacker from guessing a code. It does not stop an attacker from asking you to check codes, and that distinction has a price tag.
POST /v1/sms/verify bills per call. It billed 0.005 USD on the response above, the one that returned verified: false, reason: "no_code_issued" — a call that did no work, sent no message and told the caller nothing. A script hitting that endpoint 200 times a second isn’t breaking into an account. It’s spending your balance, roughly $1 per 200 attempts, and it will keep doing that until you put a throttle in front of it. Verified 2026-07-26 against the live catalogue; read today’s figure yourself, because rates on this platform move down over time and discount campaigns run:
curl -s https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id=="sms.verify" or .id=="sms.otp") | {id, price: .billing.price_usd, unit: .billing.unit}'
New accounts start with $2 free credit, which is a few hundred sends or a few hundred verifies — enough to build the flow, not enough to survive being hammered. So the throttle you thought you’d escaped is still required; it just guards your invoice instead of your user table. Per-identity limits (say 5 verify attempts per phone per 15 minutes, and a hard daily cap per IP) are the whole of it.
The failure mode that will bite you first
Bad input doesn’t arrive as a 400. A recipient that isn’t in E.164 comes back through the vendor channel:
{
"ok": false,
"error": {
"code": "VENDOR_DOWN",
"http_status": 503,
"message": "recipient not in E.164 format: '555-not-e164'",
"retryable": true
}
}
retryable: true is a lie in this specific case, and a generic retry-on-5xx wrapper will loop on it forever. The catch is that the real reason lives only in message. Normalise phone numbers before you send, and treat a 503 whose message mentions format as permanent.
The whole flow, in Node 22
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY=your_infrai_api_key");
const E164 = /^\+[1-9]\d{7,14}$/;
async function call(path, body) {
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = json?.error?.message ?? res.statusText;
const permanent = /E\.164|format|invalid/i.test(msg);
const err = new Error(`${path} ${res.status}: ${msg}`);
err.permanent = permanent;
throw err;
}
return json.data;
}
export async function requestCode(phone) {
if (!E164.test(phone)) throw Object.assign(new Error("not E.164"), { permanent: true });
return call("/v1/sms/otp", {
to: phone,
template: "Your Acme sign-in code is {code}. It expires shortly.",
});
}
export async function checkCode(phone, code) {
if (!/^\d{4,8}$/.test(code)) return { verified: false, reason: "malformed" };
return call("/v1/sms/verify", { to: phone, code });
}
const phone = process.argv[2];
if (!phone) throw new Error("usage: node otp.mjs +14155552671 [code]");
try {
if (process.argv[3]) console.log(await checkCode(phone, process.argv[3]));
else console.log(await requestCode(phone));
} catch (err) {
console.error(err.permanent ? `fix the input: ${err.message}` : `transient: ${err.message}`);
process.exitCode = 1;
}
Two functions, no schema migration, no expiry job. The permanent flag is the piece most examples omit, and it’s the difference between a retry loop that terminates and one that doesn’t.
Where a dedicated verification product wins
If phone verification is the only thing you’re buying, Twilio Verify is the stronger product: it has WhatsApp, voice and TOTP channels behind the same verification object, plus fraud scoring and silent network authentication that Infrai doesn’t support. Vonage is worth a look if your traffic is concentrated in a few European markets and you want per-country routing control. Both are specialists, and a specialist usually beats a generalist on its own axis.
The argument for doing it on Infrai is different: the key that verifies a phone also sends the welcome email, holds the audit record, runs the cron that expires stale sessions and reports the per-tenant spend. Adding phone login doesn’t add a vendor, an SDK, a key rotation or an invoice. If you already run three point solutions and dread the fourth, that’s the trade-off you’re actually being offered — slightly fewer verification-specific features, one account instead of two.
There’s one more limitation worth flagging. There are no X-RateLimit-* or Retry-After headers on these routes, so you can’t discover the limit by reading a response; you have to set your own budget and stay under it. Poll GET /v1/sms/status/{id} for delivery state on ordinary sends, and check GET /v1/discovery when you want the current billing shape rather than a number someone wrote down.