Technology Aug 27, 2026 · 10 min read

Simple SMS API Comparison: 3 Bounce-Suppression Controls for App Alert Status

Short answer: for B2B SaaS app alerts, choose an SMS API by whether webhook delivery events and polling results can feed one idempotent status ledger, then suppress a recipient only after a documented terminal failure; don't let either transport write directly to the suppression list. That separati...

DE
DEV Community
by AlgernonCross4103
Simple SMS API Comparison: 3 Bounce-Suppression Controls for App Alert Status

Short answer: for B2B SaaS app alerts, choose an SMS API by whether webhook delivery events and polling results can feed one idempotent status ledger, then suppress a recipient only after a documented terminal failure; don't let either transport write directly to the suppression list.

That separation matters more than the shape of a send call. A callback can be delayed, duplicated, or delivered out of order, while a poller can observe a newer state before an older callback arrives. If both paths mutate customer eligibility, a routine ordering race can silence a valid recipient or repeatedly target an invalid one. The safe design is smaller: ingest observations, resolve them under one state policy, and make suppression a separate, auditable decision.

This is also where an apparently simple comparison gets uncomfortable. Twilio, Vonage, and AWS End User Messaging SMS should face the same contract test; a feature grid can't prove which event stream fits a particular application's retention window, compliance controls, and recovery targets. The winner is the integration whose documented states can be normalized without guessing.

How should a simple SMS API compare webhook and polling delivery status?

Start with evidence, not transport preference. For every attempted alert, the application needs its own immutable message identifier, the provider's identifier, the recipient key, the observed provider state, the observation time, the ingestion time, and the source of that observation. A webhook and a polling response are then two claims about the same attempt. Neither is automatically the truth merely because it arrived last.

Use three controls. First, make ingestion idempotent: the same observation must be harmless when processed twice. Second, define a monotonic state policy so that a late intermediate event cannot move a terminal attempt backward. Third, separate attempt status from recipient suppression. A failed attempt may reflect a recipient problem, a policy block, or a transient delivery condition; the normalized reason and the provider's documented terminal semantics determine which one it is.

The comparison therefore belongs in a test matrix, not a marketing checklist:

Question Webhook evidence to capture Polling evidence to capture Acceptance rule
Can one observation be replayed? Stable event or derived dedupe key Stable provider message ID plus observed state A replay produces no second transition
Can states arrive out of order? Event timestamp and state State plus retrieval timestamp A stale observation cannot reverse a terminal state
Can processing resume after downtime? Retry behavior and retention stated in documentation Status lookup window stated in documentation The recovery window exceeds the application's outage budget
Can a failure justify suppression? Machine-readable terminal reason The same normalized reason, if exposed Only an allowlisted reason creates a suppression candidate

Run those checks against each candidate's current documentation and sandbox. I'm not sure a paper comparison can settle the last two rows for every account configuration; the evidence that would settle them is a recorded contract test using the exact sender type, destination region, and event settings planned for production. Your mileage may vary because that configuration is part of the system under test.

Short send APIs are nice. Deterministic evidence is better.

What belongs between delivery evidence and a suppression decision?

A status ledger should be append-only at its boundary even if a projection stores only the latest resolved state. That gives operators the raw sequence needed to explain why a recipient was suppressed. It also prevents the callback handler from becoming a hidden policy engine — a tempting shortcut that makes later reconciliation painful.

Consider an alert attempt that has three observations: polling records accepted at 09:01, a callback records delivered at 09:02, and a delayed callback later reports the earlier accepted state again. Arrival order says the last event wins. Domain order says delivered remains final. The resolver retains the late event as evidence but refuses the backward transition, so an operator can still reconstruct what arrived and when. Now change the example: a terminal invalid-recipient reason arrives after an intermediate state. The attempt becomes terminal, but the recipient still shouldn't disappear instantly from every channel. The policy can create a suppression candidate, attach the exact reason and source attempt, and require the appropriate channel-specific threshold or review. If the same destination is later corrected by an authorized account administrator, that correction should be another audited policy action rather than a deletion of the original failure. That extra boundary is what keeps an SMS failure from suppressing email by accident, and it preserves enough history to explain a support ticket without trusting the current row alone.

One source of truth.

Email and SMS identity must stay distinct. SPF, defined by RFC 7208, authorizes hosts to use domain names in SMTP identities; it doesn't validate a phone number and shouldn't be used as a conceptual shortcut for SMS recipient validity. A B2B account may have one person with several addresses and numbers, so model suppression at tenant + channel + destination, then add an explicit account-level rule only if the product actually requires it.

Here is a deliberately provider-neutral resolver. The adapter is responsible for mapping documented provider states into this small vocabulary. Unknown values remain observations for review; they are never silently treated as success or invalid-recipient failures.

from dataclasses import dataclass
from datetime import datetime
from enum import IntEnum


class Rank(IntEnum):
    QUEUED = 10
    ACCEPTED = 20
    DELIVERED = 30
    TERMINAL_FAILURE = 30


@dataclass(frozen=True)
class Observation:
    attempt_id: str
    provider_message_id: str
    state: str
    reason: str | None
    observed_at: datetime
    source: str


STATE_RANK = {
    "queued": Rank.QUEUED,
    "accepted": Rank.ACCEPTED,
    "delivered": Rank.DELIVERED,
    "terminal_failure": Rank.TERMINAL_FAILURE,
}


