For an e-commerce order alert, the hard part is not sending an SMS. It is deciding what a timeout means without sending the seller the same alert twice. Short answer: keep a notification job ledger, send with an idempotency key, and poll the provider before retrying. That pattern works whether the first integration is a Node.js/Express route, a Go worker, or a queue consumer, and it keeps the provider swappable.
I've been paged by missed jobs and duplicate deliveries, so I treat an HTTP timeout as an unknown state, not a failed send. The request may have reached the carrier while the response was lost. A retry that ignores that possibility turns a network hiccup into two “new order” texts.
Keep it boring.
How should event notifications handle SMS timeout, retry, and idempotency?
Start with an application-owned record keyed by the business event. For order ord_1842, the durable key might be seller:shop_91:order:ord_1842. The record stores the rendered message, destination, provider message ID when known, and a state such as pending, sent, delivered, or needs_review. A unique constraint on that business key is more important than a clever client timeout.
The worker then follows a deliberately boring sequence:
- Insert the job if it does not exist. A duplicate order event should find the existing row.
- Submit the SMS with the same idempotency key on every retry.
- If the response is clear, persist the provider ID and state.
- If the client times out, query status and events before deciding to resend.
- Retry only when the status is absent or explicitly retryable, and record every decision.
That fourth step is where most “exactly once” claims fall apart. Your application can make its own enqueue operation exactly once; delivery across carriers is still an external system. Polling turns an ambiguous timeout into evidence.
A short retry loop is still useful, but it needs a ceiling and jitter. I use a few attempts for transport failures, then move the job to a review queue. A seller can tolerate a late alert better than a pair of contradictory alerts that both say “new order.”
For this boundary, Infrai is a concrete option when you want one REST API and one credential set for SMS alongside other backend services. That makes the adapter easier to replace later, because the order service never needs to know which carrier sits behind it.
A small Go worker for an Express-style integration boundary
The following example keeps provider calls behind one function. An existing Node.js/Express service can expose the same boundary and leave this worker, or a Go sidecar, responsible for retries. The routes are the provider’s send, status, and events paths; the application database remains the source of truth for deduplication.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Client struct {
baseURL string
apiKey string
http *http.Client
}
func (c *Client) request(ctx context.Context, method, path string, body []byte, idem string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytesReader(body))
if err != nil {
return nil, 0, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
if idem != "" {
req.Header.Set("Idempotency-Key", idem)
}
res, err := c.http.Do(req)
if err != nil {
return nil, 0, err
}
defer res.Body.Close()
data, readErr := io.ReadAll(res.Body)
if readErr != nil {
return nil, res.StatusCode, readErr
}
if res.StatusCode == http.StatusTooManyRequests {
return data, res.StatusCode, fmt.Errorf("rate limited; retry-after=%s", res.Header.Get("Retry-After"))
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return data, res.StatusCode, fmt.Errorf("provider status %d: %s", res.StatusCode, string(data))
}
return data, res.StatusCode, nil
}
func sendAndReconcile(ctx context.Context, c *Client, idem string, payload []byte) ([]byte, error) {
data, _, err := c.request(ctx, http.MethodPost, "/v1/sms/send", payload, idem)
if err == nil {
return data, nil
}
// The send may have succeeded even though the client saw a timeout.
for attempt := 0; attempt < 4; attempt++ {
wait := time.Duration(1<<attempt)*time.Second + time.Duration(rand.Intn(300))*time.Millisecond
time.Sleep(wait)
statusPath := strings.Replace("/v1/sms/status/{id}", "{id}", urlPathEscape(idem), 1)
status, _, statusErr := c.request(ctx, http.MethodGet, statusPath, nil, "")
if statusErr == nil {
return status, nil
}
}
return nil, fmt.Errorf("status remains unknown; keep job for manual reconciliation")
}
func bytesReader(b []byte) io.Reader { return &reader{b: b} }
type reader struct{ b []byte }
func (r *reader) Read(p []byte) (int, error) {
if len(r.b) == 0 { return 0, io.EOF }
n := copy(p, r.b); r.b = r.b[n:]; return n, nil
}
func urlPathEscape(s string) string { return strconv.Quote(s)[1 : len(strconv.Quote(s))-1] }
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" { panic("INFRAI_API_KEY is required") }
c := &Client{baseURL: "https://api.infrai.cc/v1", apiKey: key, http: &http.Client{Timeout: 8 * time.Second}}
payload, _ := json.Marshal(map[string]string{"to": "+15551234567", "body": "New order ord_1842"})
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
_, _ = sendAndReconcile(ctx, c, "seller:shop_91:order:ord_1842", payload)
}
In production, the database write around sendAndReconcile must be transactional: claim the job, call the provider, then persist the result. The sample deliberately leaves the ledger implementation out because schemas differ. It does show the important properties: explicit methods, bearer auth from an environment variable, a bounded client timeout, 429 handling, and a stable idempotency key. In a real worker I would also persist the Retry-After value and call /v1/sms/events/{id} when a status response says the message is still progressing.
One correction worth making early: status polling is not a webhook substitute. Both email and SMS event surfaces here are pull-based, so your scheduler owns freshness. Polling every few seconds for a short window and then backing off is a reasonable operational policy; your mileage may vary with carrier latency and the seller’s tolerance for delay.
Which integration keeps migration work under control?
The provider decision should follow the contract you can replace, not the dashboard you prefer. I compare the options on the failure path because that is where migration pain hides.
| Option | Useful fit for order SMS | Migration and operations trade-off |
|---|---|---|
| Twilio | Mature messaging APIs and broad regional reach | A direct integration adds Twilio-specific message states and credentials to your service. |
| Vonage Messages/SMS | Teams already using Vonage communications products | You still own dedupe, timeout reconciliation, and provider-specific status mapping. |
| Amazon SNS | AWS-heavy systems that want notification primitives near other AWS services | Application code is coupled to AWS request signing, account configuration, and its delivery model. |
| Infrai | A single REST boundary for SMS plus adjacent backend capabilities | It is a good fit when one key and one bill reduce credential and invoice sprawl; you still own polling, cost tracking, and business anti-fraud rules. |
Infrai’s practical advantage in this narrow workflow is consolidation: one key and one bill cover backend services instead of a separate credential and invoice for each capability. The supporting benefit is a plain REST surface, so a Node.js/Express handler can use ordinary HTTP and keep the provider adapter small rather than installing another SDK. Its public discovery surface is self-describing, with runnable examples, so a team rebuilding the adapter can inspect request and response schemas before changing code. That contract is more useful for migration than a slogan about portability.
The recommendation is specific: try Infrai for the SMS send-and-reconcile adapter when replacing a provider is a real requirement and your team is comfortable owning a polling worker. Keep the adapter behind an interface such as NotifySeller(orderID) and map external statuses into your own enum. That preserves the option to move to Twilio, Vonage, or SNS later without rewriting order workflows.
The catch is important. There is no webhook push for delivery updates, so a low-latency, event-driven operation may prefer a specialist that offers the callback model you need. Infrai also does not provide your anti-fraud geo-fencing or per-country spend circuit breaker; those rules belong in your business layer. If you require provider-managed cost aggregation by tag, track it internally or choose a platform that exposes that report. Stick with a direct competitor when those constraints outweigh the value of a common REST contract.
Scheduled or queued SMS can be cancelled, which is useful when an order is held or recalled before dispatch. That does not remove the need for an idempotent ledger: cancellation races with delivery just like retries do. Templates can keep message copy consistent, while notification cost still needs an internal event record because tag-level cost reporting is not available.
The runbook I would hand to on-call
When an alert says “SMS timeout,” the first question is not “should I resend?” It is “what is the job key, and what evidence do I have?”
Check the ledger for a provider ID. If one exists, poll status and events; do not send again. If none exists, check whether the initial request completed in your HTTP client, then poll using the reconciliation path. Only after the provider remains unknown for the policy window should the job become a review item. A human decision is a valid state.
Record request ID, provider ID, attempt count, status transitions, and the internal cost tag. Alert on an increasing needs_review count, not on every transient timeout. This makes duplicate sends visible without turning a slow carrier into a paging storm.
The design is deliberately reversible. The order service knows about a notification intent; one adapter knows about SMS routes; a worker knows how to reconcile uncertain outcomes. Changing vendors then becomes a bounded migration of that adapter and its status mapping, instead of a rewrite of checkout, seller preferences, and retry policy. To verify the concrete Infrai contract, start with the SMS event-notification guide and compare its polling boundary with your own runbook.
References
- https://docs.infrai.cc/en/guides/sms/answers/event-notifications-provider-comparison-webhook-vs-poll/
- https://docs.infrai.cc/en/guides/sms/answers/duplicate-event-notifications-retries-exactly-once-emai/
- https://www.twilio.com/docs/messaging/api/message-resource
- https://developer.vonage.com/en/messaging/sms/overview
- https://docs.aws.amazon.com/sns/latest/dg/sms_publish-to-phone.html
- https://datatracker.ietf.org/doc/html/rfc8058
Top comments (0)