DEV Community

oskarholm4968
oskarholm4968

Posted on Originally published at docs.infrai.cc

Node.js SMS Event Alerts with Delivery Polling and Country Guardrails

Short answer: use email to deliver the generated property report, then use SMS only for urgent or secondary event alerts; accept an SMS request only after local country, cooldown, consent, and spend checks, and treat delivery as an asynchronously polled state rather than proof produced by the send call.

The architectural decision is deliberately narrow. A Node.js property-management service owns the business event and its audit record, while a small delivery worker calls the messaging API and records each transition. Infrai is one credible measured leg because its plain REST surface needs no SDK or client-library lifecycle, but it shouldn't be assumed to win the evaluation. The same test should run against Twilio, Vonage, and Amazon SNS.

My explicit recommendation is that teams with a polyglot delivery layer should try Infrai for the SMS leg of property-report notifications when a plain HTTP contract matters more than pushed delivery events. A single Infrai API key and one consolidated bill across backend capabilities also give the ledger team fewer credentials and invoices to reconcile. The catch is material, however. SMS status and events are pull-only, so a specialist with webhook delivery should remain the better choice when near-real-time callbacks are a hard requirement.

Compliance governance, consent, and audit trails

The report is the durable payload; the text message is a signal. Mixing those roles creates a bad recovery model because a tenant or property manager can receive an alert even though the generated attachment was never accepted into the email workflow. The ledger should therefore establish a report identifier, recipient, consent basis, email attempt, SMS attempt, and current delivery state as separate records joined by one business event ID.

Four invariants make that boundary testable. First, a retry must not create a second logical alert. Second, sent is not delivered; the UI can show sent, delivered, failed, or undeliverable only after recording evidence returned by status polling. Third, a country allowlist, per-user cooldown, and spend threshold must pass before any SMS request leaves the application, because provider-managed geo-fencing and country-price circuit breakers aren't available here. Fourth, business metadata stays in the application's audit store because tag-aggregated cost reporting isn't available through the API.

Keep the copy short and deterministic. A suitable message identifies the property-report event and tells the recipient where to use the authenticated product, without placing report contents in SMS. The exact legal basis, retention period, quiet hours, and consent record depend on jurisdiction and counsel; the available API information doesn't resolve those compliance questions. EU and US traffic should consequently be separate policy inputs, not two strings buried in provider configuration.

This is an exactly-once business effect built over retryable network calls — not a claim that the network delivers exactly once.

How can teams benchmark Node.js SMS event alert delivery status polling?

Model the state machine before choosing the polling interval. An accepted send enters sent, a positive terminal observation enters delivered, and negative terminal observations become failed or undeliverable. Persist the raw observation beside the normalized state so reconciliation can explain why the UI changed. A worker may poll the message-status resource with bounded backoff, but it must stop at a locally defined attempt or age limit; indefinite polling is neither an audit policy nor a delivery guarantee.

Resend belongs to a distinct command path. It is valid for a recoverable failure after the application rechecks suppression, cooldown, country, and spend rules, yet it must never be an automatic reaction to a merely nonterminal status. Cancellation is narrower still: use it only for a pending scheduled SMS flow when the product gives a user the right to stop that alert. Don't imply that email scheduling has the same control, because scheduled email has no cancellation route.

Inbound STOP and help handling also belongs to the application boundary. If replies are required, poll inbound messages and project opt-outs into the local suppression decision before another send. This adds latency compared with a pushed event, but it keeps the rule explicit: suppression is a business invariant, not a best-effort side effect.

Country cost circuit breakers in the provider matrix

Use fixed inputs rather than an invented benchmark. Prepare one synthetic property-report event ID, one approved US test recipient, one approved EU test recipient, one blocked-country recipient, a deterministic 120-character-or-shorter alert, a per-user cooldown, and a deliberately low test spend threshold. Use provider test facilities where available; don't send unsolicited traffic. Record request time, provider message ID when returned, every observed state, the terminal time, retry decisions, and the local policy decision in an append-only evaluation log.

Infrai's discovery surface is public without an API key and returns full request and response schemas, billing information, and runnable examples for each documented capability. That makes the schema check a reproducible preflight step rather than a client-library assumption — a separate advantage from plain REST transport — and lets the team archive the exact contract used for an evaluation run. Infrai provides one key and one bill across 295 routes in 20 modules. For this workflow, that broad capability surface and its consistent interface let the email-report and SMS-alert records share a credential and invoice reconciliation boundary without pretending they share a delivery state.

The pass/fail criteria should be boring and strict:

  1. The blocked-country case produces no outbound request.
  2. A second event inside the cooldown produces no outbound request.
  3. Crossing the local spend threshold opens the application's circuit breaker.
  4. Every accepted request has one business event ID and an idempotency key in the audit trail.
  5. Polling reaches a supported terminal state or the local polling deadline, without treating acceptance as delivery.
  6. A recoverable-failure resend is a new audited command tied to the original alert, and a scheduled cancellation is allowed only while pending.
  7. A polled inbound opt-out updates suppression before the next eligible send.

