DEV Community

XaviorCross6845
XaviorCross6845

Posted on

Node.js Backend Polling for SMS Login Notifications (With Game Report Email)

A login code and a generated game report have the same awkward constraint: the request that creates the message finishes before the delivery story does. Treating provider acceptance as delivery makes failed OTP sends invisible and makes report-email support tickets hard to answer.

Short answer: let Node.js Express create an immutable message attempt, return without waiting for delivery, and let a separate reconciler combine signed delivery notifications with bounded status polling. Keep login authorization tied to OTP verification, never to a carrier status, and give email reports a separate policy even if both channels share the same outbox.

That split is the simplest backend flow I would choose when integration effort matters. It has one internal contract, one state model, and two deliberately different business outcomes. It's also small enough to test without pretending that SMS and email mean the same thing.

Start with the constraint, not the transport

The API call that asks an SMS service to send a code answers a narrow question: was the request accepted for processing? It cannot prove that a handset received the message or that a person controls the handset. Delivery evidence can arrive later, arrive twice, or remain inconclusive. So the login endpoint should issue a short-lived challenge and record a send attempt, while the verification endpoint alone decides whether authentication succeeds.

For a gaming backend, the adjacent job is sending a generated match report as an email attachment. Reusing the outbox, correlation ID, retry scheduler, and audit vocabulary reduces integration work. Reusing the OTP policy does not. A delayed report can be retried later; a delayed OTP may already be useless. A report attachment also belongs to an authenticated recipient and needs its own retention and access rules. Shared plumbing is sensible. Shared meaning isn't.

The core record should contain an internal attempt ID, channel, purpose, recipient reference, provider message reference, creation time, expiry time, and current evidence state. Store a protected recipient reference rather than scattering a raw phone number or email address through logs. Keep the generated report's storage reference separate from its email attempt so a retry does not regenerate or duplicate the report.

This boundary matters for compliance as much as debugging. OWASP recommends consistent responses for account-recovery requests, side-channel delivery, rate limiting, expiring single-use codes, and invalidating a code after use. Those controls imply that a public login response must not reveal whether a phone number exists, even when the internal send attempt fails. The detailed outcome belongs in restricted operational telemetry.

Be strict here.

How should a Node.js Express backend poll SMS 2FA delivery status?

Use notifications as the normal evidence path and polling as a bounded reconciliation path. Express receives the login request, creates the OTP challenge and outbox item in one durable operation, and queues the send. A worker submits it, saves the external message reference, and schedules a reconciliation check. The public request can then return the same neutral response used for every account lookup.

A notification handler should authenticate the sender using the mechanism documented by the selected transport, parse into an internal event, deduplicate it, and apply a monotonic state transition. A successful response from that handler means the event was durably recorded, not merely parsed in memory. Do not put login decisions in this handler. Delivery reports are operational evidence; the submitted OTP is the authentication factor.

Delivery is evidence, not authentication.

Polling starts only for attempts that still lack terminal evidence after a configurable delay. It stops when the attempt becomes terminal, the OTP expires, or the reconciliation budget is exhausted. The exact delay and budget depend on the transport's documented behavior and the latency your users observe; I'm not sure a universal interval exists, and a staging trace plus production percentiles would resolve that for a particular integration. Unbounded polling is the wrong default because it turns an uncertain message into permanent background traffic.

The reconciler can remain language-neutral even if Express owns the edge. This Python reference shows the important part: normalize external observations before they touch product logic.

from dataclasses import dataclass
from enum import Enum


class Evidence(str, Enum):
    QUEUED = "queued"
    ACCEPTED = "accepted"
    DELIVERED = "delivered"
    FAILED = "failed"
    EXPIRED = "expired"


TERMINAL = {Evidence.DELIVERED, Evidence.FAILED, Evidence.EXPIRED}
RANK = {
    Evidence.QUEUED: 0,
    Evidence.ACCEPTED: 1,
    Evidence.DELIVERED: 2,
    Evidence.FAILED: 2,
    Evidence.EXPIRED: 2,
}


@dataclass(frozen=True)
class Attempt:
    attempt_id: str
    evidence: Evidence


def merge_observation(attempt: Attempt, observed: Evidence) -> Attempt:
    if attempt.evidence in TERMINAL:
        return attempt
    if RANK[observed] < RANK[attempt.evidence]:
        return attempt
    return Attempt(attempt_id=attempt.attempt_id, evidence=observed)
Enter fullscreen mode Exit fullscreen mode

