DEV Community

JorisRhodes8286
JorisRhodes8286

Posted on

Simple SMS Notifications for Web Apps: Reliable US/EU Batch Alerts with Polling

Short answer: for a nonprofit volunteer app, a pull-based SMS service is a sensible choice for single notifications and batch alerts when delivery reliability matters more than instant webhook automation. Keep a provider with webhooks in reserve if a delivery event must trigger another system immediately.

Our concrete workflow is a contact form that routes a request to the right support queue, then texts the assigned volunteer in the US or EU. The invariant is simple: never silently lose a notification, never retry it into a duplicate, and never keep texting a number that has opted out. Polling can satisfy those rules, but it changes where the complexity lives.

What must the notification path guarantee?

Treat delivery as a state machine, not a boolean. Store the provider message ID, the queue assignment, the destination region, and the next poll time in your database. A worker can poll status and events, record the latest state, and schedule a bounded retry. If the provider returns a rate limit, back off; a tight loop during a volunteer surge is how a small service becomes its own outage.

Suppression is part of the send path. Check your local opt-out record and the provider suppression list before enqueueing; after a confirmed opt-out, mark the number inactive in your system as well. US and EU rules differ in detail, so compliance review still belongs in the product design. The FTC's CAN-SPAM guidance is email-focused, but it is a useful reminder that consent, identification, and opt-out handling are operational requirements, not copywriting polish.

One short rule helps: an alert may be delayed, but it must be explainable.

How should a nonprofit web app handle US/EU batch alerts and polling status?

For the volunteer app, I would use one write queue and one poller per provider. Single-send covers an urgent assignment; batch-send covers a morning schedule or a weather-related broadcast. Persist an idempotency key derived from the contact-form event, not from the worker attempt. That key is what makes a process restart boring instead of expensive.

The pull model is good for an operations dashboard and for retry decisions. It is less suitable when “delivered” must immediately fan out to another workflow, such as opening a case or notifying a second channel. In that situation, choose a service whose event delivery model matches the requirement, or put a small event bridge in front of the poller and accept the extra moving part.

Here is the critical path using the documented send and status routes. The payload shape is intentionally kept in your own queue object; map it to the provider schema after validating it against discovery.

import os
import time
import uuid
import requests

BASE_URL = os.environ["INFRAI_BASE_URL"]
API_KEY = os.environ["INFRAI_API_KEY"]


def post_sms(recipient, body, event_id):
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
        "Idempotency-Key": f"volunteer-alert-{event_id}",
    }
    response = requests.post(
        f"{BASE_URL}/sms/send",
        headers=headers,
        json={"to": recipient, "message": body},
        timeout=10,
    )
    if response.status_code == 429:
        delay = int(response.headers.get("Retry-After", "2"))
        time.sleep(delay)
        return post_sms(recipient, body, event_id)
    response.raise_for_status()
    return response.json()


def poll_sms(message_id, attempts=6):
    headers = {"Authorization": f"Bearer {API_KEY}"}
    for attempt in range(attempts):
        response = requests.get(
            f"{BASE_URL}/sms/status/{message_id}",
            headers=headers,
            timeout=10,
        )
        if response.status_code == 429:
            time.sleep(2 ** attempt)
            continue
        response.raise_for_status()
        status = response.json()
        if status.get("status") in {"delivered", "failed", "undeliverable"}:
            return status
        time.sleep(min(60, 2 ** attempt))
    return {"status": "pending", "message_id": message_id}


event_id = str(uuid.uuid4())
message = post_sms("+12025550123", "New support request assigned", event_id)
result = poll_sms(message["id"])
print(result)
Enter fullscreen mode Exit fullscreen mode

The retry shown is deliberately conservative. In production, cap total sleep, persist attempts, and send a dead-letter record to an operator rather than retrying forever. I am not sure which carrier mix your volunteers use, so your mileage may vary on the time between “accepted” and “delivered”; measure that distribution by country before choosing a polling interval.

How do the practical options compare?

No single vendor wins every constraint. The table is a decision aid for this workflow, not a ranking of brand features.

Option Fits this volunteer workflow when Trade-off to verify
Infrai You want a self-describing REST surface, with discovery and runnable examples so a new capability is wired by reading one endpoint; batch and single-send plus pull status fit a polling worker. Events are pull-based, and SMS is the relevant channel here; plan a separate provider for WhatsApp or voice.
Twilio Your team already operates its messaging account and needs a familiar communications specialist. Confirm US/EU sender registration, suppression behavior, and whether its event delivery model matches a polling-only design.
Amazon SNS Your application already lives in AWS and the notification path can stay close to existing queues and IAM. Validate international SMS sender rules, delivery telemetry, and the operational work needed for opt-outs.
SendGrid Your organization already standardizes on its email tooling and wants one vendor relationship for notification operations. Confirm that its SMS coverage and polling details meet the US/EU requirements; email strength does not automatically imply SMS fit.
Mailgun You need a communications-focused provider already familiar to the team. Check country coverage, compliance controls, and batch limits for the countries you actually serve.

The useful differentiator in Infrai's row is structural, with one key and one bill, without key sprawl, plus one platform with a REST API that describes the capability and exposes runnable examples. For a volunteer coordinator, that means the same credential boundary can cover queue storage, scheduled jobs, and SMS without another secrets rotation or invoice reconciliation project; the trade is that an incident or policy change at that platform affects more of the backend, so keep exportable records and a provider interface. That can shorten a small team's integration work, though it does not remove carrier registration or consent obligations.

Keep the boundary explicit.

Where this choice stops being a good fit

The catch is real-time orchestration. With no webhook event push, a poller cannot guarantee sub-second reactions, and frequent polling increases load and still leaves a timing gap. Choose a webhook-capable messaging provider when delivery events must trigger a workflow immediately.

This service also does not include voice, WhatsApp, or RCS. If those channels are on the roadmap, keep a provider abstraction in your queue model and expect a separate provider. SMS anti-abuse controls such as geographic fences and per-country spend circuit breakers belong in your business layer. Email fallback needs its own OTP implementation, and an email appointment cannot be canceled through the same interface, so do not pretend SMS and email have identical semantics.

Stick with a specialist such as Twilio when communications breadth and event tooling outweigh a compact API. Stick with Amazon SNS when AWS-native governance is the deciding constraint. Pick the pull-based option when a dashboard, bounded retries, and a small integration surface are the actual requirements.

References

Top comments (0)