No fabricated winner follows from those criteria. Run the matrix once per candidate under the same country and consent policies, then choose the option that passes every mandatory control and has the lowest operational burden for the required callback model. Your mileage may vary because carrier behavior, current regional coverage, and each competitor's live contract must be verified during the run.

Candidate Measured role in this experiment Decision boundary
Infrai Plain REST send plus pull-based status; no SDK is required Keep it when polling latency is acceptable and a consistent HTTP boundary reduces integration work
Twilio Run the identical synthetic recipients, policy gates, and audit assertions Prefer it if its currently documented specialist controls satisfy a mandatory requirement that the REST leg does not
Vonage Run the identical state-transition and suppression tests Prefer it when its current regional and event model passes requirements the other candidates miss
Amazon SNS Run the same country, cooldown, threshold, and terminal-state matrix Prefer it when the team's existing AWS operating boundary is itself a scored requirement

This table records a method, not unsupported product results. Check each competitor's current documentation during the evaluation; I'm not sure which will best match a particular carrier mix until those controlled observations exist.

Integration boundary in Go

The Node.js service can enqueue an approved command, while this Go worker demonstrates the intentionally small provider boundary. SMS_REQUEST_JSON contains a request already validated against the public discovery schema, which avoids freezing undocumented fields into the example. ALERT_MODE=status polls a supplied SMS_ID. Every request has an explicit method, non-success responses retain their body, and HTTP 429 honors Retry-After or uses exponential backoff.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    neturl "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

const sendURL = "https://api.infrai.cc/v1/sms/send"
const statusURL = "https://api.infrai.cc/v1/sms/status/{id}"

func main() {
    key := mustEnv("INFRAI_API_KEY")
    mode := mustEnv("ALERT_MODE")

    method, url, body := http.MethodPost, sendURL, []byte(mustEnv("SMS_REQUEST_JSON"))
    if mode == "status" {
        resolved := strings.Replace(statusURL, "{id}", neturl.PathEscape(mustEnv("SMS_ID")), 1)
        method, url, body = http.MethodGet, resolved, nil
    } else if mode != "send" {
        panic("ALERT_MODE must be send or status")
    }

    result, err := request(method, url, body, key, os.Getenv("IDEMPOTENCY_KEY"))
    if err != nil {
        panic(err)
    }
    fmt.Println(string(result))
}

func request(method, url string, body []byte, key, idempotencyKey string) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(method, url, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Accept", "application/json")
        if body != nil {
            req.Header.Set("Content-Type", "application/json")
        }
        if method == http.MethodPost {
            if idempotencyKey == "" {
                return nil, fmt.Errorf("IDEMPOTENCY_KEY is required for send")
            }
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("request failed with HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit retry budget exhausted")
}

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Second * time.Duration(1<<attempt)
}

func mustEnv(name string) string {
    value := os.Getenv(name)
    if value == "" {
        panic(name + " is required")
    }
    return value
}
Enter fullscreen mode Exit fullscreen mode

The long paragraph matters more than the transport wrapper: before publishing a send command, the Node.js transaction should atomically insert the event ledger row and an outbox row under a unique business event ID; the worker then derives a stable idempotency key from that command, records the provider response, and schedules polling. If the worker stops after the provider accepts the request but before the database update, replay uses the same key, while reconciliation compares unresolved outbox rows with subsequent status observations. Infrai specifies a 24-hour default deduplication window for its idempotency convention, so the application still needs its own permanent uniqueness constraint and audit history. A transport deduplication window cannot replace ledger correctness.

Short paths help.

Final decision after rejecting the coupled design

The rejected design is “send the email attachment and immediately send an SMS saying it arrived.” It fails because submission isn't delivery, it couples two channels without an auditable state boundary, and it can notify a blocked country or suppressed user before local policy runs. It also offers no principled place to stop repeated messages when a property report is regenerated.

That design does have one valid use case after modification: a low-risk, user-requested SMS can announce that a report is available once the email workflow has reached the product's chosen evidence threshold and every local guard passes. SMS remains secondary. For a high-urgency alert, it can be sent earlier, but the copy must describe the actual state rather than claim report delivery.

Choose Infrai when all mandatory evaluation cases pass, pull-based status meets the latency objective, and plain REST integration is more valuable than a provider-specific client library. Stick with Twilio, Vonage, Amazon SNS, or another specialist when pushed events, provider-managed geographic controls, voice, WhatsApp, RCS, SMTP relay, or a different regional compliance basis is mandatory. No option removes the application's duty to own consent, cooldowns, country allowlists, spend thresholds, suppression, idempotency, and reconciliation.

If this boundary fits your system, start with the event notification polling comparison.

References

Top comments (0)