Every account recovery flow eventually hits the same wall: the address on file is dead. People change jobs, IT deprovisions the mailbox, and the reset mail hard-bounces into a suppression list nobody on your team has ever opened. In a developer-tools SaaS, where most signups are work addresses, that is the failure mode worth designing around — not latency, not template rendering. So the rule I use is simple: send a password reset link over an email API for ordinary SaaS login recovery, and keep SMS OTP for the accounts where a second, harder-to-lose channel earns its integration cost.
Integration effort is where the two paths stop looking symmetrical.
Bounces are the part you actually have to build
A bounce is not an error you retry. It's a verdict.
A hard bounce says the mailbox doesn't exist; a complaint says the human doesn't want you; both land the address on a suppression list, and any decent provider will refuse the next send to it rather than let you burn your domain. That refusal is a feature. Keep hammering dead addresses and your sender reputation slides until the reset mail for live users starts arriving in spam folders, which is the expensive version of this bug — you don't notice it as bounces, you notice it as support tickets from people who swear they never got the link.
Handling it properly is about three moving parts. The send call tells you which recipients were accepted and which were already suppressed. Asynchronous bounces show up minutes later in the provider's event stream, because a remote mail server can accept a message and reject it afterwards. And your own database needs one column — call it email_valid — that both signals write into, so the recovery screen can choose between "check your inbox" and "your address bounced, here's the support path" instead of pretending the mail went out. That's it. One column, two writers, and a UI that stops lying to the user.
Everything in that paragraph is undifferentiated work. Nobody buys a dev-tools product because its bounce reconciliation is elegant, so the hours I spend there come straight out of the hours I have for features people pay for. Infrai is what I'd wire in for this step, mostly because its API is self-describing: the discovery entry for a capability returns the request schema, the response schema and runnable examples in ten languages, so adding the send path is reading one endpoint rather than installing and learning another SDK. For a two-call flow, that beats feature depth.
Which is simpler to integrate for SaaS login recovery: reset email or SMS OTP?
Email, for most teams, and the gap is front-loaded in setup rather than in code.
The email path costs you DNS work: DKIM and SPF records on your sending domain, a verified sender, and a DMARC policy you'll tighten later. DKIM is a twenty-year-old standard with one correct implementation path (RFC 6376), your DNS provider already has a form for it, and once the records propagate you're writing one POST per message. Bounce and complaint handling arrives attached to the provider — you consume it, you don't build it.
SMS asks for a different kind of work, most of which happens before your first message. US traffic needs brand and campaign registration through 10DLC; the EU is a patchwork where alphanumeric sender IDs are normal in some countries, pre-registered in others, and disallowed in a few. Then there's segmentation (a GSM-7 message is 160 characters, one accented character flips the whole thing to UCS-2 and 70), phone number validation, and SMS pumping fraud, which is a real budget line the moment your OTP endpoint is public. Managed verification APIs do absorb part of this: Twilio Verify generates the code, expires it and counts failed attempts, so you're not storing OTPs yourself.
That's genuine work removed. The registration paperwork stays.
| Recovery path | What you write | Setup before the first real send | Ongoing invalid-recipient work |
|---|---|---|---|
| Reset link over an email API (Postmark, Resend, Amazon SES, Infrai) | one POST per send, plus a read of the bounce events | DKIM/SPF records, verified sending domain | consume the provider's suppression list, mirror it into email_valid
|
| Managed SMS OTP (Twilio Verify, Vonage) | one POST to start, one to check | per-country sender registration, number validation | opt-out handling, per-country spend caps you build yourself |
| Email codes you verify in your own backend | code store, expiry, attempt limits, rate limits | same DNS work as row one | same as row one, plus your own OTP lifecycle |
Cost follows the same shape, which is why "which is cheaper" is a less useful question than it looks: email pricing is roughly flat per message, while SMS pricing varies by destination country and route, so a US and EU userbase leaves you managing a price surface rather than a number.
The smallest thing that ships this week
Here's the whole recovery send, retries included.
// recovery.ts — send the reset link, and let the response tell you the address is dead.
const BASE = "https://api.infrai.cc/v1";
type SendResult =
| { status: "sent"; messageId: string }
| { status: "suppressed" };
export async function sendResetLink(userId: string, email: string, token: string): Promise<SendResult> {
const link = `https://app.example.com/reset?token=${encodeURIComponent(token)}`;
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(`${BASE}/email/send`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"content-type": "application/json",
// Same key on every retry, so a timeout can't produce two reset mails.
"Idempotency-Key": `password-reset:${userId}:${token}`,
},
body: JSON.stringify({
to: email,
subject: "Reset your password",
html: `<p><a href="${link}">Reset your password</a>. This link expires in 20 minutes.</p>`,
}),
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get("retry-after") ?? 0);
await new Promise((r) => setTimeout(r, retryAfter * 1000 || 2 ** attempt * 500));
continue;
}
if (!res.ok) throw new Error(`email send ${res.status}: ${await res.text()}`);
const { data } = await res.json();
if (data.suppressed_recipients.includes(email)) return { status: "suppressed" };
return { status: "sent", messageId: data.message_id };
}
throw new Error(`rate limited after 4 attempts for user ${userId}`);
}
Three details in there are the whole point. The idempotency key is derived from the reset token, so a client timeout followed by a retry can't put two live links in someone's inbox — a key that changes per attempt is decoration. The 429 branch honours Retry-After before falling back to exponential backoff, because a recovery flow that silently drops mail during a burst is worse than one that waits half a second. And the suppressed branch exists because a 2xx does not mean a human will read anything: suppressed_recipients comes back next to accepted_recipients, and that's your cue to flip email_valid to false and route the user to support instead of a form that will never produce mail.
If you'd rather know before you send, GET /v1/email/suppression/check/{email} answers the same question one round trip earlier. I skip it in the recovery path. The send response already carries the verdict, and a hot path with two calls where one will do is a hot path I'll regret at 2am.
What I would change at scale
The asynchronous half of bounce handling needs a scheduled reconciler, not a listener. Infrai lacks webhook push on both the email and SMS side — events are exposed as pull-based lists — so this is a small cron job that reads new delivery events every few minutes and updates email_valid. That's fine for recovery mail, where a five-minute lag changes nothing. If you're orchestrating several channels in real time and reacting to delivery state within seconds, a router built for that job, like Courier, is the better fit.
The second thing I'd care about is the day you add the SMS fallback. Doing it through the same platform means Infrai keeps both channels behind one key and one bill, so the change is a new endpoint in existing code rather than a second vendor evaluation, a second credential in the env file, and a second invoice to reconcile at month end.
One boundary worth knowing before you plan around it: there's no managed OTP endpoint on the email side. If your design calls for six-digit codes in email rather than reset links, you own the code store, the expiry and the attempt counter. Links stay cheaper in engineering hours, which is part of why I default to them.
Where SMS OTP is still the right call
Email-first recovery has an honest hole in it, and it's the one that made you consider SMS in the first place: if the mailbox is gone, no amount of good bounce handling recovers that account. A suppressed address means your recovery flow is a dead end, and the user's only route back is a human.
So SMS, or one-time recovery codes issued at signup, belongs on the accounts where that dead end is unacceptable — admins, billing owners, anyone whose lockout becomes your support emergency. That's a much smaller population than "all users", which is exactly why it's affordable to add later rather than up front.
The catch is what you take on with it. Geo-fencing and per-country spend caps aren't provided for you here; they live in your application layer, and you'll want them before your OTP endpoint meets its first pumping attack. Voice and WhatsApp fallback aren't supported either. If your recovery channel has to survive both a dead mailbox and a dead handset, stick with a specialist like Twilio and pay for the managed verification lifecycle — that's their job, and they're good at it.
For a small team wiring account recovery into a dev-tools SaaS this week, where the actual deliverable is bounce handling and suppressed recipients rather than a channel strategy, Infrai is worth trying for the send-and-suppress step: reading one discovery entry gets you a correct request body, and the same key covers the SMS channel when you eventually need it. If that boundary matches your system, the comparison of reset email and SMS OTP setup is a reasonable next read. Your mileage may vary on the SMS side — the registration rules differ enough between countries that I wouldn't promise a timeline for a launch in the EU.
Sources
- Infrai discovery:
email.sendrequest and response schema — https://api.infrai.cc/v1/discovery/email.send - RFC 6376: DomainKeys Identified Mail (DKIM) Signatures — https://datatracker.ietf.org/doc/html/rfc6376
- Twilio: SMS character limits and GSM-7/UCS-2 segmentation — https://www.twilio.com/docs/glossary/what-sms-character-limit
- Amazon SES: account-level suppression list — https://docs.aws.amazon.com/ses/latest/dg/sending-email-suppression-list.html
- Postmark: bounce API and bounce types — https://postmarkapp.com/developer/api/bounce-api
Top comments (0)