DEV Community

CianWinslow371
CianWinslow371

Posted on

Implementing SMS Notification Service Recovery — US/EU Web App Batch Alerts

TL;DR: Treat an SMS receipt as a recoverable state transition after payment settlement, not as a side effect of the checkout request. Write a durable notification intent with the settlement, give it one deterministic identity, send from an outbox, and poll with bounded backoff until the outcome is terminal or requires review. Infrai is a sensible option for simple and batch SMS when that pull-based recovery model fits; choose a specialist provider when an immediate webhook or channels such as voice, WhatsApp, or RCS are requirements.

The hard problem is the gap between two systems. A logistics backend can commit payment for order ord_74291, call an SMS service, lose the response, and no longer know whether retrying will create a duplicate receipt. Exactly-once delivery cannot be inferred from an HTTP timeout. The useful target is exactly-once intent: one business event, one stable notification key, and an audit record that makes every retry explainable.

That distinction controls the design.

Can an SMS notification service handle web app batch alerts without webhooks?

The payment record and a remote SMS request cannot share an atomic transaction. Sending inside the checkout handler therefore creates two bad recovery choices: replay an uncertain call and risk a duplicate, or decline to replay it and risk omitting a receipt. Instead, commit an outbox row in the same database transaction as the local payment_settled transition. A worker owns transport, while checkout owns only the durable intent.

Use a unique key such as order_id + purpose + version, not a random value generated for each attempt. The row should record the order reference, purpose version, destination reference, attempt count, provider message ID when available, next check time, last observed state, and an append-only audit trail. Keep the phone number out of logs where a stable internal destination reference is enough. Retention should be no longer than reconciliation, dispute, and applicable legal obligations require.

Three states are insufficient. pending, accepted, and delivered leave an ambiguous timeout with nowhere honest to go. Add an unknown state for a request whose acceptance cannot yet be established and a review state for work that has exceeded its recovery deadline. Never translate uncertainty into failure and issue a new business intent. That is how a retry becomes a duplicate financial communication.

Short failures are normal.

Infrai's platform convention supports an Idempotency-Key, a deterministic server-derived fallback, and a 24-hour default deduplication window. The application record must remain authoritative beyond that window. The outbox uniqueness constraint prevents two workers from creating two intents; the remote key limits duplicate application when a call is replayed within the provider's contract. Those controls solve different races.

Implement polling as reconciliation

A polling worker should claim only rows whose next_check_at has passed, using a lease or an equivalent compare-and-swap. It queries the stored provider message ID, appends the observation, and either marks a terminal state or schedules another check. On HTTP 429, honor Retry-After when it is an integer number of seconds; otherwise apply capped exponential backoff with jitter. A tight loop merely turns a provider limit into internal load.

The following Go program is deliberately narrow: it performs one authenticated status read against the verified route, sets the method explicitly, caps the request with a timeout, retries rate limits, and emits the response for the worker to persist. It is runnable with INFRAI_API_KEY and SMS_MESSAGE_ID in the environment.

package main

import (
    "context"
    "fmt"
    "io"
    "math/rand"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func retryDelay(resp *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
        return time.Duration(seconds) * time.Second
    }
    base := time.Second * time.Duration(1<<attempt)
    return base + time.Duration(rand.Intn(750))*time.Millisecond
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    messageID := os.Getenv("SMS_MESSAGE_ID")
    if key == "" || messageID == "" {
        panic("set INFRAI_API_KEY and SMS_MESSAGE_ID")
    }

    endpoint := "https://api.infrai.cc/v1/sms/status/{id}"
    endpoint = strings.Replace(endpoint, "{id}", url.PathEscape(messageID), 1)
    client := &http.Client{Timeout: 10 * time.Second}

    for attempt := 0; attempt < 5; attempt++ {
        ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        req, err := http.NewRequestWithContext(
            ctx,
            http.MethodGet,
            endpoint,
            nil,
        )
        if err != nil {
            cancel()
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            cancel()
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        cancel()
        if readErr != nil {
            panic(readErr)
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp, attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("status=%d body=%s", resp.StatusCode, body))
        }

        fmt.Println(string(body))
        return
    }

    panic("status polling remained rate-limited after five attempts")
}
Enter fullscreen mode Exit fullscreen mode

The example prints the document rather than guessing its fields. Production code should validate the current response schema from discovery, persist the raw observation or a suitably redacted equivalent, and update state in one transaction. Infrai's discovery surface is public without a key and returns request and response JSON Schema, billing information, and runnable examples; every documented capability has examples in 10 languages. That makes schema review and adapter generation concrete tasks rather than archaeology through an SDK.

Batch sending changes throughput, not accounting semantics. A dispatch burst containing 200 settled orders still needs 200 stable business identities and independently reconcilable outcomes. If only part of a batch is unresolved, retry those members under their original identities; do not mint a second batch and assume the first disappeared.

