Phone verification at signup with SMS OTP, in two calls
send_code then verify, with E.164 normalisation, the abuse budget SMS OTP creates, and where a phone factor is the wrong choice.
Phone verification on Infrai mirrors the email flow exactly: POST /v1/auth/phone/send_code texts a code, POST /v1/auth/phone/verify checks it and returns a session. The same purpose enum (verify or login) decides whether you’re proving a number belongs to an existing user or using it as the login itself, and the SMS delivery runs on the same credential as the rest of your stack.
What’s different from email is the economics. Email OTP is effectively free to send; SMS is not, and every unprotected send_code endpoint is someone’s opportunity to spend your money.
Send the code
curl -sS -X POST "https://api.infrai.cc/v1/auth/phone/send_code" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"phone": "+8613810096328", "purpose": "verify", "locale": "zh-CN"}'
{
"ok": true,
"data": { "ok": true, "phone": "+8613810096328", "expires_in_seconds": 300 }
}
Five minutes, echoed back in expires_in_seconds. Read it rather than hardcoding it.
locale matters more here than in email, because an SMS is short and a user who gets an English code template for a Chinese number will read it as spam. Pass the user’s actual language tag.
Normalise the number before you send it
The single most common bug in phone auth has nothing to do with the API.
0138 1009 6328, 138-1009-6328 and +86 138 1009 6328 are one phone number and three strings, and if you store whatever the user typed, you’ll create one identity per format and then wonder why the verified flag doesn’t stick. Normalise to E.164 at the edge of your system, once, before anything touches the platform — and do it with a library that knows national prefixes rather than a regex, because the rule for dropping a leading zero is country-specific and the version you write yourself will be right for your own country and wrong for the next market you enter.
import { parsePhoneNumberFromString } from "libphonenumber-js";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
export function toE164(raw, defaultCountry = "CN") {
const parsed = parsePhoneNumberFromString(String(raw), defaultCountry);
if (!parsed?.isValid()) throw new Error(`not a valid phone number: ${raw}`);
return parsed.number; // always +<country><national>
}
export async function startPhoneVerification(raw, locale = "en") {
const res = await fetch(`${API}/v1/auth/phone/send_code`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({ phone: toE164(raw), purpose: "verify", locale }),
});
const payload = await res.json();
if (!payload.ok) throw new Error(payload.error?.code ?? "send_failed");
return payload.data.expires_in_seconds;
}
Verify and attach
curl -sS -X POST "https://api.infrai.cc/v1/auth/phone/verify" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"phone": "+8613810096328", "code": "704813", "login": true}'
{
"ok": true,
"data": {
"access_token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImluZnJhaS1hdXRoLWVkMjU1MTktdjEi...",
"refresh_token": "au_rft_9wQ1zV6pLkS3dHyBnMfE",
"expires_in": 900
}
}
login: true starts a session. Pass false when the person is already signed in and is only adding a number — that’s the settings-page case, and you don’t want it minting a second session.
To record the number on the user afterwards, PATCH /v1/auth/user/update/{user_id} takes name and metadata, so a metadata.phone_verified_at timestamp is a reasonable place to keep your own audit trail:
curl -sS -X PATCH "https://api.infrai.cc/v1/auth/user/update/au_usr_lMmXGySJeGVM1xicqHGJaJbB" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB", "metadata": {"phone_verified_at": "2026-09-21T02:24:30Z"}}'
metadata replaces wholesale, so read the user first with GET /v1/auth/user/get/{user_id} and merge rather than clobbering fields another feature relies on.
The abuse budget
SMS costs real money per message, which makes send_code the one auth endpoint with a direct financial attack. Three defences, in order of effectiveness:
| Control | Where it lives | Stops |
|---|---|---|
| Per-number and per-account rate limit | the platform, AUTH_RATE_LIMIT | repeat sends to one number |
| Resend cooldown of 30-60s | your client | users mashing the button |
| A captcha before the first send | POST /v1/captcha/verify | scripted enumeration |
| A monthly budget cap | POST /v1/account/budget/set | the worst case costing more than you can absorb |
That last row is the one people skip and then regret. The cap is a single call on the same key, and it turns an unbounded loss into a bounded one.
When a phone factor is the wrong choice
Don’t use SMS as the only login method for an account that holds anything valuable. Numbers get recycled by carriers, and SIM-swap attacks target exactly this flow — that’s a limitation of the channel, not of any particular API. Treat a verified phone as a convenience factor and a recovery hint, and keep email or a passkey as the authoritative identity.
Also worth flagging: if your product needs deep telephony — short codes, per-country sender registration, two-way conversations — Twilio has a decade of that and a general platform won’t match its depth. Vonage sits in the same bracket. Use them when messaging is your product.
What the same key gives you next
The verification text goes out through the platform’s own SMS surface, so there’s no second vendor to onboard. The follow-on steps are equally close: POST /v1/sms/send for the “welcome, you’re verified” message, GET /v1/sms/status/{id} to confirm it landed, and POST /v1/errors/capture when a code never arrives and you want the failure recorded rather than lost. One key, one bill, one usage view.
Both auth routes on this page report billing_class: free in discovery — the OTP verification itself isn’t billed per call, and identity is metered per monthly active user the way Auth0 and Clerk meter it. The SMS leg is the billable part, and GET /v1/discovery carries the current per-message rate for sms.send while GET /v1/account/usage shows what you actually spent; both read live, verified 2026-09-21. Rates on this platform move down as vendor contracts change, so read them rather than quoting this page.