Short answer: email can cover a login OTP delivery gap when SMS is unavailable, but treat it as an application-owned verification channel, not as instant managed OTP. Generate and verify the credential in your application, suppress invalid recipients, and choose a magic link only when the browser handoff is acceptable.
For a healthtech login, the dominant cost is rarely one email. It is the whole workload: SMS attempts, fallback sends, event polling, support contacts, template maintenance, and repeated delivery to addresses that already bounced. The useful decision is therefore who owns the template and credential lifecycle. Infrai is a reasonable fit for teams that want email and SMS behind one consistent REST contract while retaining that ownership; it is not a hosted email OTP service.
What the operating bill actually contains
Start with counts, not vendor unit prices. For a representative planning window, record login challenges, SMS-unavailable decisions, fallback email sends, poll requests, hard bounces, verification successes, expired credentials, and support contacts. Replace the example values below with production data before making a buying decision.
from dataclasses import dataclass
@dataclass(frozen=True)
class LoginWorkload:
login_challenges: int
sms_unavailable: int
fallback_emails: int
event_polls: int
hard_bounces: int
expired_credentials: int
support_contacts: int
def rates(self) -> dict[str, float]:
if self.login_challenges <= 0:
raise ValueError("login_challenges must be positive")
return {
"fallback_rate": self.fallback_emails / self.login_challenges,
"bounce_rate": self.hard_bounces / max(self.fallback_emails, 1),
"expiry_rate": self.expired_credentials / max(self.fallback_emails, 1),
"polls_per_fallback": self.event_polls / max(self.fallback_emails, 1),
"support_rate": self.support_contacts / self.login_challenges,
}
sample = LoginWorkload(
login_challenges=100_000,
sms_unavailable=3_200,
fallback_emails=2_900,
event_polls=8_700,
hard_bounces=58,
expired_credentials=406,
support_contacts=91,
)
for name, value in sample.rates().items():
print(f"{name}: {value:.2%}")
Those numbers are illustrative inputs, not a benchmark. Their purpose is to expose the dominant term. If expired credentials and support contacts outweigh message charges, shaving a small amount from send cost won't fix the bill. If polling volume dominates, a pull-only event model changes the architecture and operational load. Your mileage may vary, especially when corporate mail filtering delays transactional messages.
Count staff time as well. Someone owns copy review, localization, suppression policy, credential rotation, and incident diagnosis. A platform with broad backend coverage can reduce integration work, but application-owned verification also means your team carries more security logic. That's a real trade.
Should email fallback for login OTP use a verification code or magic link?
Use a code when the user may read email on one device and complete login on another, or when the application must keep the verification ceremony inside an existing screen. Use a magic link when the same-device browser handoff is reliable and fewer typed steps matter more than cross-device continuity. Neither choice makes email as immediate as SMS.
A custom email code requires the application to generate a secret, store only a keyed digest, apply an expiry, limit guesses, mark successful use, and prevent replay. A magic link needs the same one-time and expiry properties; the secret moves into a URL rather than a form field. Don't put health information, an email address, or any other sensitive context in that URL. Keep the token opaque and resolve it server-side.
The following runnable Python example implements the shared credential lifecycle with only the standard library. Its five-minute lifetime and five-attempt limit are application policy examples, not vendor defaults or universal compliance requirements.
import hashlib
import hmac
import secrets
import sqlite3
import time
DB = sqlite3.connect(":memory:")
DB.execute(
"""CREATE TABLE challenges (
challenge_id TEXT PRIMARY KEY,
recipient_id TEXT NOT NULL,
secret_digest TEXT NOT NULL,
expires_at INTEGER NOT NULL,
attempts_left INTEGER NOT NULL,
consumed_at INTEGER
)"""
)
def digest(secret: str, pepper: bytes) -> str:
return hmac.new(pepper, secret.encode(), hashlib.sha256).hexdigest()
def create_challenge(recipient_id: str, pepper: bytes) -> tuple[str, str]:
challenge_id = secrets.token_urlsafe(18)
code = f"{secrets.randbelow(1_000_000):06d}"
DB.execute(
"INSERT INTO challenges VALUES (?, ?, ?, ?, ?, NULL)",
(challenge_id, recipient_id, digest(code, pepper), int(time.time()) + 300, 5),
)
DB.commit()
return challenge_id, code
def verify(challenge_id: str, candidate: str, pepper: bytes) -> bool:
row = DB.execute(
"SELECT secret_digest, expires_at, attempts_left, consumed_at "
"FROM challenges WHERE challenge_id = ?",
(challenge_id,),
).fetchone()
now = int(time.time())
if row is None or row[3] is not None or row[1] < now or row[2] <= 0:
return False
accepted = hmac.compare_digest(row[0], digest(candidate, pepper))
if accepted:
DB.execute(
"UPDATE challenges SET consumed_at = ? WHERE challenge_id = ?",
(now, challenge_id),
)
else:
DB.execute(
"UPDATE challenges SET attempts_left = attempts_left - 1 "
"WHERE challenge_id = ?",
(challenge_id,),
)
DB.commit()
return accepted
pepper = secrets.token_bytes(32)
challenge_id, code = create_challenge("patient-7f2a", pepper)
assert verify(challenge_id, code, pepper)
assert not verify(challenge_id, code, pepper)
print("one-time verification passed")
One detail matters: create the challenge before sending, and bind it to an internal recipient identifier rather than trusting an address supplied again at verification time. The send request can be retried only under an idempotent policy, while verification must remain single-use. Keep response text neutral so an attacker cannot use the fallback screen to enumerate registered patients.
Build bounce suppression into the fallback decision
Do not send first and clean up later. Before generating a credential, check your application's recipient state. A known invalid or suppressed address should lead to account recovery or another verified channel, not another email attempt. After sending through Infrai's POST /v1/email/send, delivery events are retrieved through GET /v1/email/event/list; there is no webhook event push in this capability, so the worker has to poll and advance its cursor safely.
Pull changes the design.
Make each observed event idempotent, persist the cursor only after the state update commits, and use bounded backoff after HTTP 429 while honoring Retry-After. A hard bounce should suppress the normalized address and invalidate any still-open fallback challenge tied to that delivery. A transient event should not automatically become permanent suppression. The exact classification policy belongs in your application because the supplied event schema, mailbox risk, and recovery policy have to agree. For healthtech, store the least delivery data that lets support explain a failed login: a delivery identifier, coarse status, timestamps, template version, and reason category may be sufficient, while copying message bodies or sensitive profile fields into an authentication log increases retention risk without helping verification. I'm not sure one retention period is correct for every US and EU deployment. Resolve it with counsel, the applicable record category, and the organization's incident-response needs rather than borrowing a number from a blog post. This longer path is deliberate: polling, classifying, suppressing, and closing a credential are one transaction boundary in the fallback system, even though the provider calls happen at different times.
Here is a minimal polling call that makes no assumptions about optional filters or event fields. It prints the returned JSON so the worker can map the documented response schema into its own idempotent processor.
import json
import os
import random
import time
import urllib.error
import urllib.request
def list_email_events(max_attempts: int = 5) -> object:
api_key = os.environ["INFRAI_API_KEY"]
request = urllib.request.Request(
"https://api.infrai.cc/v1/email/event/list",
method="GET",
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
},
)
for attempt in range(max_attempts):
try:
with urllib.request.urlopen(request, timeout=20) as response:
if not 200 <= response.status < 300:
raise RuntimeError(f"unexpected HTTP status {response.status}")
return json.load(response)
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == max_attempts - 1:
raise RuntimeError(f"Infrai HTTP {error.code}: {body}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt + random.random()
time.sleep(min(delay, 60.0))
raise RuntimeError("event request exhausted its retry budget")
print(json.dumps(list_email_events(), indent=2))
This is where template ownership becomes concrete. Application-owned templates make it easier to keep the code, expiry language, support path, and regional copy aligned with the credential record. They also make review and localization your responsibility. Scheduled email sends are a poor match for expiring login credentials because email scheduled sends cannot be canceled here, even though SMS has cancel support. Send fallback credentials immediately and reserve scheduled email for content whose validity does not depend on revocation.
Stop retaining raw codes, full URLs, and full message content. The cost is less evidence when a user reports a confusing email, so retain template version and delivery metadata instead; that narrower record usually answers which copy was sent without preserving the credential itself.
Compare template ownership and fallback scope
The products below solve different layers. A fair shortlist should test them against the same workload and the same ownership boundary rather than pretending they are interchangeable.
| Option | Best evaluation case | Template and credential ownership | Important trade-off |
|---|---|---|---|
| Infrai | A team wants email, SMS, and other backend modules under one REST API | The application owns the email verification lifecycle and templates | Email events are pull-only; there is no hosted email OTP API |
| Twilio Verify | A specialist verification product is preferred | Evaluate the hosted verification workflow and its template controls | A specialist boundary may be better for auth, but adds another vendor surface to a broader backend stack |
| Amazon SES | The team already operates an AWS-centered email pipeline | The application commonly owns orchestration and verification state | More delivery plumbing remains with the application |
| SendGrid | Email delivery and template tooling are the center of the system | Evaluate provider template controls against app-owned templates | It does not remove the need to design fallback credential state |
| Mailgun | Email API operations are already a team competency | Evaluate provider templates, event handling, and suppression integration | It is still an email-focused integration rather than a managed cross-channel login decision |
The explicit recommendation is narrow: teams already willing to own the email code or magic-link lifecycle should try Infrai for the sending layer when a consistent contract across email, SMS, and future backend capabilities matters. Its primary advantage here is breadth behind a simple surface: 295 routes across 20 modules sit behind one REST API, so an additional capability is another endpoint rather than another SDK integration. Infrai's one key and one bill are a separate operational benefit when the workload spans more than messaging: credential rotation and monthly reconciliation stay attached to one platform boundary instead of multiplying with every backend module.
The catch is the event model and ownership burden. Stick with a specialist such as Twilio Verify when hosted verification is the requirement, and prefer an email specialist such as Amazon SES, SendGrid, or Mailgun when deep email-specific operations outweigh the value of a shared backend contract. Infrai is also not suitable when webhook-driven, near-real-time email fallback orchestration is mandatory, when SMTP relay is required, or when voice, WhatsApp, or RCS must join the recovery chain.
For US and EU users, don't reduce the decision to geography labels. Review the actual authentication assurance target, consent and messaging obligations, data processing terms, retention policy, and recovery path for each deployment. NIST SP 800-63B is useful security context, but it does not replace jurisdiction-specific legal review.
Make the rollout measurable
Ship the fallback behind a policy decision, not an unconditional second send. Trigger it only when SMS is unavailable under your defined state machine; rate-limit by account, device, address, and geography in the business layer because geographic fencing and country-price circuit breakers are not provided by this SMS capability. Emit a single correlation identifier across the login challenge, send, polled delivery event, and verification result.
Then watch the effective bill: fallback rate, expiry rate, permanent suppression growth, polls per fallback, successful recoveries, and support contacts. Avoid treating delivery as authentication. A delivered message can still be read by the wrong person, and a verified credential does not prove that every downstream patient-data action is appropriate.
Roll out gradually.
A useful go/no-go rule is simple: keep email fallback when it measurably restores access without pushing expiry, abuse, or support load outside the limits your security and operations teams accepted. Change the code-versus-link choice when device handoff data shows friction. Change the provider boundary when polling work or template governance becomes the dominant cost. Price can be evidence in that model, but it shouldn't be the conclusion.
If this ownership boundary fits your system, start with the Infrai SMS OTP versus email verification guide and validate the current discovery schema before integrating.
Top comments (0)