TL;DR: For Node.js event notifications, send transactional email first, poll its delivery status, and use SMS fallback only while the password-reset token remains useful. The deciding constraint is the expiry clock: a late fallback can create noise without helping a locked-out customer.
For a customer-support system, this is a reliability problem with a security boundary. The useful outcome is not “an API accepted a request.” It is a reset message that is observable, bounded by its expiry, and safe to explain when a support ticket arrives.
The before/after mental model is small. Before: the application sends email, waits a fixed number of seconds, then sends text. After: it records one notification attempt, advances it through explicit states, and decides from the expiry plus the latest delivery evidence. Different clocks. Different consequences.
No guesswork.
Start with a state machine, not a timer
Use a durable record keyed by a random notification ID and the reset request ID. Keep the reset secret out of the event payload and logs. A support agent needs to see queued, accepted, delivered, failed, or expired; they do not need a reusable link.
Four states carry most of the operational weight: email_pending, email_delivered, text_pending, and complete. A terminal expired state matters just as much. It prevents a delayed worker from turning an old password-reset request into a fresh-looking text alert.
Here is the policy in code. The transport functions deliberately expose generic results, because delivery semantics differ across channels and implementations.
type Channel = "email" | "text";
type DeliveryState = "pending" | "accepted" | "delivered" | "failed";
type ResetNotice = {
id: string;
resetRequestId: string;
expiresAt: Date;
email: { state: DeliveryState; receiptId?: string };
text: { state?: DeliveryState; receiptId?: string };
};
function chooseNextChannel(notice: ResetNotice, now: Date): Channel | null {
if (now >= notice.expiresAt) return null;
if (notice.email.state === "delivered") return null;
if (notice.text.state === "delivered") return null;
if (notice.email.state === "failed" && !notice.text.state) return "text";
return null;
}
The trade-off is deliberate. An accepted email is not automatically a text trigger, because acceptance is evidence that the receiving mail system took responsibility, not evidence of inbox placement or human attention. A failed email is a stronger signal. For pending messages, a deadline policy is needed: poll while there is time to act, then stop.
How should Node.js event notifications handle transactional email and SMS fallback?
Text should take over after an email failure, or after a defined observation window ends before the reset expiry. Pick the window from the token lifetime and the system's polling cadence, not from a decorative round number.
For example, with a 10-minute reset expiry and 60-second polling, reserving the final 2 minutes means the escalation decision must happen no later than minute 8. This leaves room for a text attempt and avoids sending a link that is likely to be dead by the time it is read. Those values are a policy example, not a universal security setting; the product's threat model and user behavior should set them. The same rule also prevents an unpleasant support pattern: a customer opens a text message, follows a reset link, and is told it expired because the worker had been retrying a transport state that was no longer actionable. The notification record should make that answer obvious. It should show when the email was accepted or failed, when the fallback was claimed, and how much validity remained at each transition. Support can then distinguish an expired request from a delivery failure without exposing the reset secret.
| Evidence | Application decision |
|---|---|
| Email delivered | Mark the notice complete; do not escalate. |
| Email failed before the deadline | Claim one SMS fallback attempt. |
| Email still pending at the escalation deadline | Apply the documented fallback policy once. |
| Any state after expiry | Stop delivery work and mark the notice expired. |
Do not turn “delivered” into account-recovery proof. SMS over the public switched telephone network has known weaknesses, and NIST classifies PSTN use as restricted for out-of-band authentication in the cited guidance. Here, text is a notification path. The reset flow still needs its own rate limits, single-use token handling, and account-recovery controls.
Make polling boring and inspectable
Poll only records that are pending and still eligible for escalation. Store the provider receipt ID beside the channel state, normalize each response into the small state set above, and persist the transition atomically. Webhooks can reduce latency where available, but they should feed the same state transition function; a webhook does not remove the need for an expiry check or idempotency.
async function reconcileNotice(notice: ResetNotice, now: Date) {
if (now >= notice.expiresAt) return markExpired(notice.id);
if (notice.email.state === "pending" && notice.email.receiptId) {
const state = await lookupEmailState(notice.email.receiptId);
await transitionEmail(notice.id, state);
}
const fresh = await loadNotice(notice.id);
if (chooseNextChannel(fresh, now) === "text") {
await enqueueTextOnce(fresh.id);
}
}
The subtle pitfall is a duplicate worker. Two workers can observe the same failed email and both try to enqueue text. The database transition or queue must claim the escalation with a uniqueness constraint on (resetRequestId, channel). An in-memory boolean works until a retry, deployment, or concurrent poller makes it irrelevant.
Emit structured events for every transition: notification ID, reset request ID, channel, prior state, next state, receipt ID, and the time remaining before expiry. Omit addresses, phone numbers, and reset URLs. Then create three operational views: pending notices by age, channel failures by normalized reason, and expired notices that never reached delivered. Those views turn a vague support report into a traceable path through the system.
Delivery reliability begins before a provider returns a receipt. Publish and align sender authentication for the domain that sends reset mail. DMARC gives domain owners a policy framework built on SPF and DKIM alignment, as defined in RFC 7489. It does not guarantee inbox placement, but it gives receiving systems a way to evaluate domain alignment and gives operators aggregate feedback to investigate.
Keep reset mail transactional and unambiguous: a short explanation, the expiry time, and a route for someone who did not request it. Avoid placing the reset secret in logs, analytics query strings, or support exports. A text message should be similarly minimal and should not become a second long-lived credential.
“Why not send both channels immediately?” Because simultaneous sends hide useful failure evidence, increase confusion for customers, and can produce two urgent-looking messages for one request. Parallel delivery may be justified for a narrowly defined high-risk workflow, but it should be an explicit policy with a measured reason, not the default behavior.
“Why poll if a transport has callbacks?” Because the application still owns the business decision. A callback may arrive late, arrive more than once, or be temporarily unavailable to the application. Reconciliation gives the system a bounded way to recover state, provided it stops at expiry and makes every transition idempotent.
That boundary is the whole design.
Build the test matrix around the states, not the transport library: accepted then delivered, failed then text delivered, no update before expiry, duplicate reconciliation, and a late callback after expiry. That is the evidence a support team needs when delivery reliability is the real requirement.
Top comments (0)