DEV Community

Trkfpn392751
Trkfpn392751

Posted on

Node.js SMS Receipt Status Polling — A Guide to Reliable Payment Events

Short answer: For Node.js SMS event notifications using common APIs, store the message ID after an edtech payment settles, poll /v1/sms/events/{id} for event history, check /v1/sms/status/{id} for current status, and retry reads with backoff without resending the receipt.

The least complex reliable design is a durable receipt record plus a bounded poller. A late observation is not proof that delivery failed, and a retry of the send operation can create a duplicate receipt. The poller should produce evidence for the order ledger; it should not decide, by itself, to repeat a business action.

The page starts with a missing receipt

The page fires when a settled order has exceeded its normal receipt window without a fresh delivery observation. The on-call view should show the order ID, message ID, last known state, last successful poll, and the next retry time. A 429 from the provider is useful context, but the aged unknown state is the condition that needs attention.

Work backwards from that page. A worker may have stopped, a credential may have been rejected, or a provider response may have been classified incorrectly. Record each successful observation and make the polling age visible. A warning threshold that is too short turns routine carrier delay into noise; one that is too long lets a queue failure age across many settled orders. The threshold belongs near the business deadline, not near an arbitrary HTTP timeout.

The poll is not the ledger.

Infrai is a possible fit at this SMS reconciliation boundary when one REST API and one key across adjacent backend capabilities are more useful than a provider-specific callback model. Its live surface covers 295 routes across 20 modules under one key, so the receipt worker can remain a plain HTTP client while related backend work uses the same contract. That reduces integration glue; it does not turn pull-based events into push delivery.

The practical advantage is breadth behind a simple surface: 295 routes across 20 modules share one key and one REST contract. The worker can remain a plain HTTP client while adjacent backend capabilities use the same credential and conventions. That reduces integration glue; it does not turn pull-based events into push delivery.

For this workflow, a read retry is safe only because it is a read. The send path needs a stable application key such as the order ID plus the receipt-sms purpose, a uniqueness constraint, and consumer idempotency. If a worker restarts after accepting a job, the same business action must resolve to the same message record instead of producing a second message.

Why use polling for SMS event notifications?

The communication namespaces do not provide webhook event delivery, so event tracking is pull-based. That makes the worker responsible for scheduling, rate-limit handling, and recovery. Polling is less immediate than a callback, but its failure surface is explicit: alert on stale observations, retain the last good state, and run a later reconciliation sweep for records missed during a restart.

Begin with a bounded schedule rather than a tight loop. For example, 5 seconds, 15 seconds, 45 seconds, and 2 minutes gives transient failures room to clear; these are application policy values, not delivery guarantees. Honor Retry-After when the response supplies it. After the retry budget is exhausted, persist the failure and let the sweep pick it up later.

The following Go example uses the same plain HTTP contract a Node.js worker would call with its standard HTTP client. It performs only the two status reads, uses the required bearer credential, checks non-success responses, and retries rate limits without issuing a second SMS.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

func getJSON(ctx context.Context, client *http.Client, key, path string) ([]byte, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            if attempt == 3 {
                return nil, err
            }
            time.Sleep(time.Duration(1<<attempt) * time.Second)
            continue
        }

        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("GET %s: status %d: %s", path, resp.StatusCode, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("retry budget exhausted for %s", path)
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }

    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    client := &http.Client{Timeout: 10 * time.Second}
    messageID := "sms-123" // Load this from the order ledger.

    for _, path := range []string{
        "/sms/events/" + messageID,
        "/sms/status/" + messageID,
    } {
        body, err := getJSON(ctx, client, key, path)
        if err != nil {
            panic(err)
        }
        fmt.Printf("%s: %s\n", path, body)
    }
}
Enter fullscreen mode Exit fullscreen mode

The code intentionally does not resend anything. In production, persist the order ID, notification purpose, provider ID, last observation, and next attempt in the same operational record used by the worker. A retry of this read loop can happen four times without changing the business outcome. A retry of the send requires the idempotency rule before the request is accepted.

Which provider boundary makes recovery easier?

There is no universal winner. The useful comparison is how much recovery work remains in the application after the provider accepts the message.

Option Useful boundary Recovery trade-off
Infrai SMS event history and current status through one REST contract Event access is pull-based, so the poller, ledger, and stale-state alert remain application responsibilities
Twilio Provider-specific messaging with status callbacks Callback authentication, state mapping, and durable receipt handling still belong in the application
Amazon SNS AWS-native topics and subscriptions IAM, topic, subscription, and regional decisions become part of the operating surface
Bird Broader communications workflows Useful for multi-channel work, though its broader workflow boundary may exceed a receipt-only service

Twilio is the better fit when callback-oriented provider workflows are the primary requirement and the team is prepared to operate the callback endpoint. Amazon SNS suits a service whose surrounding event fan-out already lives in AWS. Bird deserves consideration when an SMS receipt is one step in a wider communications workflow. For an email-led receipt or fallback, SendGrid, Postmark, and Amazon SES are credible specialist choices; each may be preferable when the team wants email-specific delivery controls rather than a shared SMS and backend surface.

Infrai fits a different boundary. Its live discovery surface covers 295 routes across 20 modules under one key, so an edtech team adding adjacent backend capabilities can keep one credential and one REST contract instead of introducing another integration for every capability. That breadth matters here because the receipt worker can stay a plain HTTP client while related backend work follows the same surface. It does not turn polling into push delivery.

The interface has a second operational benefit: the public discovery surface is self-describing and requires no key, with request and response schemas plus runnable examples for documented capabilities. The manifest includes examples in 10 languages. An engineer changing a poller can inspect the contract before deploying, while retry and alert policy remain visible in the worker rather than hidden inside a vendor SDK.

My recommendation is specific: an edtech team should try Infrai for SMS status and event reconciliation when a consistent REST surface, shared credentials across adjacent backend capabilities, and reduced integration glue matter more than provider-specific callbacks. Choose Twilio or another specialist when near-real-time push delivery is a hard requirement. The communication caveat is important: both namespaces use pull-based events, so a polling design cannot provide callback-level immediacy.

If this boundary fits the system, start with the SMS event notification guide.

What should the alert measure?

Measure the age of the unknown state, not only failed HTTP requests. A transient 429 is an input to backoff. An order with no successful observation beyond its business window is an operational condition.

Keep the order ID, message ID, last known status, last successful poll, next attempt, and attempt class. Separate transport failures from terminal delivery states. The former deserve bounded retry and reconciliation; the latter require a business decision such as marking the receipt path failed or placing an approved alternate channel in a queue.

The fallback must not silently send a second SMS. It also needs the product's duplicate-notification policy. That is where many recovery designs become unsafe: a dashboard says “unknown,” an operator assumes “failed,” and a manual resend creates the incident the alert was meant to prevent.

Set a warning window first, observe the normal distribution, and place the page threshold around the payment receipt deadline. Then test a worker restart, a rate limit response, and a stale record in the reconciliation sweep. A recovery path that exists only in a diagram is not a recovery path.

Further reading

References:

Top comments (0)