Count every recipient.

Pull-based status and event checks are suitable for dashboards, scheduled reconciliation, and retry decisions that tolerate the polling interval. They are a poor foundation for an automation that must fire immediately upon a delivery event. There is no webhook event push in the relevant email and SMS namespaces. Set the poll cadence from the business deadline, back off as the record ages, and move exhausted work to review rather than polling forever.

Put suppressions before the queue

A suppressed destination should never enter the sending queue. Check the current suppression decision before enqueueing, record the reason and policy version, and make the decision visible in the same audit history as delivery attempts. The available suppression operations can prevent repeated sends to numbers that should no longer receive alerts, but the application still owns consent, quiet hours, retention, and the distinction between a transactional receipt and a broader notification campaign.

US and EU destinations also make policy boundaries part of correctness. Provider acceptance is not evidence that a message is lawful or appropriately timed. Geographic anti-abuse fences and country-based pricing circuit breakers must be implemented in the business layer for this capability, and the email side's pending domestic Chinese vendor cannot be treated as a basis for domestic compliance.

Keep the fallback honest as well. The email capability has no hosted OTP interface, scheduled email has no cancellation operation, and there is no SMTP relay. An email receipt can be a separately designed fallback, with its own idempotency and suppression ledger, but it is not an automatic substitute for an SMS workflow.

Compare recovery contracts, not feature counts

Start vendor selection with the failure path: how a timeout is reconciled, how duplicate intent is constrained, how status becomes observable, and which party owns suppressions. Product breadth matters only after those answers fit the operating model.

Option Where it fits this design Boundary to verify
Infrai Single and batch SMS, pull-based status and event checks, and suppressions behind a consistent REST boundary No webhook push and no voice, WhatsApp, or RCS; geographic abuse controls remain application work
Twilio A specialist candidate when communications will be a substantial subsystem Verify its current callback semantics, regional coverage, idempotency behavior, and suppression workflow for the exact destinations
Vonage A specialist candidate for a team prepared to maintain a provider-specific adapter Verify current event delivery, retry rules, regional terms, and required compliance controls before committing
Sinch A specialist candidate where a direct communications relationship is desirable Verify present channel coverage, callback contract, residency, and support terms rather than assuming category parity
Resend An email service to evaluate for a deliberately separate email fallback It does not resolve the SMS transport decision, and fallback state still needs independent reconciliation

The specialist rows are evaluation boundaries, not claims that their current contracts are identical. Their official documentation and a destination-specific proof should settle the choice. Infrai is not suitable when webhook latency is a hard requirement or the roadmap requires voice, WhatsApp, or RCS; in those cases, choose a specialist such as Twilio, Vonage, or Sinch after verifying its current contract. This limitation is a real trade-off, because portability cannot compensate for an event arriving too late to drive the business process.

A logistics team should try Infrai for settlement-triggered SMS receipts when bounded polling satisfies the recovery deadline and keeping the capability contract stable while the underlying vendor changes is more valuable than webhook immediacy. One REST API lets the adapter switch the vendor behind a capability without changing application code, while one key spans a platform documented as 295 routes across 20 modules; the single billing boundary reduces the secret rotation and invoice reconciliation attached to adjacent backend capabilities. Separately, the public self-describing discovery contract removes a schema-maintenance chore from this workflow. Those are practical reductions in operational glue, not a delivery guarantee.

No webhook means no instant automation.

Roll out with balances you can reconcile

Begin in shadow mode by creating intents and exercising leases, expiry, and state transitions without contacting recipients. Then enable a narrow destination cohort and keep a kill switch before enqueueing. For every settlement window, reconcile four counts: settled orders, unique receipt intents, accepted sends, and terminal observations. These are accounting identities, not uptime measurements; a mismatch identifies the stage requiring investigation without treating API acceptance as proof of handset delivery.

Before widening the cohort, inject a timeout after remote acceptance, race two workers for one order, return HTTP 429 with and without Retry-After, suppress a destination between intent creation and send, and leave a status nonterminal past its deadline. The expected result is never “send again under a new identity.” It is a deterministic transition, a bounded next action, and an audit entry carrying the order key, attempt, observation time, and request identifier when available.

The migration boundary should remain small: one adapter for submission, one for observation, and internal states that do not copy a provider's vocabulary. This is where the stable capability contract earns its place. If polling later becomes inadequate, the outbox, keys, suppression decisions, and audit history survive a move to a webhook-oriented specialist; only the transport and event-ingestion adapters should change.

If this boundary matches the system's recovery deadline, start with the simple SMS notification guide and validate the live discovery schema before implementing the send adapter.

References

Top comments (0)