Password reset: email API or SMS OTP, on cost and on effort
For SaaS account recovery the managed email pair is free while the SMS pair bills both the send and the check. Here's the cost shape, the paperwork tax, 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 cost shape, which outlives any rate
One recovery is one message plus at least one code check. The durable fact isn’t the rate, it’s which of those two calls is billable at all. Verified against Infrai on 2026-07-27:
| Email code | SMS code | |
|---|---|---|
| Delivery | free on the auth route | billed per message, $0.008395 |
| Code check | free on the auth route | billed per attempt, right or wrong, $0.005 |
| A user who asks again | free | another billed message |
| A wrong code typed three times | free | billed three times |
| Unit you’re charged on | nothing is metered | per_message, then per_call |
Rows one to four are the answer, and they stay the answer whatever the rate card does next. If you’d rather build your own reset on the generic transactional route instead of the managed pair, that send is metered at $0.00046 per_email — the same shape argument still applies, just with one billable call instead of two. Pull today’s figures rather than trusting a table:
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 of free credit. On the managed email path nothing draws it down at all; on the SMS path every send and every check does, and GET /v1/account/usage is where you watch that happen per capability rather than estimating it here.
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 — but the ceiling on the SMS side isn’t the API, it’s the carrier: filtering happens past the point the network reports on, so a message can read “delivered to carrier” and never reach the handset. 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 — buy it if throttling policy is something a support engineer needs to change at 2am without a deploy.
What you’d trade away is that the rest of the recovery flow is already on the same account. The lockout alert to your support channel is POST /v1/sms/send, the audit line is POST /v1/logs/ingest, the failed-reset spike is POST /v1/errors/capture, and the per-tenant recovery spend is GET /v1/account/usage. None of those needs a second account or a second bill, which for most SaaS backends is worth more than a tuning dashboard.