def resolve(current: Observation | None, incoming: Observation) -> Observation:
    if incoming.state not in STATE_RANK:
        raise ValueError("unmapped delivery state")
    if current is None:
        return incoming

    current_rank = STATE_RANK[current.state]
    incoming_rank = STATE_RANK[incoming.state]
    if incoming_rank < current_rank:
        return current
    if incoming_rank == current_rank and incoming.observed_at <= current.observed_at:
        return current
    return incoming

The equal rank is intentional: delivery and terminal failure are both final, so the application must flag a contradictory pair instead of letting timestamps decide a business fact. Keep the raw observations, alert on the conflict, and consult the provider's state contract. Don't invent precedence for a condition the adapter wasn't designed to understand.

There is another sharp edge. HTTP success from the send request establishes that a request was accepted at that boundary; it does not establish handset delivery. Store the provider message identifier, return control to the app, and let later evidence advance the attempt. Conversely, an HTTP 429 is a submission outcome to handle with bounded retry policy, not proof that the recipient is invalid. Mixing request errors with delivery results is how suppression lists get poisoned.

Webhooks lead; polling repairs gaps

Webhooks are the timely path because they let the provider push a new observation. Polling is the reconciliation path because the application controls when it asks. Treating them as rivals misses the useful architecture: use callbacks for normal progress, then poll attempts that remain nonterminal beyond an application-defined age or whose callback processing record is incomplete.

Keep the callback boring. Authenticate it according to the provider's current documentation, parse it through the provider adapter, persist the raw payload under the applicable retention policy, enqueue the normalized observation, and acknowledge only after durable acceptance. The public handler should not update a contact, send a second alert, or evaluate tenant policy. Those actions make retries dangerous.

The poller needs equal discipline. Bound concurrency, respect documented rate limits, add jitter, and stop querying terminal attempts. A 429 should delay that provider-account partition rather than cause a tight loop across every unresolved message. Also cap the reconciliation horizon according to the provider's documented lookup window and the application's own audit requirements. Once the source can no longer answer, mark the attempt status_unknown for review; don't translate missing evidence into a delivery failure.

Fast is useful. Recoverable wins.

For duplicate detection, prefer a provider event identifier when the contract supplies one. Otherwise, derive a key from stable fields such as provider message ID, normalized state, reason, and provider observation time. Do not hash the full raw body and assume semantic duplicates will serialize identically. Record the adapter version beside the normalized observation, too, because a state-map change is a data migration even when no database column changes.

Compare providers with contract tests, not assumed parity

Twilio, Vonage, and AWS End User Messaging SMS are reasonable real-product candidates for the same evaluation harness, but their names shouldn't change the acceptance criteria. For each one, implement a thin adapter and record objective results: which documented state was emitted, whether the duplicate was ignored, whether an older state was rejected, and whether a terminal invalid-recipient classification produced exactly one suppression candidate. This compares observable integration behavior without pretending that similarly named statuses carry identical semantics.

Use fixed scenarios: a normal delivery progression, a duplicate callback, an older callback after a terminal observation, a polling response that races a callback, an unknown state, a rate-limited status request, and a terminal invalid-recipient reason. The expected results belong to your domain model. The provider-specific input fixtures belong to the adapter test suite and should be refreshed when its documented contract changes.

The catch is operational ownership. A webhook-first design is not suitable when the team cannot expose, authenticate, monitor, and retain evidence from a public callback endpoint; in that case, stick with bounded polling if the documented lookup window safely covers the reconciliation interval. Polling is a poor fit when status volume would collide with documented request limits or when the required update latency is shorter than the safe poll interval. Then a durable callback receiver plus targeted reconciliation is the better shape.

No single provider wins those trade-offs in the abstract. A team already standardized on a cloud account may value centralized access controls; another may value a communications-focused API and event model. Require the same security review, state mapping, replay test, and exit plan from all three. Cost belongs in the scorecard after reliability and compliance constraints, using the current quote for the actual destinations and message mix rather than a copied headline rate.

OTP deserves one extra boundary. The OWASP Forgot Password Cheat Sheet says reset codes or tokens should be random, sufficiently long, stored securely, single use, and expire after an appropriate period. Delivery status must not extend an OTP's lifetime, and a delivered status must not mark the authentication challenge as completed. The notification ledger and the authentication state machine can share an attempt reference, but they should not share authority.

How can a team roll out without corrupting its current suppression list?

Begin in shadow mode. Ingest webhook and polling observations into the new ledger, resolve them, and calculate suppression candidates without applying them. Compare each candidate with the current system's decision, inspect disagreements, and classify the cause as mapping, ordering, identity scope, or policy. This stage needs enough time to exercise delayed and duplicate observations; choose the duration from observed traffic cycles and the application's risk tolerance rather than an arbitrary universal number.

Next, enable suppression writes for one tenant cohort and one channel while retaining an immediate audit trail: recipient key, normalized reason, triggering attempt, observation source, adapter version, and policy version. Monitor callback age, unresolved-attempt age, duplicate rate, contradictory terminal states, polling 429 responses, and suppression reversals. A rollback should disable new writes without deleting evidence or silently restoring recipients whose validity has not been reviewed.

Finally, make the comparison repeatable. Re-run the contract suite before an adapter release, after a provider configuration change, and during a provider migration. The durable asset isn't the adapter code. It's the set of invariants proving that callbacks, status checks, and suppression policy cannot disagree unnoticed.

References

DE
Source

This article was originally published by DEV Community and written by AlgernonCross4103.

Read original article on DEV Community
Back to Discover

Reading List