DEV Community

IgnazCole6453
IgnazCole6453

Posted on

Password-Reset SMS Alert API for US/EU SaaS: Node.js Status and Template Ownership

Short answer: for a US/EU SaaS password-reset SMS alert, choose an API whose Node.js or plain-HTTP integration exposes a stable message ID, delivery status, and server-side cancellation; keep the template in your application repository unless compliance requires a managed catalog.

I build RAG and agent features in Python, so I treat messaging as an evaluated pipeline rather than a single HTTP call. The useful question is not which provider has the flashiest SDK. It is who owns the text, who can prove the expiry policy, and what your system does after a carrier response arrives late.

What should a SaaS team verify in an SMS alert API for US and EU delivery?

Start with the contract. A send request should return an opaque ID immediately, before a carrier has accepted anything. A status read should be repeatable, and a cancel request should have a defined result for messages that are queued, accepted, or already delivered. These details matter more than a language-specific helper because a Python worker, a Node.js service, and a test harness can all speak the same HTTPS contract.

For a password reset, the message is an authenticator hint, not the authenticator itself. NIST's digital identity guidance says a verifier must enforce replay resistance and an appropriate lifetime for authentication secrets. The SMS API cannot enforce that policy for you. Your reset service must bind a random, single-use token to a user, store only a digest, and reject it after a short deadline.

Here is the decision table I use during design review:

Concern Minimum contract Why it changes the design
Template ownership Versioned text under application change control Security wording and localization can be reviewed with code
Delivery state Queued, sent, delivered, failed, expired, canceled (or documented equivalents) Operators can separate carrier delay from an invalid number
Polling GET by message ID, with retry guidance A worker can reconcile missed webhooks
Cancellation Idempotent cancel for queued work A reset request revoked by the user stops future delivery
Regional routing US and EU sender, consent, and retention rules documented Legal and deliverability policies differ by destination

Do not treat “delivered” as “the user authenticated.” Delivery is a transport observation. The reset endpoint still needs its own atomic consume operation.

How do expiry, delivery status polling, and scheduled cancellation fit together?

The data flow is small: the reset endpoint creates a token and an outbox record, a worker submits the rendered SMS, and a reconciler polls status until the message reaches a terminal state. A cancellation event can mark the outbox record revoked and ask the messaging API to cancel any still-queued send. The token deadline remains authoritative even if the text arrives after that deadline.

I keep two clocks. expires_at belongs to the authentication token; send_after belongs to the message schedule. They are intentionally independent. If a user requests a new reset, the old token is revoked immediately, while an already delivered old SMS remains harmless.

The following example uses a generic HTTP adapter. It is deliberately boring: the same shape can be implemented with httpx, requests, or a Node.js client without changing the state machine.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import secrets
from typing import Protocol


class SmsTransport(Protocol):
    def schedule(self, *, to: str, body: str, send_after: datetime) -> str: ...
    def status(self, message_id: str) -> str: ...
    def cancel(self, message_id: str) -> str: ...


@dataclass
class ResetRequest:
    user_id: str
    phone: str
    token_digest: str
    expires_at: datetime
    message_id: str | None = None
    revoked: bool = False


def issue_reset(user_id: str, phone: str, sms: SmsTransport) -> ResetRequest:
    raw_token = secrets.token_urlsafe(32)
    digest = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
    now = datetime.now(timezone.utc)
    reset = ResetRequest(
        user_id=user_id,
        phone=phone,
        token_digest=digest,
        expires_at=now + timedelta(minutes=10),
    )
    body = f"Your password reset code is {raw_token}. It expires in 10 minutes."
    reset.message_id = sms.schedule(
        to=phone,
        body=body,
        send_after=now,
    )
    return reset


def revoke_reset(reset: ResetRequest, sms: SmsTransport) -> None:
    reset.revoked = True
    if reset.message_id:
        result = sms.cancel(reset.message_id)
        if result not in {"canceled", "delivered", "expired"}:
            raise RuntimeError(f"unexpected cancel state: {result}")
Enter fullscreen mode Exit fullscreen mode

The raw token is shown only in the outgoing body and is never stored. In production, persist the reset row and outbox row in one transaction, then let a worker perform schedule; otherwise a database rollback can leave a message with no auditable owner. My eval harness includes a test where status returns delivered after the token has expired. The correct result is still a rejected reset.

Which failure modes deserve tests before production?

Polling is not a substitute for idempotency. A timeout after submission leaves an ambiguous outcome, so the outbox record needs a deterministic idempotency key and a retry budget. If the API offers no idempotency mechanism, generate one at your boundary and reconcile by your own message ID before retrying.

Late events are normal. Webhooks can be duplicated, reordered, or delayed; polling can observe an older state. Store transitions with timestamps and accept only legal moves toward a terminal state. Never move delivered back to queued because a stale poll said so.

Cancellation also has a race. A cancel request can arrive just after carrier acceptance. Your UI should say “revoked” for the token and show the transport state separately. That wording avoids promising that a handset message can be recalled.

Regional behavior needs its own fixtures. US and EU numbers differ in consent records, sender registration, local quiet hours, and retention expectations. Keep country-specific policy in configuration and test it with synthetic numbers; do not infer compliance from a successful API response. Your mileage may vary where carriers apply local filtering, and I am not sure any single status vocabulary will map perfectly across every network, so preserve the provider's raw code alongside your normalized state.

Cost enters later. Count retries, status reads, and scheduled cancellations in the same budget as sends, but do not make a low per-message figure the selection criterion. A status API that lets you reconcile cleanly can reduce operational toil even when its call model looks different.

Who should own the template: the application or a messaging console?

For password resets, I default to application ownership. Store locale, version, variable schema, and approval metadata next to the code that validates the token. A reviewer can then see that “10 minutes” in the copy matches the actual TTL, and an eval can render every locale with a fixed fixture.

Managed templates are reasonable when a compliance team must approve edits without a deploy, or when many products share a centrally audited catalog. The catch is coordination: a console edit can change security wording while your reset service still assumes the old variable names. Require immutable template IDs, a publish event, and a compatibility test before accepting that model.

This is the boundary I document for the team:

  • Choose application-owned templates when reset semantics, localization, and review live with the service.
  • Choose managed templates when independent approval and audit trails outweigh repository-level control.
  • In either case, reject unknown variables, record the rendered version, and keep the token TTL in server-side policy.

The least complex option wins when the ownership rule is explicit. It also makes notebook-to-prod work less surprising: the fixture used in an experiment is the same fixture the worker renders in staging.

A practical launch checklist

Before launch, replay a complete reset in a staging project for both US and EU test numbers. Capture the request ID, message ID, normalized status, raw carrier code, template version, and token outcome in one trace. Exercise duplicate submits, worker restarts, a status timeout, a late delivery, and a cancel-after-accept race. Assert that the token can be consumed once, that an expired token fails, and that a revoked token fails even if the SMS says delivered.

Then set alerts on terminal failures and on messages stuck in a non-terminal state longer than their useful lifetime. Keep a runbook sentence for each alert: who owns the template, which clock is authoritative, and how to find the corresponding outbox row. That is the difference between an SMS demo and an operable authentication path.

References

Top comments (0)