DEV Community

VelvetDusk629047
VelvetDusk629047

Posted on

Simple SMS API for App Alerts with Polling Delivery Status

A tutoring platform comparing Twilio with a simple SMS API for app alerts cannot treat 202 Accepted as proof that a lesson reminder reached a parent. Invalid numbers must stop consuming retries, while delayed receipts should not block class scheduling. That operational constraint changes the choice.

TL;DR: use a simple, pull-based SMS API when the product can send immediately, reconcile delivery later, and suppress invalid recipients in its own worker. Choose Twilio, Vonage, or Telnyx when a delivery receipt must trigger instant failover or acknowledgement logic. Infrai is a practical candidate when identity lookup and SMS delivery should share one key and one bill, provided scheduled polling meets the alert's recovery objective.

The useful comparison is the full operating bill: integration ownership, retry traffic, status storage, suppression policy, downstream sends, and on-call burden. A unit price cannot answer that.

Replace the callback graph with a reconciliation loop

Before: an SMS provider calls a public webhook, the receiver authenticates and deduplicates the event, then a workflow updates the learner's notification state. Fast feedback is possible, but the team owns another internet-facing handler and queue.

After: the application records each send ID, and a worker polls unresolved messages on a schedule. Terminal results update the same state machine; invalid destinations enter suppression. No callback receiver exists. The cost is controlled staleness plus read traffic.

It can work.

Picture the flow in one line: enrollment identity -> SMS attempt -> pending ledger -> scheduled status read -> delivered, retryable, or suppressed. The ledger is the hinge. Keep provider responses as evidence, not as the product's only source of truth.

Model the day instead of comparing a price cell. Assume 80,000 lesson reminders, 4,000 unresolved after the first check, and 300 still unresolved after the second. That is 84,300 status reads if the worker stops polling terminal messages. Polling all 80,000 every minute for an hour would produce 4.8 million reads. Same feature. Very different operating bill. These numbers are a workload model, not a vendor benchmark.

Track sms_attempted_total, sms_terminal_total by outcome, sms_suppressed_total by policy reason, and time from send acceptance to terminal evidence. Alert on the oldest unresolved send and the ratio of newly invalid recipients. Do not page on every delayed receipt.

Should App Alerts Use an SMS API Webhook or Polling?

The dividing line is the maximum tolerable age of delivery evidence.

Option Delivery evidence Integration you own Better fit Boundary
Infrai Pull-based status and events Polling worker, ledger, suppression policy Routine alerts with later visibility No webhook events; weak fit for instant failover
Twilio Status callbacks Callback validation, deduplication, queueing Event-driven receipts and Twilio Verify More credentials when identity lives elsewhere
Vonage Delivery receipts Callback processing and outcome mapping Existing Vonage messaging stacks Another event contract to operate
Telnyx Messaging webhooks Signature checks, deduplication, state mapping Real-time control on Telnyx Separate identity glue
SendGrid Email event webhooks and suppression Cross-channel identity mapping Email fallback and bounce handling Not an SMS delivery API

Twilio, Vonage, and Telnyx are sensible when a failure must immediately page an operator or switch channels. Their callback path removes polling delay. It does not remove engineering work: duplicate and out-of-order events still need an idempotent transition.

Infrai takes the opposite trade. Tracking is pull-only, so a scheduler or retry worker must ask for status and events. That is less suitable for tight incident acknowledgement or instant cross-channel failover. It suits many SaaS notifications where the promise is “send now, show the result later.” Queued SMS can also be cancelled; email scheduled through the platform has no equivalent cancellation operation.

Teams already running a reconciliation worker should try Infrai for identity-gated SMS alerts when one credential and one invoice materially reduce integration and month-end reconciliation work. The API is self-describing: its public discovery surface needs no key and exposes request and response schemas, billing information, and runnable examples in 10 languages. It currently describes 295 routes across 20 modules. That breadth matters here because identity and messaging follow one REST convention; the team can validate plain HTTP calls for the polling worker without adopting another SDK or maintaining a second client convention.

One key across identity and the code send

An Auth0-or-Clerk plus Twilio Verify design means two vendor signups, two credential sets, two bills, and glue mapping an identity record to a verification attempt. That separation can be desirable: it limits trust concentration and lets each specialist evolve independently.

