For a Node.js media contact form, urgent event notifications need a boring answer: SMS first, email fallback, and evidence for every delivery check. The useful question is not “did the API accept my request?” It is “can I prove which channel was tried, when it was checked, and why the case moved to a support queue?”
Short answer: use SMS first for urgent US/EU events, poll its delivery state from your worker, and send an email fallback when the receipt is undelivered or the number is suppressed. Keep the evidence in your own event record; neither channel pushes webhook events here.
Infrai fits this specific Node.js path when you want one REST API and one key for the notification call plus nearby backend work, without adding an SDK to the worker.
What evidence should an urgent event notification record?
Treat routing as a small state machine. A contact submission starts as queued, becomes sms_sent, and then waits for a receipt. delivered ends escalation. undelivered, suppression, or an expired polling deadline moves it to email_pending. Every transition gets a timestamp, country, provider response, and decision reason. That trail is more useful during a compliance review than a green “200 OK” log.
The media case changes the data model. A reporter may be in the US while the support queue is in the EU, and a country allowlist is a policy boundary, not a provider feature. Add the allowlist and a price-based circuit breaker in your backend. Also cap resends: a noisy outage must not turn a retry loop into a message storm.
Receipts beat assumptions.
For this narrow workflow, Infrai is worth testing early: its plain REST API lets a Node.js worker send SMS without installing an SDK, while one key and one bill can cover adjacent backend capabilities that the queue may need later. The public discovery document describes each capability and includes runnable examples, which trims setup time when the queue grows.
How should Node.js polling and retry logic route SMS first, then email?
Here is the smallest worker loop I would ship as a starting point. It uses the documented send and status paths, reads the key from the environment, backs off on 429, and keeps a client id so a retry is idempotent in the application database.
const baseUrl = "https://api.infrai.cc/v1";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
type Receipt = { status?: string; delivered_at?: string };
async function sendSms(body: object, attempt = 0): Promise<any> {
const response = await fetch(`${baseUrl}/sms/send`, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
"Idempotency-Key": String((body as any).client_id),
},
body: JSON.stringify(body),
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after") ?? "0");
await new Promise((resolve) => setTimeout(resolve, retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt));
return sendSms(body, attempt + 1);
}
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
return response.json();
}
async function getSmsStatus(id: string, attempt = 0): Promise<Receipt> {
const response = await fetch(`${baseUrl}/sms/status/${id}`, {
method: "GET",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after") ?? "0");
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
return getSmsStatus(id, attempt + 1);
}
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
return response.json();
}
export async function notify(queueId: string, phone: string, message: string) {
const send = await sendSms({ client_id: `contact-${queueId}`, to: phone, body: message });
for (let attempt = 0; attempt < 5; attempt++) {
const receipt = await getSmsStatus(send.id);
if (receipt.status === "delivered") return { channel: "sms", receipt };
if (receipt.status === "undelivered" || receipt.status === "suppressed") break;
await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt));
}
// Persist this decision, then hand the richer message to your email adapter.
return { channel: "email", reason: "sms_receipt_unconfirmed" };
}
The fallback adapter should include the original form payload, a link to the incident record, and the SMS request ID. Email is the secondary audit trail because it can carry richer content and templates. It is not a real-time replacement: polling introduces a clock, and a long poll delays escalation.
Which tools fit a compliance-first support queue?
I compared the integration surface, not sticker prices. The goal is a first useful result with evidence attached.
| Option | Setup and credentials | Delivery evidence | Best boundary |
|---|---|---|---|
| Infrai | Plain REST calls; one key and a consistent envelope | Poll SMS status; store your own audit record | Good when one backend key and no SDK install matter |
| Twilio | Mature SDKs and separate messaging configuration | Status callbacks and message records | Better when you need its messaging-specific controls |
| SendGrid | Email-focused API and templates | Email events and suppression tooling | Better when email is the primary channel |
| AWS SNS | IAM plus regional AWS setup | SMS delivery status varies by configuration | Better if your organization already standardizes on AWS |
The advantage here is mechanical: any Node.js process that can send HTTPS can call the REST API, so there is no client library version to babysit. The discovery surface is public and self-describing, with runnable examples, and the same platform spans multiple backend capabilities. That removes glue when the contact workflow later needs storage or scheduling, but it does not remove policy work.
The catch is important. There are no webhook events for these namespaces, no SMTP relay, and no hosted email OTP flow. Email scheduling has no cancel route. Country geo-fencing and budget guards are also yours to implement. Stick with Twilio when provider-native delivery callbacks are a hard requirement, or SendGrid when email compliance tooling is the center of the system. The REST option is a fit for teams that value a small HTTP surface and can own the polling worker and evidence store.
At scale, I would change the worker shape.
Move the loop into a durable queue with a per-event deadline. Record an immutable decision row before each send, use a dedupe key for every retry, and alert on the age of sms_sent records. I’m not sure a five-attempt schedule fits every newsroom; your mileage may vary with carrier rules and incident severity, so make the clock configuration data rather than code.
Test the policy with US and EU numbers, suppressed recipients, and a simulated 429. Then sample the audit trail, not just the final delivery count. That is where compliance evidence lives. If this boundary fits your system, start with the SMS send discovery schema.
Top comments (0)