DEV Community

GriffinHayes3461
GriffinHayes3461

Posted on

Node.js Seller Notifications: Operating US/EU SMS Delivery Polling Without Webhooks

Short answer: for a Node.js marketplace sending basic transactional SMS alerts in the US and EU, choose a provider whose send and status APIs are easy to isolate behind a queue worker; a polling-only API is a sound fit when integration effort is the deciding constraint, but it is the wrong choice when a webhook must trigger the next business action immediately.

The concrete job is deliberately small: notify seller sel_2048 that order ord_78421 is ready to fulfill. Checkout should not wait for the carrier, and a successful send request should never be recorded as proof of delivery. The marketplace owns the order, so it should also own the alert state machine.

That boundary matters.

Integration boundary at the order outbox

Put an SMS adapter behind the marketplace's durable notification queue. Commit an outbox item with the new order, let a worker submit one message, store the returned message identifier, and let later jobs poll delivery status. This keeps provider latency out of checkout and makes transport replacement a local change rather than a rewrite of order handling.

The design has four invariants. One order and one template revision produce at most one logical seller alert. Provider acceptance and carrier delivery remain separate states. A 429 delays work rather than causing a tight retry loop. Finally, destination country, consent, suppression state, message purpose, and template revision are validated before traffic leaves the application.

Consider the awkward replay, because happy-path diagrams hide it. Worker A submits ord_78421, then loses its queue lease before persisting the provider message ID; worker B receives the same outbox item and submits it again. A deterministic idempotency key such as seller-order:ord_78421:new_order_v3 gives both attempts the same transport identity, while a unique constraint on the outbox gives the application its own defense. The exact storage transaction depends on the queue and database, and I'm not sure there is one universal lease duration: queue visibility, provider latency, and the marketplace's recovery target have to settle that number. The invariant does not change.

Duplicates count as corruption.

How can a Node.js SaaS app implement SMS delivery status polling?

Polling belongs in scheduled queue work, not in the HTTP request that created the order. The first worker sends the alert and persists its provider ID; a later worker reads the status, appends an observation, and schedules another check only while the local state is nonterminal. Keep order_committed_at, provider_accepted_at, and status_observed_at as distinct timestamps. They answer different operational questions.

A pull-only event model limits how quickly delivery changes can feed a broader workflow. Don't pick it if a delivery receipt must synchronously release inventory, page an operator, or switch channels within seconds. For a seller-facing heads-up where the durable order dashboard is authoritative, however, polling is often acceptable and easier to reason about than another public callback ingress. Your mileage may vary — test the cadence with the actual countries and carriers instead of copying an arbitrary interval from a sample.

The application also owns controls that are easy to miss during a demo. Geo-fencing and country-based spend cutoffs must run before the adapter. Keep an app-side registry of approved SMS templates and revisions as well; a template lifecycle does not remove the need for a local deployment record. If the roadmap calls for voice, WhatsApp, or RCS, this capability set isn't suitable. Nor is it a shortcut to a multichannel fallback system: events are pull-only, email has no managed OTP operation, scheduled email has no cancellation operation, and there is no SMTP relay.

Short code is not the same as a small system.

Provider comparison under the integration-effort constraint

The useful comparison is not a feature-count contest. It is the provider-specific machinery required to operate one new-order alert without lying to the order service about delivery.

Option Integration shape Choose it when Limitation for this decision
Twilio Programmable Messaging Messaging-specific API with delivery status callbacks Pushed status changes or a mature messaging-specific toolchain are requirements Adds callback ingress and a provider event contract to operate
Vonage SMS API Dedicated SMS API with delivery receipts The team already uses Vonage or wants receipt callbacks Order idempotency, consent, and market policy still belong in the app
Amazon SNS SMS publishing inside the AWS service and IAM model Existing AWS controls matter more than a narrowly tailored SMS abstraction SNS delivery semantics must be mapped into the marketplace's local state model
Infrai Plain REST with public, keyless discovery schemas and runnable examples, while one API key and one bill cover a 295-route, 20-module backend surface Basic US/EU send, resend, cancel, and status polling fit, and avoiding another SDK plus another credential path reduces integration work Events are pull-only; polling, orchestration, geo controls, and country cutoffs stay in the app

The last row has two separate engineering advantages, not one slogan. Self-description reduces the time spent guessing request and response contracts when the SMS adapter is first wired. The shared credential reduces a different kind of friction later: if the marketplace adopts another backend capability from the same 20-module surface, secrets rotation and access provisioning do not gain another vendor-specific key. Runnable examples are available in 10 languages, although this article keeps the boundary in Python to show that the REST contract is independent of the production Node.js runtime.

