TL;DR: For a B2B SaaS system comparing international SMS alerts, scalability and pricing are recipient-state problems before they are provider-selection problems. Persist every delivery event, map provider-specific signals into a small internal vocabulary, and suppress only when the evidence says the destination is invalid or permanently unreachable. Keep temporary failures retryable, and make every suppression reversible.

The useful comparison is not a static price sheet. It is how much evidence each delivery path gives your system, how quickly that evidence arrives, and how cleanly you can move between providers without changing business logic. Prices, route availability, sender registration, and local rules change. A durable suppression ledger survives those changes.

The trade-off is unavoidable. A richer event record takes more storage and more adapter work, but a one-bit failed flag makes retries cheap only until it sends alerts to dead destinations.

What should happen after an alert fails?

The data flow can stay plain: an alert creates a message intent, a channel adapter sends it, an asynchronous callback records the outcome, and a policy worker changes recipient state. The application should never infer a permanent failure from a timeout, and it should never delete a recipient because one route rejected one message.

For email, a bounce is a delivery report, not a single universal status. SMTP enhanced status codes distinguish classes of conditions, while delivery-status notifications carry structured fields for the original message and recipient. SMS providers expose different event names and reason strings, so the adapter must translate them before policy code sees them.

A small state machine is enough for the first version:

  • active: eligible for an alert
  • deferred: delivery is pending or temporarily failed
  • suppressed: a durable policy decision blocks automatic sends
  • review: the evidence is ambiguous and needs an operator or a later recheck

The critical rule is monotonic evidence, not monotonically harsher action. A permanent recipient error can move active to suppressed; a network timeout should move the message to deferred, not the recipient.
Here is a framework-free TypeScript core. It accepts normalized events, so provider adapters remain at the edge.

type DeliveryClass = | "delivered" | "temporary_failure" | "permanent_failure" | "unknown"; type RecipientState = "active" | "deferred" | "suppressed" | "review"; interface DeliveryEvent { messageId: string; recipient: string; channel: "email" | "sms"; classification: DeliveryClass; reasonCode?: string; observedAt: string; sourceEventId: string; } interface RecipientRecord { state: RecipientState; lastReasonCode?: string; lastEventAt?: string; } function applyEvent( record: RecipientRecord, event: DeliveryEvent ): RecipientRecord { if (event.classification === "permanent_failure") { return { state: "suppressed", lastReasonCode: event.reasonCode, lastEventAt: event.observedAt }; } if (event.classification === "temporary_failure") { return { ...record, state: record.state === "suppressed" ? "suppressed" : "deferred", lastReasonCode: event.reasonCode, lastEventAt: event.observedAt }; } if (event.classification === "unknown") { return { ...record, state: record.state === "suppressed" ? "suppressed" : "review", lastReasonCode: event.reasonCode, lastEventAt: event.observedAt }; } return { ...record, lastEventAt: event.observedAt }; } 

The persistence layer should enforce uniqueness on sourceEventId. Callbacks are retried in real systems; processing the same event twice must not create two suppression records or send two audit notifications. Store the raw event beside the normalized one. That costs storage, but it is the cheapest way to investigate a disputed suppression later.

Four states. Three evidence dimensions. That is enough structure to make the first decision auditable.

How should international SMS alerts scale without a pricing trap?

This is where many alert systems quietly damage delivery. A destination can be valid while a route is unavailable, a sender identity is not accepted in a country, a message is filtered, or a downstream service has not reported a final state yet. Those are different operational problems. An adapter that cannot expose that distinction is a poor fit for a high-reliability alert path, even when its initial pricing looks attractive.

I use three independent dimensions in the event model: recipient evidence, message evidence, and transport evidence. Recipient evidence answers whether this address or number should be attempted again. Message evidence describes content or policy rejection. Transport evidence describes the path used for this attempt. Combining them into one failed boolean throws away the information needed for a safe retry.

A practical policy might suppress an email only after a classified permanent mailbox failure, or suppress an SMS destination only after a provider event is mapped to a durable invalid-recipient category. It should send ambiguous cases to review. A provider timeout, a rate limit, and a temporary carrier result belong in retry handling with bounded attempts and a visible dead-letter queue.

Do not let a retry worker mutate recipient eligibility by accident. Give it a message-attempt key, an idempotency key, and a next-attempt timestamp. The recipient ledger owns suppression; the delivery queue owns retry timing.

What does international scale change in the design?

International traffic adds policy and observability work before it adds throughput work. A phone number needs a canonical representation, and the system needs to retain the country or region context used for the send. Email addresses need normalization rules that do not silently rewrite mailbox semantics. Keep the original user input for display and investigation; keep the canonical destination for matching and dispatch.

This is the part a cost spreadsheet cannot answer.

Consider one alert with two attempts. The first attempt receives a route rejection that names a sender restriction; the second receives a permanent invalid-recipient result after the adapter has retried through an allowed route. If the system stores only failed, the retry worker either suppresses too early or keeps sending forever. With separate transport and recipient evidence, the first event stays attached to the attempt, the second changes the recipient ledger, and the audit trail explains why. The same distinction matters when an email bounce arrives after an SMS fallback was already queued: the fallback should consult the current recipient state, but it should not inherit an unrelated channel's failure without a policy that says so. This is extra code at the boundary. It is less code in incident review.

Keep it boring.

Country-level routing can also change the meaning of an error. A sender restriction may be a route or registration issue, not proof that the recipient is invalid. That is why the adapter should preserve a provider reason, a mapped class, the destination region, and the attempted route. Do not collapse these fields into a dashboard label such as “undelivered.”

The cost control is architectural. Retry only transient classes, cap attempts, and sample verbose payloads after the retention window needed for audits. Measure cost per successful alert and cost per suppressed-invalid destination, not just the transport line item. If a retry policy doubles attempts for a destination that can never receive the message, a nominally attractive route becomes an operational liability.

A vendor-neutral boundary also keeps a migration manageable. Whether a team evaluates Plivo, Sinch, Twilio, or another carrier-connected service, the application should compare callback completeness, reason-code stability, regional constraints, exportability of event history, and failure semantics. A rate table is one input. It is not the delivery contract.

Which tests catch suppression bugs before production?

Test the normalization table with captured, redacted fixtures. Include a permanent mailbox rejection, a temporary mailbox rejection, a malformed destination, a route rejection, a timeout, a duplicate callback, and an out-of-order callback. The expected result should cover both message state and recipient state.

Then test time. A callback observed after a retry was queued must not resurrect a suppressed recipient. A late successful event should close the message attempt without clearing a recipient suppression created by stronger evidence. Property-based tests are useful here because event order is the bug surface, not the happy-path payload.

For a staging send, use controlled recipients and verify the complete chain: intent, outbound attempt, callback authentication, normalization, ledger update, suppression check, and audit record. Synthetic monitoring should report the age of the oldest unclassified event. That number often exposes a broken callback consumer earlier than a generic uptime check.

The operational checklist is short enough to keep beside the runbook: define the normalized classes; persist raw and mapped events; deduplicate callbacks; separate recipient state from attempt state; retry only temporary outcomes; quarantine ambiguous evidence; authenticate callbacks; record region and route context; and provide a reviewed, audited way to release a suppression.

Reliable international alerts come from making uncertainty explicit. The system should be conservative about suppressing recipients and aggressive about preserving evidence. That decision keeps the application portable, limits wasteful retries, and gives an operator something better than a vague failure count when delivery goes wrong.

References