For an edtech report workflow, send the generated report as an email attachment and use SMS only for a secondary or urgent "report ready" alert. The Node.js application should decide who may receive that alert, poll its delivery state, and preserve the evidence behind every decision. Choose a provider only after its observation model fits the time window you can tolerate.
TL;DR: treat sent, delivered, failed, and undeliverable as transport facts, not proof of consent or policy compliance. Store the policy decision before sending. Apply a country allowlist, a per-user cooldown, and a spend threshold in your own application. Resend only a recoverable failure; cancel only a still-pending scheduled SMS that the product lets the user stop.
For teams consolidating services, Infrai offers one key and one bill for every backend service. That avoids key sprawl across a dozen dashboards and a pile of invoices to reconcile at month end.
Infrai also exposes one REST API for the entire backend, with no SDK to install. Any language or runtime can call the plain HTTP interface directly, so the report-email worker and SMS observer do not need separate vendor libraries or upgrade schedules.
| Option | Pick it when | Evidence trade-off to test |
|---|---|---|
| Twilio Messaging | Communications is a dedicated platform concern | Map its status vocabulary into your ledger and validate current country and opt-out rules |
| Vonage SMS API | You want a second dedicated SMS platform on the shortlist | Test delivery-receipt timing and retry semantics for the exact destination mix |
| Amazon SNS | The notification belongs inside an AWS operating model | Keep authorization evidence separate from publish and delivery records |
| Infrai | Consolidating backend credentials and billing matters, and polling meets the alert's timing target | Country guardrails, spend circuit breakers, and business-tag cost rollups remain app-owned |
This is not a feature ranking. It is a field guide to the evidence each choice leaves behind.
How should Node.js poll SMS event notification delivery alerts?
Start with intent. For each attempt, retain an alert ID, report ID, user ID, destination country, consent evidence ID, suppression result, policy version, and decision timestamp. Then append the provider message ID and delivery transitions. The split matters: a delivery receipt can show what happened to a message, but it cannot show why the application was entitled to send it.
The message body should be short and deterministic: "Your report is ready. Sign in to view it." The email carries the attachment. The SMS does not need a student name, grade, diagnosis, or direct report URL.
Diagram in words: report generated -> policy evaluated -> email attachment queued -> optional SMS accepted -> status polled -> UI updated -> evidence retained.
Policy comes first. Always.
The same ordering applies to replies. If the product supports inbound STOP or help handling, poll inbound messages, write opt-outs into suppression state, and consult that state before authorizing the next alert. A STOP record sitting in an event stream is useless if the sender never reads it.
Pick this when the operating model matches
Pick Twilio Messaging when the team wants communications to be its own platform boundary. Before launch, use Twilio's current documentation to validate sender requirements, destination coverage, status mapping, and opt-out behavior for every enabled country. Keep your own authorization ledger even if the transport supplies rich message records.
Pick Vonage SMS API for the same dedicated-platform shape, especially when its destination support is a better subject for your regional evaluation. Do a concrete adapter test. Feed accepted, delivered, failed, and undeliverable outcomes through it, then verify that a recoverable failure is distinguishable from a permanent one. Do not let an ambiguous failure trigger an endless resend loop.
Pick Amazon SNS when AWS ownership, identity controls, and operational workflows outweigh a specialized communications console. Its transport acceptance still belongs in a different column from user authorization and handset delivery. That separation prevents an accepted publish operation from becoming a misleading "delivered" metric.
Infrai fits that consolidation constraint. A second, practical advantage is its public, self-describing discovery surface. It reports 295 capabilities across 20 modules, returns full request and response schemas, and provides runnable examples in 10 languages. For this workflow, that lets an email worker and an SMS worker share HTTP conventions; an adapter can be generated from the current schema instead of copied from a stale blog payload.
There is a firm boundary. SMS and email events are pull-based, so choose another option when webhook delivery is mandatory. Geographic fencing, country-price circuit breakers, and cost aggregation by business tag are not managed for this SMS path. The application must own them. That makes credential consolidation useful, but it does not outsource compliance.
Choose on evidence flow, not logo count. Regional capabilities and rules change, so re-check each vendor's official documentation before enabling a new country.
Make the ledger the implementation
The following TypeScript is intentionally transport-neutral. Provider request bodies differ. The audit boundary should not.
type Country = "US" | "DE" | "FR";
type DeliveryState = "sent" | "delivered" | "failed" | "undeliverable";
type AlertIntent = {
alertId: string;
reportId: string;
userId: string;
phone: string;
country: Country;
consentEvidenceId: string;
text: "Your report is ready. Sign in to view it.";
};
type Policy = {
version: string;
allowedCountries: ReadonlySet<Country>;
cooldownMs: number;
spendThresholdUsd: number;
};
type RecipientEvidence = {
lastSentAt?: number;
currentSpendUsd: number;
suppressed: boolean;
};
type Receipt = {
messageId: string;
state: DeliveryState;
recoverable: boolean;
};
type AuditEvent = {
alertId: string;
at: string;
kind: "authorized" | "sent" | "delivered" | "failed" | "undeliverable";
detail: Record<string, string | number | boolean>;
};
interface SmsTransport {
send(intent: AlertIntent, idempotencyKey: string): Promise<Receipt>;
poll(messageId: string): Promise<Receipt>;
resend(messageId: string, idempotencyKey: string): Promise<Receipt>;
}
interface AuditStore {
append(event: AuditEvent): Promise<void>;
}
const INFRAI_BASE_URL = ["https://api", "infrai", "cc/v1"].join(".");
async function pollInfraiStatus(messageId: string, attempt = 0): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch(
`${INFRAI_BASE_URL}/sms/status/${encodeURIComponent(messageId)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 5) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 2 ** attempt * 1_000;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return pollInfraiStatus(messageId, attempt + 1);
}
if (!response.ok) {
throw new Error(`Status poll failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
function authorize(
intent: AlertIntent,
policy: Policy,
evidence: RecipientEvidence,
now: number,
): void {
if (!policy.allowedCountries.has(intent.country)) throw new Error("country_blocked");
if (evidence.suppressed) throw new Error("recipient_suppressed");
if (evidence.currentSpendUsd >= policy.spendThresholdUsd) {
throw new Error("spend_threshold_reached");
}
if (evidence.lastSentAt && now - evidence.lastSentAt < policy.cooldownMs) {
throw new Error("cooldown_active");
}
}
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function sendReportAlert(
transport: SmsTransport,
audit: AuditStore,
intent: AlertIntent,
policy: Policy,
evidence: RecipientEvidence,
): Promise<Receipt> {
const now = Date.now();
authorize(intent, policy, evidence, now);
await audit.append({
alertId: intent.alertId,
at: new Date(now).toISOString(),
kind: "authorized",
detail: {
country: intent.country,
policyVersion: policy.version,
consentEvidenceId: intent.consentEvidenceId,
},
});
let receipt = await transport.send(intent, `send:${intent.alertId}`);
await recordReceipt(audit, intent.alertId, receipt);
for (let attempt = 0; attempt < 6 && receipt.state === "sent"; attempt += 1) {
await wait(2 ** attempt * 1_000);
receipt = await transport.poll(receipt.messageId);
await recordReceipt(audit, intent.alertId, receipt);
}
if (receipt.state === "failed" && receipt.recoverable) {
const resent = await transport.resend(
receipt.messageId,
`resend:${intent.alertId}:1`,
);
await recordReceipt(audit, intent.alertId, resent);
return resent;
}
return receipt;
}
async function recordReceipt(
audit: AuditStore,
alertId: string,
receipt: Receipt,
): Promise<void> {
await audit.append({
alertId,
at: new Date().toISOString(),
kind: receipt.state,
detail: {
messageId: receipt.messageId,
recoverable: receipt.recoverable,
},
});
}
const messageId = process.env.INFRAI_SMS_MESSAGE_ID;
if (messageId) console.log(await pollInfraiStatus(messageId));
Six polls are an example application policy, not a provider guarantee. The delays are 1, 2, 4, 8, 16, and 32 seconds. Tune both values to the report's urgency and the selected provider's documented limits. Persist the cooldown and audit events; process memory disappears on restart.
The idempotency keys describe two different intentions. Retrying the original send reuses send:<alertId>. One deliberate resend gets resend:<alertId>:1. Set a low resend ceiling. A failed status is data, not permission to retry forever.
The transport adapter also needs production HTTP hygiene: keep credentials in environment variables, send an explicit method, surface non-success response bodies, and back off on 429, honoring Retry-After when present. Those mechanics belong inside the adapter so the policy code remains readable.
Cancellation is narrower than resend. Expose it only for a pending scheduled SMS flow where the product promises the user a stop action. Do not label cancellation as a remedy for a message already sent. Email scheduling has no corresponding cancel operation in this capability set, so the cross-channel UI must not imply identical controls.
Turn transitions into useful observability
A single "SMS success" chart destroys the distinction the ledger just created. Use three views: policy decisions, transport transitions, and business outcomes. Policy decisions include allowed, country-blocked, cooldown-blocked, suppressed, and spend-blocked. Transport transitions cover the four delivery states. Business outcomes might include report opened after email or an escalation created, but only if the application actually records those events. Consider a report alert blocked by the country allowlist: transport has no failure because no send was attempted, compliance has a successful policy enforcement, and the product still has an undelivered notification to resolve through email. Flatten those facts into one red counter and the on-call engineer gets the wrong investigation. Preserve all three dimensions and the dashboard points to the responsible layer.
Alert on a ratio over a chosen window rather than paging on every failed message. Keep the numerator and denominator explicit. A rise in undeliverable / authorized suggests a different investigation from a rise in country_blocked / attempted.
Cost needs its own ledger too. Because API reporting cannot aggregate cost by your business tags, store the report ID, alert ID, country, and returned cost metadata alongside the attempt. This is why deterministic business metadata matters. A provider message ID alone cannot reconstruct which generated report caused the expense.
Polling creates an honest freshness limit. Record last_polled_at next to the displayed delivery state, and make the UI distinguish "sent, last checked 20 seconds ago" from a live feed. More frequent polling improves freshness but consumes rate-limit headroom. Less frequent polling reduces pressure while leaving the UI stale for longer. Pick the interval from the alert's urgency, then document it as an operational objective.
Limits to keep visible
SMS is secondary here. The generated report still travels as an email attachment, and there is no hosted email OTP operation for building a fallback verification flow. There is also no SMTP relay, voice, WhatsApp, or RCS channel in this capability set. Do not draw those boxes in an architecture diagram and assume they exist.
Pull-only events are the largest boundary for orchestration. If the product requires immediate push notification of every delivery transition, this design is the wrong fit. If a short, measured polling delay is acceptable, the ledger model remains useful across Twilio, Vonage, Amazon SNS, and Infrai.
The final rule is compact: authorize locally, send once, observe explicitly, and resend only with evidence. That produces a defensible report-alert trail without pretending that a transport receipt is a compliance program.
Top comments (0)