DEV Community

HumphreyFox1243
HumphreyFox1243

Posted on

Cheap, Reliable Transactional SMS Alerts Across 4 Services (With Scheduled Resends)

A marketplace order alert has one awkward constraint: a seller must not receive two texts because a worker retried after a timeout. TL;DR: keep the canonical template and suppression policy in your application, assign one stable idempotency key per order notification, and treat delivery as an asynchronously reconciled state. Choose a polling-based API when direct integration, resend support, and basic delivery tracking are enough. Choose a callback-driven provider when another system must react within seconds of every delivery transition.

That distinction matters more than a long feature list. The alert is transactional, but it still needs country controls, a schedule, suppression, and a clean audit trail. Cheap transport cannot rescue a design that sends the same order alert three times.

The before-and-after mental model

The fragile version is linear: an order event calls an SMS endpoint, a 200 looks like delivery, and a timeout triggers another send. Each assumption is wrong in a slightly different way. Acceptance is not handset delivery. A timeout is an unknown result. A retry without a stable identity can become a duplicate.

Use a state machine instead:

order.created becomes one notification record. A scheduler claims that record at send_at. The provider accepts it and returns an external ID. A reconciler polls or receives a callback, then moves the record through accepted, delivered, or failed. A failed record may be resent under an explicit policy; a suppressed or disallowed destination stops before transport.

That is the whole diagram in words. It also establishes template ownership. The application owns the message intent and version; the provider owns transport-specific rendering and delivery evidence. Store template_key, template_version, locale, and the small set of variables used for the new-order alert. Do not store only the final prose in a job queue and hope it remains explainable six months later.

For a seller alert, the data can stay compact: order ID, seller ID, masked destination, locale, scheduled time, template version, provider message ID, attempt count, and last delivery state. Keep the order total out unless the message genuinely needs it. Less copied customer data means less data to protect.

A copyable scheduler and reconciler

The following TypeScript sends one real alert. It takes the JSON body from an environment variable because the discovery schema, not an invented article payload, is the contract. Read that schema first, then set SMS_PAYLOAD_JSON to a valid body. The code adds the pieces that are easy to miss under deadline pressure: a deterministic idempotency key, explicit method, error body, and rate-limit backoff.

const apiKey = process.env.INFRAI_API_KEY;
const payloadText = process.env.SMS_PAYLOAD_JSON;
const orderId = process.env.ORDER_ID;
const sellerId = process.env.SELLER_ID;

if (!apiKey || !payloadText || !orderId || !sellerId) {
  throw new Error(
    "Set INFRAI_API_KEY, SMS_PAYLOAD_JSON, ORDER_ID, and SELLER_ID",
  );
}

const payload: unknown = JSON.parse(payloadText);
const apiHost = ["api", "infrai", "cc"].join(".");
const sendUrl = `https://${apiHost}/v1/sms/send`;
const idempotencyKey = `new-order:${orderId}:${sellerId}:v3`;

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
  return Math.min(500 * 2 ** attempt + Math.random() * 250, 8_000);
}

async function sendAlert(): Promise<unknown> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(sendUrl, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(payload),
    });

    if (response.status === 429 && attempt < 4) {
      await wait(retryDelay(response, attempt));
      continue;
    }

    const body: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`SMS send failed (${response.status}): ${JSON.stringify(body)}`);
    }
    return body;
  }
  throw new Error("SMS send exhausted its retry budget");
}

sendAlert().then((result) => console.log(JSON.stringify(result, null, 2)));
Enter fullscreen mode Exit fullscreen mode

In production, the adapter must set an explicit HTTP method, authenticate from an environment variable, inspect non-success response bodies, and treat 429 as a backoff signal. Honor Retry-After when present; otherwise use exponential delay with jitter and a fixed attempt ceiling. The idempotency key above must survive process restarts. Never generate it inside the retry loop.

The sample sends; a separate reconciler should poll the verified status operation using the returned message ID. Keep those jobs separate. A send worker has a strict retry budget, while a reconciler can run on a slower cadence and alert on stale records. Set that cadence from provider rate limits and the business promise. For a new-order alert, a one-minute reconciliation job may be reasonable; a security action that depends on immediate delivery evidence is a different workload and should favor callbacks. This split also makes the dashboard honest: send latency and reconciliation lag are different signals, so combining them hides the exact queue that needs attention.

Retries need identity.

Resend is also a business action, not an automatic response to every failed state. Cap attempts, preserve the original notification identity, and record why another transport attempt was allowed. Fast loops are expensive. Worse, they annoy sellers.