The deliberately boring rule is useful: repeated events do nothing, an older accepted observation cannot move an attempt backward, and the first terminal observation closes reconciliation. If a transport documents that terminal reports can be corrected later, preserve every raw event and introduce an explicit correction policy rather than quietly overwriting history.

Failed sends need product decisions, not automatic optimism

Classify failure before deciding to retry. A transient submission limit, a terminal destination rejection, an expired challenge, and a malformed internal request should not share one retry counter. The adapter can map documented transport outcomes into a small internal set such as retryable, terminal, unknown, and delivered, while keeping the raw external value beside the normalized value for audit and adapter debugging. For retryable submission outcomes, use delayed retries with a cap and idempotency at the attempt boundary. For a terminal outcome, stop sending that challenge. For unknown delivery evidence, allow the challenge to expire normally and offer the user a controlled resend path. Never mark the login successful because a status says delivered, and don't keep resending the same code after its useful window. A fresh resend should create a fresh attempt while the authentication policy determines whether the prior code remains valid. Notifications and polls can also race: both writers must use the same compare-and-set transition or serialized event consumer, and deduplication must be based on a stable event identity or a canonical observation fingerprint. Otherwise a late polling result can erase a delivery notification just as an operator opens the trace. The outbox-to-send boundary needs the same care; claiming work and recording the external reference must be recoverable without producing uncontrolled duplicate messages. Observe the flow by purpose and normalized state, not by recipient. Useful signals include time from queueing to submission, time to terminal evidence, attempts that expire without evidence, retry count, notification authentication failures, and poll volume. Keep message content, OTP values, phone numbers, email addresses, and report attachments out of ordinary logs. An operator should be able to follow one correlation ID from login request to challenge verification without seeing the secret itself.

Unknown must stay unknown.

The catch is that this design is not suitable when the chosen transport offers no queryable status and no authenticated notification mechanism. In that case, record only what can be proven, label the final state unknown after acceptance, and measure successful OTP verification as a separate product outcome. Do not manufacture a delivered state. If the system has tiny volume and no asynchronous worker infrastructure, a managed queue or transport-specific callback consumer may require less integration work than operating a general event pipeline.

Compare integration effort at the adapter boundary

A useful comparison does not start with a feature grid. Build a thin spike for each candidate transport against the same adapter contract, then count the application-specific branches it forces above that boundary. The winning integration is the one that preserves your state model with the least special handling, while meeting security, delivery-evidence, regional, and compliance requirements.

Decision point Lower-effort evidence Warning sign
Submission Stable idempotency and an external message reference Success response cannot be correlated later
Notifications Documented authentication, stable event identity, replay handling Handler must infer identity from mutable fields
Status lookup Documented mapping and clear terminal outcomes Adapter leaks many transport states into login code
Data handling Configurable retention and suitably scoped credentials OTP content or recipient data spreads into logs
Email reports Attachment support and traceable message identity A retry regenerates the report or changes its identity
Operations Test environment plus observable limits Delivery behavior can be learned only in production

No single row decides the choice. For example, a polished notification path does not compensate for a region the service cannot reach, and broad channel coverage does not help if credential scope is too wide for the team's threat model. Your mileage may vary because existing queueing, secret management, and on-call tooling change the real integration cost.

Email adds a different filter. Yahoo's sender guidance emphasizes authenticated mail, aligned sending identity, valid message formatting, secure transport, and active complaint management. Those are delivery prerequisites around the report workflow, not reasons to merge report success with OTP success. For larger or sensitive reports, a short-lived authenticated download may be more appropriate than an attachment, but that choice depends on the report's sensitivity, recipient workflow, and the applicable mail limits.

Roll out the shared message contract in narrow steps

Begin in shadow mode: create attempt records and ingest delivery evidence without changing login behavior. Compare notification and polling observations, inspect unknown mappings, and confirm that duplicate events leave the final state unchanged. Then enable bounded polling for unresolved OTP attempts, followed by operational alerts and the user-facing resend rule.

Move report email last. Reuse the attempt envelope and observability fields, but give reports a separate expiry, retry policy, content pipeline, and terminal business action. Roll back by disabling the new consumer while preserving recorded events for replay; do not make rollback depend on deleting history.

The final acceptance test is compact: one login request cannot enumerate accounts, one OTP cannot be used twice, duplicate delivery evidence is harmless, polling stops, and retrying a report does not regenerate its attachment. If those properties hold, the backend has a simple flow because its boundaries are simple — not because asynchronous delivery was wished away.

References

Top comments (0)