Short answer: for a fintech SaaS that releases generated reports after login, poll SMS delivery for a short, measured window, then replace the SMS challenge with a new email OTP only after a terminal failure or deadline; never leave both codes valid. No webhook is required, but a durable challenge record, idempotent sends, rate limits, and explicit unknown handling are.
The least complex reliable design is one login challenge with two possible delivery attempts. The application, not either messaging provider, owns the state transition. A user requests access, the service creates a random one-time code, and an SMS adapter submits it. A worker polls delivery status while the browser polls only the application's challenge endpoint. If SMS is confirmed delivered, the system keeps that challenge. If delivery fails or remains unknown past the policy deadline, the service invalidates the first code, rotates the challenge version, and sends a different code by email. Only a successful verification can release the report workflow.
Keep those jobs separate.
This distinction matters for a generated financial report. The OTP email is an authentication message, not the report delivery message, and it should not carry the report as an attachment. Authentication finishes first; the report attachment is sent in a separate, auditable step after access is proven. Mixing the two turns a delivery fallback into a data-disclosure path.
How should a Node.js SaaS poll SMS before an email OTP fallback?
Treat provider status as an input, not as your source of truth. A submitted or accepted message means the provider has taken responsibility for an attempt; it does not prove that the handset received it. Normalize every provider response into four application states: pending, delivered, failed, and unknown. The polling worker then has a small decision table.
| Observed state | Application action | Why |
|---|---|---|
pending, before deadline |
Poll again with jitter | Avoid duplicate codes while delivery may still complete |
delivered |
Stop polling; retain SMS challenge | There is no reason to fan out another secret |
failed |
Invalidate SMS code; create email challenge | Failure is terminal for this attempt |
unknown, at deadline |
Apply the fallback policy once | Silence must become an explicit decision |
The deadline should come from measured delivery distributions by destination and carrier, not a universal blog-post number. A 20-second cutoff is useful in the example below because it makes the mechanics concrete, but it is not an industry constant. I'm not sure a single global cutoff is defensible for both US and EU traffic without production percentiles split by route. Your mileage may vary.
Keep retries bounded. The browser should not call a messaging provider, and it should not decide to send email. It asks your backend for the current challenge phase; a durable worker owns provider polling and the one-way transition to fallback. That prevents a refreshed tab, two open devices, or a delayed client request from issuing extra secrets.
Implement the challenge as a versioned state machine
The useful abstraction is small: adapters send codes and report delivery state, while the coordinator performs compare-and-set updates in durable storage. The storage boundary must atomically verify the expected version. In production, back it with a transaction or conditional write.
type DeliveryState = "pending" | "delivered" | "failed" | "unknown";
type Phase = "sending_sms" | "awaiting_sms" | "awaiting_email" | "verified" | "expired";
interface Challenge {
id: string;
userId: string;
version: number;
phase: Phase;
codeHash: string;
providerMessageId?: string;
smsDeadlineMs: number;
expiresAtMs: number;
}
interface DeliveryAdapter {
sendOtp(input: {
challengeId: string;
destination: string;
code: string;
}): Promise<{ messageId: string }>;
getStatus(messageId: string): Promise<DeliveryState>;
}
interface ChallengeStore {
get(id: string): Promise<Challenge | null>;
replaceWithEmail(input: {
id: string;
expectedVersion: number;
newCodeHash: string;
expiresAtMs: number;
}): Promise<boolean>;
attachEmailMessage(input: {
id: string;
expectedVersion: number;
messageId: string;
}): Promise<boolean>;
}
The important property is expectedVersion, not the names of the interfaces. Suppose two workers wake after the deadline. Both read version 7, but only one can replace it with version 8. The losing worker exits before sending. This makes the fallback idempotent even when a queue redelivers work.
Here is the polling transition. Cryptographic code generation and hashing are dependencies on purpose: platform primitives differ, and burying those choices inside messaging logic makes review harder.
const EMAIL_TTL_MS = 10 * 60_000;
interface OtpSecrets {
generate(): { plain: string; hash: string };
}
async function pollAndMaybeFallback(input: {
challengeId: string;
email: string;
nowMs: number;
store: ChallengeStore;
sms: DeliveryAdapter;
emailAdapter: DeliveryAdapter;
secrets: OtpSecrets;
}): Promise<"wait" | "sms_delivered" | "email_sent" | "done"> {
const challenge = await input.store.get(input.challengeId);
if (!challenge || challenge.phase !== "awaiting_sms") return "done";
if (!challenge.providerMessageId) return "wait";
const status = await input.sms.getStatus(challenge.providerMessageId);
if (status === "delivered") return "sms_delivered";
const deadlineReached = input.nowMs >= challenge.smsDeadlineMs;
if (status === "pending" && !deadlineReached) return "wait";
if (status === "unknown" && !deadlineReached) return "wait";
const next = input.secrets.generate();
const replaced = await input.store.replaceWithEmail({
id: challenge.id,
expectedVersion: challenge.version,
newCodeHash: next.hash,
expiresAtMs: input.nowMs + EMAIL_TTL_MS,
});
if (!replaced) return "done";
const sent = await input.emailAdapter.sendOtp({
challengeId: challenge.id,
destination: input.email,
code: next.plain,
});
await input.store.attachEmailMessage({
id: challenge.id,
expectedVersion: challenge.version + 1,
messageId: sent.messageId,
});
return "email_sent";
}
Schedule pollAndMaybeFallback with delayed jobs and randomized spacing. Don't run a tight loop inside a request handler: it consumes a process slot, couples login latency to provider latency, and disappears when the instance restarts. The exact schedule is an operational choice. What matters is that every run can be repeated and that the authoritative state survives deployment.
There is one subtle boundary in this sample. Moving state to awaiting_email before the email call prevents a second worker from sending another code, but the delivery call and database update are not one transaction. Use an outbox record keyed by challenge version, and make the adapter's send operation idempotent on that key when the underlying service supports it. If it does not, the outbox still gives operators a precise record to reconcile without creating a second valid challenge. Consider the deadline race: worker A reads version 7 while an SMS status refresh is still pending; worker B reads the same version and tries the fallback; then a delayed delivered observation arrives. The database transition, not arrival order in either process, must decide which state wins. Tests should pause each operation at that boundary and resume them in both orders. This is where a design that looked tidy on a whiteboard either proves its invariant or sends two usable codes.
Failure policy matters more than polling frequency
A polished timer cannot rescue a weak verification policy. OWASP's guidance for reset tokens maps cleanly to login OTPs: generate codes with a cryptographically secure method, make them long enough to resist guessing, store them securely, expire them, make them single-use, and rate-limit attempts. Responses should also avoid revealing whether an account exists.
Use two counters because they defend different resources. A send limit per account and destination controls message abuse; a verification-attempt limit per challenge controls guessing. Add broader controls for IP or device signals carefully, since shared networks can make blunt limits punish legitimate users. Never log plaintext codes. Redact phone numbers and email addresses in application logs, and keep message bodies out of traces.
Race conditions deserve their own tests. Exercise an SMS delivered update arriving just as the deadline worker runs, two workers claiming the same fallback, an old SMS code submitted after email replacement, an email code replayed after success, and expiry during verification. The invariant is crisp: at any instant, at most one challenge version can succeed.
Stop here if that invariant cannot be enforced.
Polling also needs a budget. A two-second cadence across 10,000 pending logins is 5,000 status reads per second before jitter, so concurrency and provider quotas must be part of the design. This arithmetic is illustrative, not a claimed workload benchmark. Adaptive intervals can poll quickly near submission and back off later, but reliability comes from durable scheduling and explicit terminal states, not maximum frequency.
Separate regional delivery evidence from security decisions
US and EU traffic should have separate dashboards even if they share code. Group outcomes by destination country, carrier or route when available, channel, and challenge policy version. Track submission-to-delivery latency, the share reaching delivered, terminal failures, unknowns at deadline, fallback issuance, verification success, and duplicate-send suppression. These are system measurements; they do not require storing an OTP or full destination.
Do not interpret fallback success as proof that email is universally better. Email can arrive in a different inbox session, be filtered, or become inaccessible at the same time as the account. SMS depends on cellular routing and possession of a phone number. Both channels can be socially engineered. For higher-risk actions, phishing-resistant authenticators or recovery codes belong in the broader account-security design; an email OTP fallback may be unsuitable when the mailbox is also the recovery root.
The compliance boundary is separate again. The FTC explains that CAN-SPAM distinguishes transactional or relationship content from commercial content and that misleading routing information is prohibited. Keep an OTP message narrowly transactional: no promotion, no generated report attachment, and no marketing copy smuggled into the footer. For EU recipients, have counsel and the privacy owner validate retention, processor, and transfer choices against the actual deployment; this article cannot resolve those facts from a region label.
Operate the report flow as two linked audits
Once verification succeeds, emit a separate authenticated event to generate and send the financial report attachment. Link the report-delivery audit to the user and successful challenge ID, but do not reuse the OTP message ID as proof that the report was authorized. One record proves access; the other records document generation and delivery.
Before release, run the state machine against a fake clock and deterministic adapter. Confirm that a terminal SMS failure rotates the code, a late SMS code cannot verify, and a repeated worker invocation produces one email outbox item. In staging, test representative US and EU destinations with non-sensitive fixtures. In production, alert on shifts in unknown-at-deadline rate and on outbox age, then review the cutoff using observed percentiles rather than intuition.
The catch is operational weight. Polling adds scheduled work and status-read volume. If a provider can deliver authenticated, replay-protected status events into infrastructure you already operate well, a webhook can reduce that volume. Stick with polling when inbound callbacks are prohibited or impractical and the provider exposes trustworthy delivery status; choose a different authentication factor when neither channel can meet the risk level. For a small team, the right design is the one whose failure states can be tested, observed, and explained without opening a provider dashboard during every login incident.
Top comments (0)