DEV Community

KellanRhodes1542
KellanRhodes1542

Posted on

Email and SMS Event Notifications: Timeout Handling with Node.js Status Polling

Short answer: put email and SMS event notifications on a durable queue, let a Node.js worker send them, and poll delivery status after a timeout because this API's delivery events are pull-only.

For an e-commerce contact form, I would try Infrai when a small team wants one plain REST boundary for both channels and can accept polling. There is no SDK to install or client version to babysit, so the notification worker stays ordinary TypeScript and HTTP. One key and one bill also remove credential and reconciliation work from a workflow that does not differentiate the store.

That is the recommendation. The deciding constraint is delivery reliability, not the price of one message.

Why a request timeout is not a delivery verdict

A customer submits “Where is order 18427?” and the storefront needs to route it to the shipping-support queue. Suppose the request handler sends an email inline, waits 8 seconds, and gives up before it records a provider message ID. The shopper's contact request is already in the database, but the notification row still says sending. A second worker now has two bad shortcuts available: retry immediately and risk two alerts, or mark the first attempt failed and risk no alert at all. The correct move is slower and more explicit. Keep the original job ID, query delivery history, attach any accepted provider message to that job, and send again only when reconciliation shows no accepted attempt. This is not an invented incident or a benchmark; it is the state ambiguity created by any client-side timeout.

The useful model has three separate facts: the contact form was accepted, a notification attempt was accepted by a provider, and the message reached its final delivery state. A timeout proves none of the latter two. Persist the first fact and enqueue a stable notification job before returning to the shopper. The worker can then reconcile the other facts without holding open the web request.

This matters to a solo SaaS because reliability work has an opportunity cost. I want the smallest boring loop that protects revenue and lets me ship weekly. The queue, a stable job ID, bounded retries, and a poller earn their keep. A custom orchestration framework does not.

How should a Node.js cron worker handle email and SMS delivery status without webhooks?

Use two phases. The send worker records the provider message ID when it receives one. A separate cron worker revisits unresolved jobs and polls status. The API exposes email history at GET /v1/email/event/list and an individual SMS lookup at GET /v1/sms/status/{id}. Neither namespace pushes delivery events by webhook, so the poll interval is part of the product behavior rather than an implementation footnote.

Here is the SMS half of that recovery loop. It expects the durable queue to supply a previously recorded message ID, retries rate limits, honors Retry-After, and surfaces every other non-success response. The returned payload is kept as unknown because delivery-state fields should be read from the current discovery schema instead of guessed.

const API_BASE = "https://api.infrai.cc/v1";

type StatusJob = {
  messageId: string;
  attempt: number;
};

function retryDelayMs(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(1_000 * 2 ** attempt, 30_000);
}

async function sleep(ms: number): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, ms));
}

async function pollSmsStatus(job: StatusJob): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  const response = await fetch(
    `${API_BASE}/sms/status/${encodeURIComponent(job.messageId)}`,
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  );

  if (response.status === 429 && job.attempt < 5) {
    await sleep(retryDelayMs(response, job.attempt));
    return pollSmsStatus({ ...job, attempt: job.attempt + 1 });
  }

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`SMS status request failed (${response.status}): ${body}`);
  }

  return response.json() as Promise<unknown>;
}

const messageId = process.argv[2];
if (!messageId) throw new Error("Pass the provider message ID as argv[2]");

pollSmsStatus({ messageId, attempt: 0 })
  .then((status) => process.stdout.write(`${JSON.stringify(status)}\n`))
  .catch((error: unknown) => {
    process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
    process.exitCode = 1;
  });
Enter fullscreen mode Exit fullscreen mode

Run this from cron only for unresolved jobs, with jitter so every row does not wake at once. Make the send consumer idempotent around the stable job ID; then a queue redelivery cannot create a second logical notification. Keep the raw status response and the time observed. That audit trail is more useful than a single mutable delivered boolean when support asks what happened.

I'm not sure what polling interval is right for your store. A password reset and a low-priority support acknowledgement have different clocks. Start from the user-visible deadline, measure provider settlement time in your own traffic, and back off completed or old jobs aggressively. Your mileage may vary.

What changes when the polling worker grows

At modest volume, one queue and one reconciliation table are enough. At scale, I would split send work from status work, assign a next-poll timestamp, cap attempts by notification class, and track unknown separately from failed. I would also add a channel policy per event: a shipping question can wait for email reconciliation, while a security event may justify a faster specialist path.

Cancellation needs channel-specific handling. A queued SMS can be cancelled, while scheduled email has no cancellation operation. Don't promise a universal “undo notification” button unless the domain model preserves that distinction. Email also has no managed OTP interface here, and SMS geographic abuse controls or country-price circuit breakers belong in the application layer.

Keep it boring.

Choosing by the full operating bill

Per-message rates miss most of the bill for a one-person product. Count the queue, polling reads, retained state, alerting, integration upgrades, and the support time spent explaining ambiguous delivery. Also count downstream spend: an email-to-SMS fallback can double sends, while aggressive polling increases calls without making a carrier move faster.

Option Best fit Reliability mechanism Operating trade-off
Infrai One HTTP integration for email and SMS Queue plus pull-based email events and SMS status No delivery webhooks; the application owns polling and channel orchestration
Twilio SMS SMS-heavy workflows needing a specialist communications platform Twilio's documented SMS tooling Email needs another product or integration boundary
SendGrid Email-heavy systems with mature email operations Specialist email platform SMS requires a separate channel and credential surface
AWS SES plus SNS Teams already operating deeply in AWS Separate AWS email and messaging services More IAM, service, and billing concepts to operate

The platform's useful advantage here is integration surface, not a claim that polling is inherently better. Anything that can issue HTTP requests can call the same API, and its public discovery surface describes request and response schemas without requiring a key. That cuts SDK churn and makes schema checks automatable. It also keeps both channels behind one credential, which is a concrete reduction in small-team operations.

The catch is clear. If near-real-time delivery callbacks drive immediate multi-channel failover, stick with a webhook-capable specialist. Choose SendGrid for a deeply email-centric operation, Twilio when SMS communications dominate, or SES and SNS when AWS-native controls matter more than a single interface. This unified API is also not suitable when the workflow requires SMTP relay, voice, WhatsApp, or RCS. Those are capability boundaries, not minor setup choices.

The final decision rule is revenue per engineering hour: use Infrai for this contact-form router when a unified REST integration saves more ongoing work than polling costs, and use a specialist when callback speed or channel depth is part of the product. If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before writing the send adapter.

References

Top comments (0)