DEV Community

Thalion51
Thalion51

Posted on

Signup Template Ownership: Transactional Email, SMS Fallback, and API Polling

Short answer: own the signup-verification template and notification state machine in your application, send email first, poll only while its status is unresolved, and permit SMS fallback once a terminal failure or an explicit deadline makes email an unacceptable bet.

Start with the bill, because the obvious implementation can quietly charge for three different things: initial email sends, SMS fallbacks, and delivery-status polls. For a hypothetical 100,000-signup month, an 8% fallback rate and three email status checks per signup produce 100,000 email operations, 8,000 SMS operations, and 300,000 polling operations. Those are workload assumptions, not a benchmark. Insert contracted unit prices into 100000E + 8000S + 300000P; the largest term is the one worth changing. If polling is metered and status latency allows it, exponential backoff moves that term without weakening the delivery policy. If SMS dominates, reducing false fallbacks matters more than shaving a poll.

The least complex design that still gives the team control is an application-owned template plus a durable notification record. The provider transports a rendered message; it doesn't decide what the verification copy means, when the second channel is justified, or which event closes the workflow. That boundary matters in an edtech signup flow, where an address typo, a delayed email, and an expired verification token are three different failures even though the user reports all three as “the link never arrived.”

Template releases behave like data migrations

A template edit can outlive the code release that introduced it. An outbox event written before deployment may be rendered after deployment; a retry may run on an older worker; and an SMS fallback may occur after the email template has advanced. Treating copy as an unversioned string makes those ordinary timings ambiguous. Treat it like stored data instead: assign an immutable version, declare the variables schema accepted by that version, and keep the renderer able to process every version still present in the live queue.

The deployment order follows from that constraint. First ship a renderer that accepts both the old and new event shapes. Then begin producing events pinned to the new template version. Retire the old renderer only after no queued or retryable record references it. A rollback reverses event production but does not rewrite notification history. This method is slower than editing a live template in place — deliberately so — because it gives a reviewer a stable artifact and keeps an old signup event from acquiring copy that did not exist when the event was created.

Template ownership sets the rendering boundary

Template ownership means the application repository contains the subject, body, SMS variant, variables, and a version identifier. Rendering happens before dispatch. A deployment therefore changes copy under the same review and test process as the event contract, and each notification record can say exactly which version generated it. Provider-hosted templates can reduce deployment friction for non-engineering editors, but they split the audit trail: code defines the event while a remote console defines what the student or instructor sees.

This is a real trade-off, not a universal rule. Application ownership is a poor fit when a communications team must localize or approve copy many times per day without a software release; in that case, keep templates in a dedicated content system and have the application pin a published version. Stick with provider-managed templates when their workflow is the actual source of truth, but export versioned snapshots or hashes into the notification record. Don't let “editable in a dashboard” turn into “nobody can reconstruct Tuesday's message.”

The rendering contract should be deliberately small. A signup event might contain notification_id, recipient_id, verification_url, locale, and expires_at. Avoid passing an entire user object. Validate required variables before the outbox transaction commits — a transport retry cannot repair a missing link — and render email and SMS from the same semantic input so the fallback doesn't promise a different expiration time.

Ownership model Strongest fit Operational cost Failure mode to name
Application repository Copy changes with product behavior Requires deployment for edits Old workers render a newer event with an older template
Dedicated content service Frequent localization and approvals Adds a runtime or publishing dependency An unpublished draft is referenced by an event
Provider-hosted template One transport owns the full workflow Audit data spans two systems Remote edits drift from application tests

Whichever model wins, store an immutable template version with every attempt. “Welcome email” isn't evidence; signup_verify_email:v12 is.

How should Node.js event notifications poll email delivery before SMS fallback?

Treat polling as a state-machine input, not as a loop attached to the signup request. The request creates the account, creates a single-use verification challenge, and writes an outbox event in the same durable boundary available to the application. A worker renders the pinned template and sends the email. Another scheduled worker claims notifications whose next_check_at is due, asks the transport for the provider message status, normalizes that response, and commits the next state with a compare-and-set version. Node.js is well suited to the orchestration because the work is I/O-bound, but the correctness comes from durable state and idempotency, not from a timer living inside one process.

A compact reference state machine is easier to inspect in Python than a framework-specific client. The transition rules below are the important part; a Node.js worker should preserve the same inputs and outputs around its chosen queue and API client.

from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
from enum import Enum


class State(str, Enum):
    EMAIL_PENDING = "email_pending"
    EMAIL_DELIVERED = "email_delivered"
    SMS_PENDING = "sms_pending"
    SMS_ACCEPTED = "sms_accepted"
    EXHAUSTED = "exhausted"


@dataclass(frozen=True)
class Notification:
    notification_id: str
    state: State
    email_attempts: int
    sms_attempts: int
    deadline_at: datetime
    next_check_at: datetime | None
    version: int


def apply_email_status(
    item: Notification,
    provider_status: str,
    now: datetime,
) -> Notification:
    if item.state is not State.EMAIL_PENDING:
        return item

    if provider_status == "delivered":
        return replace(
            item,
            state=State.EMAIL_DELIVERED,
            next_check_at=None,
            version=item.version + 1,
        )

    terminal = provider_status in {"rejected", "undeliverable"}
    if terminal or now >= item.deadline_at:
        return replace(
            item,
            state=State.SMS_PENDING,
            next_check_at=None,
            version=item.version + 1,
        )

    delay = min(60, 5 * (2 ** max(0, item.email_attempts - 1)))
    return replace(
        item,
        next_check_at=now + timedelta(seconds=delay),
        version=item.version + 1,
    )


