Password reset: email API or SMS OTP, on cost and on effort
For SaaS account recovery the email route is roughly two orders of magnitude cheaper and needs less registration paperwork. Here's the arithmetic and the exceptions.
For password recovery, email wins on both axes, and it isn’t close. A code-style reset over email on Infrai — POST /v1/auth/password/reset_request then POST /v1/auth/password/reset_confirm — is free, needs no carrier paperwork, and hands you session revocation as part of the deal. The SMS equivalent costs real money per attempt and drags US and EU sender registration behind it.
That’s the answer. The rest of this is the arithmetic, the failure modes that actually decide these things in production, and the two situations where you should reach for the phone anyway. Twilio and Vonage both sell excellent verification products; neither changes the shape of the comparison below, because the cost of an SMS is a carrier cost and everybody pays it.
The arithmetic
One recovery is one message plus at least one code check. Priced per completed reset, verified 2026-07-26:
| Email code | SMS code | |
|---|---|---|
| Delivery | free on the auth route | ~$0.0075 per message |
| Code check | free on the auth route | ~$0.005 per attempt, right or wrong |
| Typical completed reset | $0 | ~$0.0125 |
| Retries (user asks again) | $0 | another ~$0.0075 each |
| 10,000 resets a month | $0 | ~$125 |
The generic transactional email route sits at roughly $0.000115 per message if you’d rather build your own reset on top of it — still about 65× below an SMS. Pull the current figures rather than trusting the table, because these rates drift downward and discount campaigns run:
curl -s https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.capabilities[]
| select(.id=="email.send" or .id=="sms.otp" or .id=="sms.verify"
or .id=="auth.password.reset_request")
| {id, billable: .billing.is_billable, price: .billing.price_usd, unit: .billing.unit}]'
New accounts carry $2 free credit. On the email path that’s effectively unlimited resets during development; on the SMS path it’s about 160 completed recoveries.
The managed reset, in two calls
No token signing, no expiry column, no “reset_tokens” table. The request never reveals whether the account exists:
curl -X POST https://api.infrai.cc/v1/auth/password/reset_request \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{ "email": "dana@example.com" }'
{
"ok": true,
"data": { "sent": true },
"metadata": { "vendor": "infrai_native", "cost_usd": 0.0 }
}
sent: true comes back for an address that has no account at all — that’s deliberate, and it’s the single most commonly botched detail in a hand-rolled reset. Then the confirm:
curl -X POST https://api.infrai.cc/v1/auth/password/reset_confirm \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"email": "dana@example.com",
"code": "774102",
"new_password": "Zq9!vTr2mLp4"
}'
Confirm re-hashes with argon2id and revokes every existing session, which is the behaviour you want and the step people forget. Passwords are never recoverable in plaintext here, only replaced.
The SMS version of the same job
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": "{code} is your Acme recovery code." }'
Then POST /v1/sms/verify with to and code, and then — this is the part that gets missed — you still have to set the new password yourself, because the SMS pair verifies a phone rather than completing a reset. So the SMS route is two paid calls plus your own password-change handler, against two free calls and none.
Simpler is a measurable thing here, not a matter of taste.
Where they really differ: knowing what happened
Email gives you an auditable delivery trail. A recovery that “didn’t arrive” is usually a suppression, and one free call settles it:
curl -s https://api.infrai.cc/v1/email/suppression/check/user@example.com \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"email": "user@example.com",
"reason": "manual",
"added_at": "2026-07-04T17:02:22.803322Z",
"scope": "account",
"attempt_count_blocked": 0,
"suppressed": true
}
}
A suppressed address silently swallows every reset you send it, forever, and no amount of retrying helps. If that address belongs to a paying customer locked out of their account, you need to see it in one call — which you can. For a message you did send, the per-event timeline is free too:
curl -s "https://api.infrai.cc/v1/email/event/list?message_id=msg_9Ht2LqPd0Rk4" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" | jq '.data.items'
[
{ "type": "delivered", "at": "2026-07-26T01:11:56.402Z", "recipient": "dana@example.com" },
{ "type": "sent", "at": "2026-07-26T01:11:52.181Z", "recipient": "dana@example.com" },
{ "type": "queued", "at": "2026-07-26T01:11:52.170Z", "recipient": "dana@example.com" }
]
Note message_id is a query parameter, not a path segment — a request without it returns a 400, and the timestamp field is at. The SMS side has GET /v1/sms/status/{id} for the same purpose, and GET /v1/sms/events/{id} for a fuller timeline — though the latter reads through to the carrier and returns a 503 VENDOR_NOT_CONFIGURED if the account’s SMS vendor key isn’t hydrated. Even when it works, carrier filtering frequently reports “delivered to carrier” for a message the handset never showed. Email tells you more, and tells you sooner.
The paperwork tax, US and EU
| Email recovery | SMS recovery | |
|---|---|---|
| Before first send | Publish SPF, DKIM, DMARC for your domain | Register a 10DLC campaign (US) or a sender ID (several EU markets) |
| Lead time | Minutes to hours, DNS-bound | Days to weeks, carrier-bound |
| Ongoing | Watch bounce rate and reputation caps | Per-country sender rules, content restrictions |
| Failure to do it | Mail lands in spam | Messages are blocked or surcharged |
Domain verification is annoying. Carrier registration is a project. If you’re picking a recovery channel for a product launching next month, that row alone probably decides it.
Security: recovery is not step-up
NIST’s SP 800-63B restricted SMS as an out-of-band authenticator years ago, and SIM-swap attacks are the reason. For recovery the argument is even sharper: an attacker who controls the phone number gets the account, and phone numbers get recycled by carriers in a way email addresses don’t.
The honest counter is that email has its own single point of failure — if the mailbox is compromised, so is everything. That’s true, and it’s why neither channel is a substitute for a second factor on the account itself. Recovery decides who gets back in; step-up decides who may do something dangerous while already in. Different problems, and the cheap channel is fine for the first.
When to use SMS anyway
Two cases, both real. If your product has users who never verified an email address — consumer mobile apps that signed people up by phone — SMS isn’t a choice, it’s the only identifier you hold. And if you’re recovering access to something with immediate financial consequence, requiring both channels is defensible: the cost stops mattering when the alternative is a chargeback.
Outside those, the trade-off runs one way. The catch is that “cheaper” only holds if you don’t rebuild the free path yourself — a hand-rolled reset on top of a raw email API costs you the token table, the expiry job, the timing-safe comparison and the session revocation, and those are where the bugs live.
One limitation worth flagging on the Infrai side: neither the auth routes nor the SMS routes emit X-RateLimit-* or Retry-After headers, so your own per-identity throttle is the only backoff signal you get. If you want a verification service with configurable rate-limit objects and a console to tune them, Twilio Verify is the better product and always has been. What you’d trade away is the single credential — the same key doing the reset also sends your receipts, stores the export, runs the cron and reports per-tenant spend, which for most SaaS backends is worth more than a tuning dashboard.