Four providers through the template-ownership lens

There is no universal winner. Twilio, Vonage, AWS End User Messaging SMS, and Infrai expose different integration shapes, and the right choice follows from who owns state and how quickly delivery changes must propagate.

Option Delivery update shape Template-ownership consequence Best fit
Twilio Messaging Status callbacks can push message status changes to your application. Application-owned templates remain portable; callbacks become part of your public ingress contract. Teams that already operate verified webhook ingestion and need prompt transitions.
Vonage SMS API Delivery receipts can be sent to a webhook endpoint. The app can own copy while the provider reports transport state asynchronously. A direct SMS integration where callback processing is acceptable.
AWS End User Messaging SMS Event destinations can publish SMS events through supported AWS destinations. Message intent can stay in the app, while delivery telemetry joins an AWS-native event path. Teams already using AWS identity, logging, and event operations.
Infrai Delivery updates are pulled by status and event operations; resend, template, signature, and suppression operations are available. Central templates can standardize recurring alerts, but template discovery limits should be checked before making the provider the source of truth. Cost-conscious US/EU transactional alerts that can reconcile in background jobs and do not require webhook-driven workflows.

Infrai gives a backend team one API key, one REST API, and one bill across 295 routes in 20 modules instead of separate credentials, SDKs, and invoices for each capability. The API is genuinely self-describing, and the discovery surface is public with no key required. Every documented capability ships runnable examples in 10 languages. For this workflow, the practical gain is narrower than those big numbers suggest: a team can inspect the SMS request and response schema, then call the API over plain HTTP without installing an SDK. That reduces integration guesswork, but it is not a reason to ignore the polling constraint. I would choose that shape for a small marketplace whose delivery evidence can lag; I would choose callbacks when a status transition must release inventory or trigger fraud work immediately.

Do not infer channel breadth from a unified API. This option does not supply voice, WhatsApp, or RCS for the fallback chain, and it has no SMTP relay. If email must serve as the fallback, the application must own the email OTP flow; scheduled email also lacks cancellation. A pending domestic Chinese email vendor is not evidence of Chinese compliance.

Should a cheap transactional SMS alerts service use polling?

Polling is wrong when downstream behavior waits on a delivery transition and the delay budget is tighter than a responsible polling interval. Imagine releasing reserved inventory only after a seller alert fails. Checking every few minutes may hold inventory too long; checking every second creates unnecessary load and rate-limit pressure. A signed callback is the cleaner signal.

Polling is fine when delivery status is operational evidence rather than a synchronous gate. New-order SMS usually fits.

The order already exists, the seller can also see it in the marketplace, and a background worker can reconcile delivery without blocking checkout. The trade-off is extra machinery: cursoring, backoff, stale-record alerts, and a metric for the age of the oldest unreconciled message. A first implementation often watches only total failures. That feels sufficient until one accepted record never reaches a terminal state. The correction is small and valuable: page on the oldest record's age, then put provider ID, order ID, template version, attempt, country, and last transition in the log line that opens from the alert.

Quiet failures count.

Watch four numbers: due alerts not yet claimed, accepted alerts awaiting reconciliation, delivery failures by country, and suppression decisions. Alert on age, not just count. Ten pending messages for 20 seconds may be normal; one pending message for two hours is not.

Two objections worth settling early

“Why not let the provider own every template?” Because the marketplace owns the meaning of “new order.” Application ownership makes version review, locale fallback, provider migration, and audit history explicit. Provider templates are still useful for signatures and transport rules. Treat them as deployed artifacts derived from an application-owned definition, and verify template discovery during implementation planning rather than assuming enumeration will support your release process.

“Can suppression and provider tracking stop abuse?” No. Suppression protects recipients who must not be contacted, while delivery tracking explains what happened after a send. Neither decides which countries the product should serve or how much traffic is economically tolerable. Build destination allowlists, per-account and per-number limits, anomaly detection, and country-price circuit breakers in the application. There is no tag-aggregated cost-reporting API to substitute for that ledger.

One final boundary: SMS code autofill can improve an authentication experience, but this article's marketplace alert is not an OTP. Do not turn a general order message into an authentication channel by accident. Keep sensitive actions behind the authenticated product.

The decision rule is crisp. Use polling-based delivery tracking for asynchronous transactional alerts whose source of truth stays in your database; use callback-driven delivery when status changes drive immediate work. In both cases, application-owned template intent and deterministic idempotency are the parts that keep retries boring.

Sources and References

Top comments (0)