There is no universal winner here. Stick with Twilio or Vonage when pushed delivery changes are a hard requirement. Prefer Amazon SNS when the alert should remain inside an established AWS control plane. Choose the polling-oriented REST option only when the marketplace already has durable workers, delayed retries, and a state store; without those pieces, the apparently simple setup merely moves complexity into an application that cannot safely hold it.

Python implementation of the critical API path

The sample calls only the verified send and status routes. It reads the send body from SMS_SEND_JSON because the current discovery schema, rather than an article, should define provider fields. ORDER_ID supplies a stable idempotency key, and SMS_API_ORIGIN is the selected API origin without a trailing slash. In production, run each status read as a separate durable job; the bounded loop below exists only to make the two-call contract copyable.

import json
import os
import time
from email.utils import parsedate_to_datetime
from typing import Any

import requests


API_ORIGIN = os.environ["SMS_API_ORIGIN"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
ORDER_ID = os.environ["ORDER_ID"]
SEND_BODY = json.loads(os.environ["SMS_SEND_JSON"])


def retry_delay(response: requests.Response, attempt: int) -> float:
    retry_after = response.headers.get("Retry-After")
    if retry_after:
        try:
            return max(0.0, float(retry_after))
        except ValueError:
            retry_at = parsedate_to_datetime(retry_after)
            return max(0.0, retry_at.timestamp() - time.time())
    return min(2 ** attempt, 30)


def call(
    method: str,
    path: str,
    *,
    body: dict[str, Any] | None = None,
    idempotency_key: str | None = None,
) -> dict[str, Any]:
    headers = {"Authorization": f"Bearer {API_KEY}"}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    for attempt in range(5):
        response = requests.request(
            method=method,
            url=f"{API_ORIGIN}{path}",
            headers=headers,
            json=body,
            timeout=20,
        )
        if response.status_code == 429:
            time.sleep(retry_delay(response, attempt))
            continue
        if not response.ok:
            raise RuntimeError(
                f"SMS request rejected ({response.status_code}): {response.text}"
            )
        return response.json()
    raise RuntimeError("SMS rate limit persisted after five attempts")


def find_message_id(value: Any) -> str:
    if isinstance(value, dict):
        for key, child in value.items():
            if key in {"id", "message_id"} and isinstance(child, (str, int)):
                return str(child)
        for child in value.values():
            try:
                return find_message_id(child)
            except ValueError:
                pass
    elif isinstance(value, list):
        for child in value:
            try:
                return find_message_id(child)
            except ValueError:
                pass
    raise ValueError("Send response did not contain a message identifier")


sent = call(
    "POST",
    "/v1/sms/send",
    body=SEND_BODY,
    idempotency_key=f"seller-order:{ORDER_ID}:new_order_v3",
)
message_id = find_message_id(sent)
print(json.dumps({"order_id": ORDER_ID, "send": sent}, indent=2))

for poll_number in range(1, 6):
    time.sleep(min(5 * poll_number, 20))
    status = call("GET", f"/v1/sms/status/{message_id}")
    print(json.dumps({"poll": poll_number, "status": status}, indent=2))
Enter fullscreen mode Exit fullscreen mode

The sample is intentionally strict about the transport boundary. Every request has an explicit method, the key comes from the environment, writes carry an idempotency key, 429 respects Retry-After when present, and non-success responses surface their bodies. It does not infer delivery from submission. It also does not claim a fixed response envelope: the recursive identifier lookup is compact demonstration glue, while a production adapter should generate or hand-write a typed parser from the discovered response schema and pin that contract in tests.

Five in-process polls are not an operational recommendation. A real worker should persist the next-check time, release its lease, apply a bounded policy chosen for the marketplace, and stop according to locally defined terminal states and retention rules. This is slower to sketch on a whiteboard, but it survives process restarts and deploys.

Rollout threshold for replacing polling with callbacks

The rejected design is a webhook-first, provider-shaped state machine for this one seller alert. It requires a public receiver, signature verification, replay handling, event ordering rules, dead-letter processing, and a mapping from the provider's event vocabulary into the order domain. Those are defensible costs when pushed receipts drive time-sensitive work; they are unnecessary coupling when the seller can always see the durable order and the SMS is only a prompt.

The catch is explicit: this rejection expires as soon as delivery timing becomes a business input. If a failed SMS must trigger email within a tight deadline, or if a receipt controls fraud review or fulfillment, use Twilio, Vonage, or another provider with the required pushed events and design the callback path properly. Likewise, if WhatsApp, RCS, or voice is on the near-term roadmap, select a communications platform that supports those channels instead of stretching a basic SMS contract past its boundary.

For the bounded marketplace job, approve the polling architecture only after a replay test proves one order cannot yield two logical alerts, a market-policy test blocks disallowed destinations before submission, and an operational test shows delayed jobs recover after a worker restart. The vendor choice follows those invariants. It does not replace them.

References

Top comments (0)