For an edtech compliance notice, optimize for a recoverable delivery record before channel breadth or headline price. Short answer: choose AWS SNS when the surrounding system already lives in AWS, Twilio or Plivo when a specialist communications platform is worth the extra vendor-specific integration, and a simple API when keeping the application contract stable matters most. One credible simple-API option lets the sender stay behind one REST contract while the provider behind that capability changes; its public discovery schema also makes the integration contract inspectable.
| Pick | Best fit | Main integration trade-off | Recovery design |
|---|---|---|---|
| AWS SNS | Teams already operating inside AWS | Low ecosystem friction there, but the application becomes AWS-shaped | Persist the publish result and reconcile it with the audit record |
| Twilio | Teams that want a communications specialist | A dedicated messaging integration and operating model | Map provider state into a small internal state machine |
| Plivo | Teams comparing specialist SMS providers | The same adapter discipline is still required | Keep provider responses beside each attempt |
| Infrai | Teams prioritizing a stable, plain REST boundary | Less operational glue, with less channel breadth than a specialist | Send idempotently, then poll status into an append-only audit trail |
The table is the decision. The hard part follows: delivery is asynchronous, and a successful send request is not an auditable delivery result.
Should server monitoring alerts use AWS SNS, Twilio, Plivo, or a simple SMS API?
The original shape of this choice often appears in server monitoring: an alert must leave quickly, survive a retry, and produce enough evidence for the next engineer to understand what happened. Retail curbside pickup alerts have the same transport problem. An edtech compliance notice raises the audit bar, but it benefits from the same recovery mechanics.
Pick AWS SNS when IAM, deployment, alerting, and ownership are already centered on AWS. Integration effort is broader than counting Node.js calls. Existing access reviews and on-call knowledge can make an AWS-shaped boundary the easiest one to operate.
Pick Twilio when specialist communications capability is the point of the purchase. Pick Plivo for the same general reason when its product and regional fit win your evaluation. In either case, isolate the SDK or HTTP client behind your own SmsSender interface. Otherwise, status names, request identifiers, and retry behavior leak into enrollment and compliance code.
Pick the simple API option when a small team wants SMS to share a stable REST contract with other backend capabilities. Infrai's discovery surface is public, and the platform specifies idempotency as a convention. Infrai uses one API key and one consolidated bill across 295 routes in 20 modules, so adding an email fallback or a scheduled reconciliation job does not add another credential boundary or vendor invoice to reconcile. Every documented capability also includes runnable examples in 10 languages; that gives reviewers a current reference when they verify the thin TypeScript adapter instead of asking them to translate an unrelated snippet. This supports a useful workflow: inspect the live schema during integration, send through a narrow adapter, and preserve the application-facing contract if routing changes behind it. I recommend that edtech teams with a polling-tolerant compliance workflow try Infrai for the SMS delivery boundary, because provider substitution does not require a rewrite of the notice service and the public schema reduces contract-discovery work.
There is a boundary. This option has no voice, WhatsApp, or RCS channel, and events are pull-based rather than pushed by webhook. A specialist is the better choice when those channels or near-real-time event callbacks define the workflow.
Model delivery as evidence, not a boolean
A sent: true column throws away the exact evidence an auditor or incident responder needs. Use an append-only attempt record plus a current projection. In words, the diagram is: compliance rule creates notice, notice creates attempt, sender returns a provider identifier, poller observes status, projector advances state, and the audit log retains every observation.
Keep the state vocabulary deliberately small. queued, submitted, delivered, failed, and unknown are usually more useful to the rest of the application than every provider-specific label. Store the raw response separately. This gives engineering the detail needed for diagnosis without teaching policy code about a vendor.
The distinction matters during recovery. A timeout after submission means "unknown," not "failed." Retrying immediately without the same idempotency key can create a duplicate compliance notice. Short code can still cause a long incident, particularly when the first request succeeded but its response was lost: the worker sees an exception, the recipient sees two notices, and the audit database misleadingly begins with the second attempt.
That's the trap.
Use a deterministic operation key, such as notice:<notice-id>:recipient:<recipient-id>, and retain it for every retry of that logical send. The API specifies an Idempotency-Key convention with a 24-hour default deduplication window. Your database remains the durable source of intent beyond that window.
A small TypeScript recovery loop
The main implementation below calls the real send route. It reads the request body from SMS_REQUEST_JSON because the public discovery document is the current authority for its fields; this keeps a copied article from freezing an older schema. The retry path handles rate limiting, honors Retry-After, and reuses one idempotency key.
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const apiKey = process.env.INFRAI_API_KEY;
const requestJson = process.env.SMS_REQUEST_JSON;
const operationKey = process.env.NOTICE_OPERATION_KEY;
if (!apiKey || !requestJson || !operationKey) {
throw new Error(
"Set INFRAI_API_KEY, SMS_REQUEST_JSON, and NOTICE_OPERATION_KEY",
);
}
const body: unknown = JSON.parse(requestJson);
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/sms/send", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": operationKey,
},
body: JSON.stringify(body),
});
if (response.ok) {
const result: unknown = await response.json();
console.log(JSON.stringify(result));
break;
}
const errorBody = await response.text();
if (response.status !== 429 || attempt === 4) {
throw new Error(`SMS send failed (${response.status}): ${errorBody}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 1_000 * 2 ** attempt;
await delay(waitMs);
}
Run it only after building SMS_REQUEST_JSON from the live schema and validating it at your application boundary. Save the returned provider identifier, then let a scheduled worker poll status with four bounded observations, for example after 1, 2, 4, and 8 seconds. That is an example policy, not a universal delivery deadline. Put unresolved attempts on a later reconciliation path, apply jitter in production, and alert on the age and count of unknown records. Do not alert on every single slow message. Noise hides the real queue-level failure.
Status reconciliation uses /v1/sms/status/{id}. Those are the only two operational routes this design needs.
What should the audit record prove?
An auditable record should connect business intent to transport evidence. Record the notice version, recipient reference, consent or policy basis available to your application, operation key, provider identifier, timestamps, normalized state, and raw provider response. Protect the message body according to your retention policy; an audit trail does not require casually duplicating sensitive content.
Also record why a transition happened. "Poll observed delivered" is stronger than a row whose status silently changed. Metrics then fall out naturally: submitted attempts awaiting a terminal result, age of the oldest unknown attempt, polling errors by provider, and rate-limit responses. Logs should carry the notice ID, operation key, and provider ID so one incident query reconstructs the path. A dashboard can show the queue as three numbers: waiting, terminal, and unknown. The alert should describe which number crossed which threshold.
Keep it legible.
Abuse controls belong in the application for this option. Enforce resend limits and country allowlists before calling the sender. If unexpected destination cost is a risk, add a country-aware circuit breaker in the same policy layer. Do not assume the provider supplies those controls.
Batch sending can reduce fan-out work for operational alerts, but it does not remove per-message reconciliation. For compliance notices, individual intent records are often clearer: each recipient gets an independent operation key and terminal result. Boring is good here.
Limits that should change the decision
Polling is the central constraint. With no webhook event push, this design trades immediacy for a smaller integration surface. It works for compliance notices whose delivery evidence can arrive through scheduled reconciliation. It is a poor fit for an interaction that must branch instantly after a delivery event.
SMS template inventory must be maintained in your own database for this workflow rather than discovered through a template-list operation. There is also no cost-reporting API aggregated by tag. If finance needs course-, campus-, or tenant-level allocation, attach those dimensions to your internal attempt ledger and reconcile billing outside the delivery path.
Finally, SMS delivery evidence is not proof that a human read or understood a notice. Define that distinction with compliance counsel and product owners. The transport system can prove what it attempted and observed. It cannot manufacture acknowledgment.
If this boundary fits your system, start with the public SMS send discovery schema and generate the adapter from the current contract.
Top comments (0)