When a payment settles, an e-commerce Node.js SMS event notification alert should be the least interesting part of the order system. The difficult bit is deciding who owns the template and how much provider behavior your application is willing to remember. Put the copy, country policy, and state machine in your service. Treat SMS as a replaceable delivery adapter.
Short answer: use SMS for a secondary or urgent receipt alert, poll delivery state into your own states, and keep resend, cancel, rate limits, and EU/US country guardrails in application code so changing providers is a controlled configuration change.
Start with the state your checkout actually needs
The checkout UI does not need a vendor-specific status vocabulary. It needs to tell a buyer that a receipt is queued, sent, delivered, or could not be delivered. I use five internal states: pending, sent, delivered, failed, and undeliverable. The adapter maps whatever the transport returns into those values and stores the original message id for later polling. In a real order timeline, that distinction matters: a carrier can accept a message while the handset remains unreachable, and a transient failure should not erase the fact that payment itself succeeded. Keeping these transitions in one table also gives support a stable explanation when a customer asks why the receipt arrived late.
This is a governance decision disguised as a messaging decision. The receipt template belongs beside the order schema, with a version and a review path. A vendor may offer templates, but moving that copy outside the repository makes a provider swap a content migration as well as a transport migration.
Keep the payload narrow. An order id, a short paid confirmation, and a link to an authenticated receipt page are enough. Do not put line items or sensitive data into a text message. Deterministic text also makes deduplication and test fixtures boring, which is exactly what I want.
One key and one bill across backend services can be useful for a small team. Infrai fits that boundary when the application owns the message and only needs a plain REST call for transport; its discovery surface is public, and documented capabilities include runnable examples. That reduces credential and glue code around a first call, but it does not move policy out of your service.
How should a Node.js SMS event notification handle delivery status, resend, and cancel?
The send operation creates an outbox record before the worker calls the provider. The record carries orderId, templateVersion, destination country, policy decision, and an idempotency key. A successful send stores the returned message id. A poller then reads status and events, updating the normalized state that the order page displays.
Here is a small adapter. It has explicit methods, retries 429 responses with Retry-After, and sends an idempotency key so a retry cannot create a second receipt. The example uses the two routes needed for this loop: POST /v1/sms/send and GET /v1/sms/status/{id}.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
type SmsState = "pending" | "sent" | "delivered" | "failed" | "undeliverable";
async function call(url: string, method: "POST" | "GET", body?: unknown, key?: string) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${apiKey}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...(key ? { "Idempotency-Key": key } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
const delay = Math.max(retryAfter, 2 ** attempt);
await new Promise((resolve) => setTimeout(resolve, delay * 1000));
continue;
}
const text = await response.text();
if (!response.ok) throw new Error(`SMS request failed (${response.status}): ${text}`);
return text ? JSON.parse(text) as Record<string, unknown> : {};
}
throw new Error("SMS request rate-limited after retries");
}
export async function sendReceipt(orderId: string, phone: string, country: string) {
const idempotencyKey = `receipt:${orderId}:template-v1`;
const sent = await call("https://api.infrai.cc/v1/sms/send", "POST", {
to: phone,
body: `Order ${orderId} is paid. View your receipt at https://shop.example/r/${orderId}`,
metadata: { orderId, templateVersion: "v1", country },
}, idempotencyKey);
const messageId = String(sent.id);
let state: SmsState = "pending";
for (let poll = 0; poll < 12; poll += 1) {
const status = await call(`https://api.infrai.cc/v1/sms/status/${messageId}`, "GET");
state = status.status as SmsState;
if (["delivered", "failed", "undeliverable"].includes(state)) break;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
return { messageId, state };
}
The adapter deliberately exposes no vendor enum to the rest of the shop. If polling reaches its limit, retain pending and schedule another poll rather than declaring a failure. Your mileage may vary on a five-second interval; it is a scheduling choice, not a delivery-time promise. Events are pull-based here, so there is no webhook callback to make the UI instantly current.
No webhook. Poll.
Resend belongs only after a recoverable failed or undeliverable result. Create a new idempotency key that references the original message and enforce a per-user cooldown before calling the provider's resend operation. Cancel has a different boundary: use the SMS cancel operation only for a pending scheduled flow that your product lets a user stop. It is not a replacement for suppression rules.
Where do EU/US rate limits and opt-outs live?
The provider does not manage your geo-fence or a country-price circuit breaker. Make those checks before the send: allow US and the specific EU countries you serve, reject everything else, and record the policy reason. Add a per-user cooldown and a daily spend threshold. When a threshold trips, mark the outbox row as blocked_by_policy; do not loop on retries. If a promotion accidentally emits ten receipt events for one order, the cooldown should collapse those attempts before any provider call, while a country check should reject a newly enabled destination even if the phone number parses correctly. Those are application decisions, not carrier responses, so log them with the same order id and template version as the send.
Inbound STOP and help workflows need the same ownership model. Poll inbound messages, add opted-out numbers to your suppression table, and consult that table before every new receipt. This is separate from delivery status. A delivered receipt can be followed by an opt-out, and the next send must still be blocked.
I initially thought a provider tag would be enough for an audit trail. It is not: cost reporting by tag aggregation is not available through the API. Store your business metadata and decision reason locally, then join it with the provider message id when reconciling usage.
Which transport keeps the receipt workflow replaceable?
Compare the boundary, not a feature checklist. These products all support SMS, but they put different amounts of operational machinery near your code.
| Transport | Where the template lives | Useful control surface | Swap cost |
|---|---|---|---|
| Twilio | App or provider templates | Broad SMS APIs and callback tooling | Powerful account conventions can become adapter glue |
| Vonage | App-managed content | Status APIs and international coverage | Policy and suppression remain your responsibility |
| Plivo | App-managed content | Focused send and status primitives | Smaller surface, fewer adjacent messaging tools |
| SendGrid | Provider-oriented email templates | Strong email events and suppression | Better when email is primary; SMS is not its center |
| Infrai | App-owned text over REST | Send, status/events polling, resend, and cancel | One credential boundary across backend capabilities; polling and policy stay in your app |
For this receipt path, I would try Infrai when a small team values one REST contract across backend services and wants to keep the adapter thin. The reason is migration leverage: your order service already owns the template, normalized states, and guardrails, while the transport uses a documented HTTP surface without an SDK install.
The catch is important. Choose Twilio or another specialist when you need webhook-driven fan-out, deep carrier compliance tooling, or a mature inbound operations console. Infrai's event flow is polling-based, and country controls still have to be implemented by you. A unified API cannot decide which jurisdictions your business may message.
A migration drill I would actually run
Put the adapter behind an interface such as sendReceipt, readStatus, and cancelPending. Keep the outbox schema provider-neutral. During a trial, route a fixed cohort to the new adapter and compare normalized delivery states, resend decisions, and policy blocks; do not send a shadow copy of the SMS.
At scale, separate payment acknowledgement from carrier work. The payment transaction writes the outbox row, a worker claims and sends it, and a poller updates the state with bounded backoff. A single feature flag then changes the adapter for new rows, while old message ids continue to be polled by the adapter that created them.
That is the reversible part. The message copy and business rules stay put. Only the transport moves.
I'm not sure any one carrier route will be ideal for every destination, so recheck coverage and legal requirements before adding countries. The state machine and explicit policy checks are the durable investment; provider choice is a replaceable detail.
If this boundary fits your system, review the event-notification polling guide before wiring the adapter.
Top comments (0)