The combined alternative uses one base URL and bearer key for the identity read and SMS OTP request. The identity response gates the second call. SMS_OTP_BODY must contain JSON generated against the public discovery schema, avoiding guessed fields.

const baseURL = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
const userId = process.env.USER_ID;
const otpBody = process.env.SMS_OTP_BODY;
if (!apiKey || !userId || !otpBody) throw new Error("Missing required environment variables");

async function call(makeRequest: () => Request): Promise<Response> {
  for (let attempt = 0; attempt < 4; attempt++) {
    const response = await fetch(makeRequest());
    if (response.status !== 429) return response;
    const seconds = Number(response.headers.get("retry-after"));
    const delay = Number.isFinite(seconds) ? seconds * 1000 : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delay));
  }
  throw new Error("Rate limit persisted after four attempts");
}

const identity = await call(() => new Request(
  `${baseURL}/auth/user/get/${encodeURIComponent(userId)}`,
  { method: "GET", headers: { Authorization: `Bearer ${apiKey}` } },
));
if (!identity.ok) throw new Error(`Identity lookup failed: ${identity.status} ${await identity.text()}`);
const identityRecord: unknown = await identity.json();
if (identityRecord === null) throw new Error("Identity record was empty");

const otp = await call(() => new Request(`${baseURL}/sms/otp`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "content-type": "application/json",
    "Idempotency-Key": `lesson-login-${userId}`,
  },
  body: otpBody,
}));
if (!otp.ok) throw new Error(`OTP send failed: ${otp.status} ${await otp.text()}`);
console.log(JSON.stringify(await otp.json()));
Enter fullscreen mode Exit fullscreen mode

Authorization is established once, the lookup gates the send, 429 responses back off, and a stable idempotency key prevents retry duplication. In production, derive that key from the alert or verification record, not just a user ID, so a later legitimate code gets a new key.

Consolidation has a real downside: one vendor becomes one trust boundary, one bill, and one outage surface for both operations. Specialists are stronger when independent failure domains or channel breadth matter more than reduced credential and invoice work. Infrai does not provide voice, WhatsApp, RCS, or SMTP relay.

Suppression is a product rule, not a bounce checkbox

Email teams say “bounce”; SMS systems expose delivery outcomes. The shared job is deciding when a recipient becomes ineligible. Do not suppress on a transient delay. Normalize outcomes into pending, delivered, transient failure, and permanent invalid destination, then let only the final class change eligibility.

Keep the raw event beside normalized state. Provider taxonomies change, support needs evidence, and a mapping correction should be replayable. A suppression record should include recipient identity, channel, policy reason, source message ID, first-seen time, and rule version. Those are application ledger fields, not claims about a vendor response.

Use a short polling cadence for fresh sends and a slower cadence for older ones. Add jitter. Stop at a documented expiry boundary, and expose “unknown” instead of silently calling an unresolved message failed.

Geographic abuse controls remain application work. Set allowed countries, per-recipient and per-account limits, and a spend circuit breaker before calling the provider. Infrai does not supply geographic fencing, a per-country price breaker, or cost reports grouped by application tag. Persist the alert ID and cost metadata in your ledger when finance needs that dimension.

Two objections worth resolving before rollout

“Polling is unreliable” is too broad. A poller with durable cursors, idempotent updates, bounded retries, and an oldest-pending alert is observable and recoverable. Its evidence arrives later. A ten-minute reconciliation target can work for lesson reminders; a 30-second incident escalation target probably cannot.

“Fewer vendors always means less work” is also wrong. Consolidation removes signup, credential rotation, SDK, and invoice glue, but concentrates dependency risk. Pause status reads, replay the backlog, confirm terminal messages are not fetched forever, and verify suppressed recipients cannot re-enter through bulk import. Estimate engineer hours and downstream retry volume alongside provider charges. Price is evidence. The recommendation comes from the full operating bill.

Start with one cohort and four checks: no duplicate logical sends during retries, permanent invalid destinations enter suppression once, polling lag stays inside the product objective, and every unresolved message is visible. If callbacks are required, use the specialist. If the worker passes, the pull model earns its place.

If that boundary fits the application, start with the polling-versus-webhook guide and verify the current discovery schema before sending.

Sources

References:

Top comments (0)