example = Notification(
    notification_id="signup_01J_POLICY_EXAMPLE",
    state=State.EMAIL_PENDING,
    email_attempts=2,
    sms_attempts=0,
    deadline_at=datetime.now(timezone.utc) + timedelta(minutes=2),
    next_check_at=datetime.now(timezone.utc),
    version=4,
)
Enter fullscreen mode Exit fullscreen mode

The numbers are an example policy, not recommended constants. I'm not sure a universal two-minute deadline is defensible: the right value comes from the product's verification-time objective, observed provider-status latency, and the harm of sending both channels. The same caution applies to the backoff cap.

Measure first.

One detail prevents a surprising amount of duplicate traffic: claim work using both notification_id and version, then accept a transition only if the stored version still matches. A second worker may poll the same provider message, but it cannot move an already delivered notification into sms_pending. Use the notification ID as the stable idempotency key for the logical message and a separate attempt ID for each transport call. Short names, strict meaning.

Delivery status is evidence, not verification

“Accepted,” “delivered,” and “verified” belong to different domains. An accepted email says the transport took responsibility under its own status vocabulary. A delivered status is stronger transport evidence, but it doesn't prove that the intended person read the message. Verification occurs only when the application atomically consumes the single-use challenge before its expiration. Keep those states separate in the data model and in dashboards, or a cheerful delivery graph will conceal a broken signup path.

DMARC is also easy to misplace in this design. RFC 7489 describes domain-based message authentication, reporting, and conformance using identifiers associated with SPF and DKIM. It helps receiving systems evaluate domain alignment and published policy; it is not a per-recipient delivery receipt. Configure and monitor authentication as part of email operations, but don't translate a passing authentication posture into EMAIL_DELIVERED.

SMS fallback has a security boundary of its own. NIST SP 800-63B treats use of the public switched telephone network for out-of-band authentication as restricted and calls for considering risks such as number reassignment and other abnormal behavior. A basic account-verification link is not automatically an authenticator under every assurance model, yet the warning should still shape the threat review: fallback changes the channel and its risks; it doesn't merely increase reach. For higher-assurance enrollment, the team should map the complete flow to its identity requirements rather than infer assurance from possession of a phone number.

The catch is that fallback can create two valid links in two inboxes unless token semantics are channel-independent. Generate one challenge, render that same challenge into both channel-specific templates, and make the first successful consumption invalidate further use. Never log the raw link. A digest or opaque challenge identifier is enough to correlate events while limiting what an operator, export, or support attachment can expose.

Which failures deserve another poll, another send, or a stop?

Name failures by the decision they permit. An unresolved provider status permits another poll after backoff. A terminal email rejection permits SMS fallback if the account supplied and authorized that channel. A transport acceptance does not permit immediate fallback, because racing both channels inflates cost and can confuse the recipient. A consumed or expired challenge stops every notification attempt. A malformed event stops before any external call and goes to an operator-visible dead-letter path with the template version and validation reason.

There is no honest global retry count. Set finite budgets per stage, record the reason for every retry, and add jitter so a delayed provider response doesn't align a fleet of workers on the same second. Retries must reuse the logical idempotency key; manual replay should create an auditable command, not mutate history. This is where a long paragraph is justified, because “retry three times” hides several independent clocks: the API request timeout, the status-visibility delay, the signup challenge expiration, the product's acceptable wait before fallback, and the retention period for operator investigation. Conflating them produces a workflow that can be locally reasonable and globally impossible — for example, an SMS queued after the challenge has already expired. Test each clock independently, then test their ordering with a fake clock and scripted provider states.

Keep the test matrix small enough to maintain but sharp enough to catch ownership errors:

  • a template version missing verification_url fails before dispatch;
  • two workers polling one notification yield one committed transition;
  • email delivery before the deadline suppresses SMS;
  • terminal email failure queues one SMS attempt;
  • challenge consumption cancels pending transport work;
  • an expired challenge cannot be revived by a delayed status update.

Observe transitions, not just API calls. Useful counters include notifications by normalized state, fallback decisions by reason, poll attempts per logical notification, template validation failures by version, and time from outbox creation to challenge consumption. Alert on changes in ratios and age distributions after establishing a baseline; a single fixed threshold copied from another system has no evidentiary basis here.

What should the audit retain, and what should it discard?

Retain the minimum record needed to explain the decision: logical notification ID, recipient reference, channel, template name and immutable version, provider message reference, normalized status, attempt timestamps, decision reason, challenge state, and worker transition version. Set the retention period from support, security, legal, and education-record obligations that actually apply to the deployment. Your mileage may vary because those obligations depend on jurisdiction, institution, and data classification; counsel and the data owner resolve that uncertainty, not an architecture diagram.

Do not keep rendered bodies, raw verification URLs, or phone and email values in general-purpose event logs merely because storage is available. Dropping them reduces exposure and usually shrinks the dominant retention term: payload bytes multiplied by replicas, indexes, backups, and retention duration. The cost is real. During an investigation, operators can prove which template version and variables schema were used, but they cannot reproduce the exact personalized body from the audit record alone. If exact-content evidence is mandatory, isolate encrypted snapshots behind narrower access and a separately justified retention schedule rather than turning every operational log into a message archive.

This design is not suitable when the product requires instantaneous dual-channel delivery, when SMS consent is absent, or when the selected assurance policy disallows the fallback channel. In those cases, either send only the approved channel or redesign enrollment around a stronger verification method. The conclusion stays deliberately narrow: own the semantic template boundary, persist transitions, poll unresolved email with a budget, and allow fallback only from an explicit state.

References

Further reading

Top comments (0)