Short answer: for an edtech SaaS app serving the US and EU, choose an SMS alerts API only after proving that its polling responses can drive an explicit delivery state machine and a durable recipient-suppression list; a quick first send is less important than never retrying a terminally invalid number.
The evaluation constraint matters. A provider can make a notebook demo look finished after one accepted request, while the real job continues until the application observes a final delivery result. The simple approach is to store sent = true and poll every unfinished message forever. The better approach stores the provider message ID, schedules bounded polls, maps provider-specific labels into a small internal vocabulary, and suppresses a recipient only when an outcome is explicitly terminal.
This is a reliability experiment, not a feature-page comparison. Its pass condition is that a simulated invalid guardian number stops receiving new transactional notifications while a delayed-but-valid number remains eligible. No webhook is required.
What does delivery reliability mean for transactional SMS alerts?
An accepted API request proves very little about handset delivery. Treat submission and delivery as separate transitions: the application first hands off a message, then learns what happened through later status reads. That distinction prevents an edtech workflow from marking an attendance alert complete merely because the initial request was accepted. Use a compact internal state model. queued and sent are nonterminal; delivered, invalid_recipient, expired, and undeliverable are terminal. Provider labels belong in an adapter, not in business logic. The application should also preserve the raw label and observation time for diagnosis, because collapsing detail at ingestion makes later evaluation much harder. The key policy is narrow: suppress a number after a terminal result that says the recipient is invalid, not after every failed poll or every undelivered message. A timeout while reading status says something about the observation attempt, not the phone number. An expiry may reflect timing or reachability. Those cases deserve finite retry schedules and an operations queue, but they don't establish that the recipient is invalid.
This is the trap.
Submission isn't delivery.
For a school product, the suppression record should be scoped carefully. A normalized destination can be blocked for SMS while the guardian account remains active for in-app or email notices. Store a reason, source message ID, and timestamp; make the decision auditable; and require an explicit recipient update or verified administrative action before clearing it. The application owns that policy even if a provider offers its own suppression feature.
Authentication messages need a separate threat review. NIST SP 800-63B describes the public switched telephone network as a restricted authenticator channel and calls for risk indicators such as device swap or number porting when PSTN delivery is used. That does not make SMS unusable for an ordinary class-cancellation alert. It does mean a team shouldn't quietly reuse the same pipeline as the sole control for high-risk authentication.
How should a SaaS app poll SMS delivery status without webhooks?
Start with two loops that have different responsibilities. The send loop writes a local notification row before submission, uses a stable application idempotency key where the selected API supports one, and records the returned message ID. The poll loop claims due rows, asks the adapter for current status, writes an observation, and computes the next action. Keeping those steps separate makes retries testable and prevents an HTTP handler from becoming a miniature workflow engine.
Polling intervals should be a policy rather than scattered sleep calls. For example, an evaluation can try 15 seconds, 60 seconds, 5 minutes, and 15 minutes, then stop after its chosen observation window. Those values are test inputs, not a claim about carrier timing. A production schedule should come from measured delivery-latency distributions, the urgency of the alert, API limits, and the provider's documented status-retention window.
Don't let two workers poll the same row. A database claim with a lease, or a queue whose visibility timeout exceeds the status-read deadline, is enough for most systems. Updates should be monotonic: once a message reaches an internal terminal state, a late stale observation must not move it back to sent. Add jitter when many notices are submitted together, because a class-wide announcement can otherwise turn into a synchronized burst of status reads.
The focused part is the decision function. It has no network calls, so a notebook can exercise the exact policy later deployed by a worker:
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
class DeliveryState(str, Enum):
QUEUED = "queued"
SENT = "sent"
DELIVERED = "delivered"
INVALID_RECIPIENT = "invalid_recipient"
EXPIRED = "expired"
UNDELIVERABLE = "undeliverable"
TERMINAL = {
DeliveryState.DELIVERED,
DeliveryState.INVALID_RECIPIENT,
DeliveryState.EXPIRED,
DeliveryState.UNDELIVERABLE,
}
@dataclass(frozen=True)
class PollDecision:
next_poll_at: datetime | None
suppress_sms: bool
needs_review: bool
def decide(
state: DeliveryState,
attempt: int,
observed_at: datetime,
) -> PollDecision:
if state is DeliveryState.INVALID_RECIPIENT:
return PollDecision(None, suppress_sms=True, needs_review=False)
if state in TERMINAL:
return PollDecision(None, suppress_sms=False, needs_review=True)
delays = (15, 60, 300, 900)
if attempt >= len(delays):
return PollDecision(None, suppress_sms=False, needs_review=True)
return PollDecision(
observed_at + timedelta(seconds=delays[attempt]),
suppress_sms=False,
needs_review=False,
)
now = datetime.now(timezone.utc)
assert decide(DeliveryState.INVALID_RECIPIENT, 0, now).suppress_sms
assert decide(DeliveryState.SENT, 0, now).next_poll_at == now + timedelta(seconds=15)
assert decide(DeliveryState.DELIVERED, 2, now).next_poll_at is None
The provider adapter has one job: translate an authenticated status response into DeliveryState.
Keep URL paths, credentials, and provider labels inside that adapter. This boundary is useful in a Node.js service too, even though the executable example is Python: the worker, repository, and pure decision function map directly to the same interfaces. More importantly, a provider migration changes the adapter while the suppression rule and its eval cases stay fixed.
An HTTP 429 during a poll should reschedule the observation according to the documented retry signal and local rate policy. It must not suppress the number. The same separation applies to a local deadline: record the failed observation, retain the last known delivery state, and retry within the bounded window. A single network outcome should never become evidence about a recipient.
The eval harness should fail before production does
I would make the contract suite the buying document. Marketing labels such as “delivery status” are too broad; the harness asks whether the candidate API supplies the transitions this application needs. I'm not sure any static comparison can predict regional delivery behavior for a particular recipient mix. A short controlled trial using consented test numbers and the application's real polling cadence resolves far more uncertainty.
Use synthetic recipients or numbers explicitly provisioned for testing. The matrix should include accepted then delivered, accepted then terminally invalid, a status that remains pending until the observation deadline, duplicate reads, a rate-limited read, and an out-of-order observation. For each case, assert the stored state, whether another poll is due, whether SMS is suppressed, and whether an operator review is created. Also assert the negative rule: a transient polling failure never changes recipient eligibility.
| Fixture | Expected state | Recipient action |
|---|---|---|
| Accepted, then delivered | delivered |
Keep SMS eligible |
| Accepted, then invalid | invalid_recipient |
Suppress SMS |
| Pending at the deadline | Last observed state | Review; do not suppress |
| Rate-limited status read | Last observed state | Reschedule; do not suppress |
Measure distributions, not a single average. Useful outputs are time from submission to the first terminal observation, the share still nonterminal at each polling boundary, status-read attempts per submitted alert, duplicate notification count, and false-suppression count. Split the report by US and EU destination cohorts only when the test has consent, an appropriate legal basis, and enough observations to avoid pretending that noise is a regional conclusion. Your mileage may vary with routes, carriers, message content, and time of day.
Prompt-cost awareness still belongs here even though the transport is SMS. If an AI model drafts alert copy, freeze the evaluated text before submission and log the prompt/model version separately from delivery data. Do not regenerate copy on a transport retry. That keeps token spend bounded and makes a delivery experiment compare transport behavior rather than shifting message content.
Keep a golden dataset in the repository: inputs, provider-label fixtures, expected internal states, and suppression decisions. Run it against every adapter change. Then run a smaller integration suite against the selected service's test environment or consented numbers. The notebook remains useful for inspecting latency curves, but the assertions must graduate into CI before the worker does.
When is status polling the wrong choice?
The catch is latency and read volume. Polling is not suitable when the product needs near-immediate delivery events at very high message volume and the team can securely operate signed callbacks; in that case, use webhooks and retain periodic reconciliation as a backstop. Polling is a good fit when inbound connectivity is deliberately unavailable, notification volume is modest, and a bounded delay is acceptable.
Simple setup also has a boundary. A single scheduled worker and relational table can be enough at first, but strict sub-minute objectives across large bursts may call for a queue, partitioned claims, and tighter rate coordination. Don't buy that complexity before the measurements demand it.
The API choice follows from the test: reject a candidate if its documented status model cannot distinguish a terminal invalid recipient from pending delivery, if status can only arrive by webhook, or if its retention window is shorter than the application's polling window. Also check supported US and EU destinations, sender-registration obligations, data-processing terms, regional data handling, authentication, rate-limit behavior, idempotency semantics, and access to test credentials. These are gates, not a score that can hide one fatal mismatch.
Email fallback requires its own suppression rules. RFC 7489 defines DMARC for domain-level email authentication and reporting; it does not validate a recipient mailbox and should not be treated as a substitute for bounce handling. Keep channel eligibility separate, so an SMS-invalid result cannot silently suppress email and an email policy result cannot suppress a phone number.
Before copying this design, measure the permitted alert delay, expected burst size, terminal-status latency, polling request budget, false-suppression tolerance, and operator capacity for unresolved outcomes. The best API is the one whose documented state transitions survive that harness while meeting the application's regional and operational constraints.
References
- RFC 7489: Domain-based Message Authentication, Reporting, and Conformance (DMARC)
- NIST SP 800-63B: Authentication and Lifecycle Management
Further reading
The two primary sources above are useful boundaries for adjacent email-authentication and SMS-authenticator decisions. For transport-specific selection, read each candidate's current delivery-status, rate-limit, retention, sender-registration, and data-processing documentation, then verify those claims with the same contract suite.
Top comments (0)