DEV Community

GregorSterling9652
GregorSterling9652

Posted on

Urgent Event Notifications: A 2-Channel SMS-First Fallback Pattern for US and EU

For a healthtech receipt that must follow a settled payment, the operational constraint is template ownership: SMS needs a short, controlled message, while email owns the detailed receipt. SMS-first with an email fallback is a reasonable US/EU pattern, but the fallback timer, polling, retries, and country guardrails belong in your Node.js service.

Short answer: send the SMS, poll its delivery state, and send the email only after a deadline or a terminal suppression state; do not treat an accepted SMS request as delivery.

Why template ownership changes the notification path

The receipt is a compliance-adjacent record, not a marketing blast. I keep the SMS template intentionally boring: order reference, amount, and a link with a short expiry. The email carries line items, tax detail, and the audit copy. That split makes ownership explicit and keeps the urgent channel small.

An SMS provider can accept a request before a carrier confirms delivery. Your application therefore needs a state machine: queued, delivered, failed, or suppressed. Polling is less immediate than a webhook, and these two namespaces expose pull-based events, so your deadline should account for that delay.

The catch is operational. Country restrictions, geo-fencing, and price-based circuit breakers are not built into this flow. Put a US/EU allowlist and a budget guard in your own database before creating the send request.

How should Node.js orchestrate urgent event notifications with polling and retry logic?

Here is the smallest shape I would ship first. Set INFRAI_BASE_URL to the provider's /v1 base in your deployment environment. The code uses explicit methods, a client idempotency key, exponential backoff for 429 responses, and a bounded polling window. The email call is shown as the final action, not as a second SMS attempt.

const API = process.env.INFRAI_BASE_URL;
const key = process.env.INFRAI_API_KEY;
if (!API || !key) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");

type SmsState = "queued" | "delivered" | "failed" | "suppressed";

async function request(path: string, method: "POST" | "GET", body?: unknown, idem?: string) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const response = await fetch(`${API}${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
        ...(idem ? { "Idempotency-Key": idem } : {})
      },
      body: body === undefined ? undefined : JSON.stringify(body)
    });
    if (response.status !== 429) {
      if (!response.ok) throw new Error(`${method} ${path}: ${response.status} ${await response.text()}`);
      return response.json();
    }
    const retryAfter = Number(response.headers.get("retry-after") ?? 0);
    await new Promise(resolve => setTimeout(resolve, retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt));
  }
  throw new Error(`rate limit persisted for ${path}`);
}

export async function sendReceipt(orderId: string, phone: string, email: string, country: "US" | "EU") {
  if (!["US", "EU"].includes(country)) throw new Error("country is outside the allowlist");
  const idempotencyKey = `receipt-${orderId}`;
  const sms = await request("/sms/send", "POST", {
    to: phone,
    body: `Payment settled for order ${orderId}. Full receipt is in your email.`
  }, idempotencyKey);

  const deadline = Date.now() + 90_000;
  let state: SmsState = "queued";
  while (Date.now() < deadline && state === "queued") {
    await new Promise(resolve => setTimeout(resolve, 5_000));
    const status = await request(`/sms/status/${sms.id}`, "GET");
    state = status.status as SmsState;
  }
  if (state === "delivered") return { channel: "sms", id: sms.id };

  const emailResult = await request("/email/send", "POST", {
    to: email,
    subject: `Receipt for order ${orderId}`,
    template: "settled-order-receipt",
    data: { orderId }
  }, `${idempotencyKey}-email`);
  return { channel: "email", id: emailResult.id, smsState: state };
}
Enter fullscreen mode Exit fullscreen mode

The 90-second value is a policy knob, not a provider guarantee. Measure delivery latency by country, suppression rate, duplicate rate, and the percentage of receipts that fall back before changing it. I’m not sure your carrier mix will match mine; your mileage will vary.

Measure first.

What do Twilio, SendGrid, and Amazon SNS change?

Competitors are useful when you map them to ownership rather than to a feature checklist. Twilio is a natural SMS-first baseline. SendGrid is strongest when the email template and audit trail dominate. Amazon SNS fits teams that already route many event types through AWS. Each choice still leaves you responsible for the fallback decision.

Option Template ownership fit Delivery decision Practical trade-off
Twilio SMS + email provider SMS operations are familiar; email is a separate concern Build polling or event ingestion yourself More vendor accounts and reconciliation
SendGrid-centric flow Rich email templates are the primary artifact SMS fallback needs another service and timer Clear email ownership, broader orchestration work
Amazon SNS Good for an existing AWS event graph Application policy decides when to email Convenient fan-out, less focused on receipt templates
One REST backend such as Infrai A single HTTP integration can own both sends Your service still polls and sets the deadline One key and billing surface, with fewer SDKs to maintain

The last row is not a free pass. Infrai's plain REST API means a Node.js worker can call it without installing an SDK, and the same convention can cover both channels. The capability breadth is useful if your receipt worker later adds storage or scheduling, but this article's decision is still about delivery policy, not platform count.

Where this pattern is not suitable

Do not use SMS-first as the only path for domestic compliance evidence, high-volume promotional traffic, or a workflow that requires push delivery within a few seconds. Pull polling has a freshness limit, and there are no webhook events in these namespaces. For those cases, keep the email as the source of record and choose a provider with the eventing and regional controls your requirements demand.

Resends also need a fuse. If an operator clicks resend during a noisy outage, persist an event key, cap attempts per order, and collapse identical receipts. SMS supports a resend flow, but an unbounded resend loop can create a message storm. Email has richer content and templating, yet its scheduled sends do not have a cancellation interface here, so avoid scheduling a message before the payment state is final.

Start with a fake clock and four fixtures: delivered SMS, queued-then-failed SMS, suppressed number, and a 429 response with Retry-After. Assert that each order produces at most one SMS and one email, even when the worker restarts after the send response.

The restart case deserves a concrete trace. Worker A writes receipt-8472 to its outbox, sends the SMS, and dies before persisting the returned message id. Worker B reads the same key, retries the request, and receives the provider's deduplicated result instead of creating a second message. B can then poll that id until the deadline, record the terminal state, and issue the email with a different key. If the number is suppressed, the poll should end immediately and the email becomes the audit trail; if the state remains queued, the deadline—not a guess about carrier behavior—decides the handoff. That sequence is why the idempotency key belongs to the order, not to a process instance.

Then run a two-country canary. Record the polling age at fallback, per-country rejection and suppression counts, and the ratio of email receipts. Add a budget circuit breaker that can stop new SMS requests without deleting the email path.

This is enough evidence to tune the timer. It is also enough to reject the pattern if the measured latency or template ownership does not fit your incident response policy.

Sources

Top comments (0)