Short answer: for low-cost passwordless backup and account alerts, choose an SMS service only after its delivery receipts can drive a provider-neutral suppression ledger, because retrying a permanently invalid recipient makes recovery less reliable regardless of the per-message price.
For a B2B SaaS product sending in the US and EU, the least complex workable design has two paths. The request path creates one notification intent and submits it once; the receipt path normalizes the later delivery result and decides whether that recipient may be tried again. Account notifications can tolerate a queued retry, but a passwordless backup code has a short useful life and needs an immediate move to another recovery method when the number is already suppressed.
Cheap sends aren't the goal. Successful recovery is.
Start with the one irreversible rule
Treat delivery reliability as a state problem, not a vendor score. A message accepted by an API is not proof that a handset received it, and a failed attempt is not automatically evidence that the phone number is permanently bad. The application needs to keep the notification intent, the provider message identifier, the normalized outcome, and the raw receipt separately. That separation lets an eval harness replay real receipt shapes without teaching business logic every provider's vocabulary.
The core states can stay small: active, cooldown, and suppressed. A permanent invalid-recipient outcome moves the destination to suppressed. A temporary outcome moves it to cooldown, where a policy can allow a later account alert. A delivered outcome clears transient failure history. An unknown outcome should remain visible for review rather than quietly becoming either success or permanent failure.
Unknown means unknown.
The security boundary matters more for passwordless recovery. OWASP's forgot-password guidance calls for consistent responses, a side channel for reset delivery, random single-use tokens, secure storage, and rate limiting. It also warns against changing the account before a valid token is presented. In practice, the SMS worker should receive an opaque notification ID and rendered text; it shouldn't decide that a user is authenticated, and a receipt webhook definitely shouldn't do so.
For ordinary account notices, consent and purpose need their own data. GDPR Article 7 requires a controller to be able to demonstrate consent where consent is the basis for processing, makes withdrawal possible, and says the request must be distinguishable from other matters. Don't overload a security-recovery destination with a marketing opt-in flag. Store the notification class and the applicable permission independently, then make suppression the final safety check before submission.
Implement the receipt-to-suppression path
The following example is intentionally provider-neutral. It uses a tiny SQLite ledger because that makes the state transition executable in a notebook and testable in CI; production storage can sit behind the same three functions. The adapter is responsible for mapping a signed, verified provider receipt into one of four internal outcomes. Signature verification is provider-specific and belongs before apply_receipt.
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import StrEnum
class Outcome(StrEnum):
DELIVERED = "delivered"
TEMPORARY_FAILURE = "temporary_failure"
INVALID_RECIPIENT = "invalid_recipient"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class Receipt:
provider: str
provider_message_id: str
recipient: str
outcome: Outcome
raw: dict[str, object]
def create_schema(db: sqlite3.Connection) -> None:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS recipients (
recipient TEXT PRIMARY KEY,
state TEXT NOT NULL CHECK (state IN ('active', 'cooldown', 'suppressed')),
reason TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS receipts (
provider TEXT NOT NULL,
provider_message_id TEXT NOT NULL,
outcome TEXT NOT NULL,
raw_json TEXT NOT NULL,
received_at TEXT NOT NULL,
PRIMARY KEY (provider, provider_message_id, outcome)
);
"""
)
def apply_receipt(db: sqlite3.Connection, receipt: Receipt) -> bool:
"""Apply one normalized, already authenticated receipt exactly once."""
now = datetime.now(timezone.utc).isoformat()
with db:
inserted = db.execute(
"""
INSERT OR IGNORE INTO receipts
(provider, provider_message_id, outcome, raw_json, received_at)
VALUES (?, ?, ?, ?, ?)
""",
(
receipt.provider,
receipt.provider_message_id,
receipt.outcome.value,
json.dumps(receipt.raw, sort_keys=True),
now,
),
).rowcount
if inserted == 0:
return False
if receipt.outcome == Outcome.INVALID_RECIPIENT:
state, reason = "suppressed", "invalid_recipient"
elif receipt.outcome == Outcome.TEMPORARY_FAILURE:
state, reason = "cooldown", "temporary_failure"
elif receipt.outcome == Outcome.DELIVERED:
state, reason = "active", None
else:
return True
db.execute(
"""
INSERT INTO recipients (recipient, state, reason, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(recipient) DO UPDATE SET
state = excluded.state,
reason = excluded.reason,
updated_at = excluded.updated_at
""",
(receipt.recipient, state, reason, now),
)
return True
def may_send(db: sqlite3.Connection, recipient: str) -> bool:
row = db.execute(
"SELECT state FROM recipients WHERE recipient = ?", (recipient,)
).fetchone()
return row is None or row[0] == "active"
Keep raw receipt data for diagnosis, but don't let raw provider strings leak through the rest of the app. The idempotency key in this sample includes provider, message ID, and outcome so a repeated callback cannot repeatedly mutate the ledger, while a later terminal outcome can still follow an earlier status. Your provider contract may expose a more precise receipt-event identifier; if it does, use that as the idempotency key and preserve every transition.
This is the notebook-to-prod hinge: the normalization function becomes a compact eval target. Feed it captured, redacted fixtures; assert the internal outcome; then run state-transition tests against an in-memory database. One fixture should cover an unfamiliar status and prove that it stays unknown. Another should replay the same receipt twice and prove that the second application returns False. A suppression record is security-sensitive operational data, but blindly sharing it across every notification purpose can also block a user from receiving a message they legitimately requested after correcting a number. Key the policy by normalized destination and channel, record the reason, and define a reviewed reactivation path. I'm not sure a single retention window works for every EU deployment; the answer depends on the controller's purpose and legal basis, so privacy and security owners need to settle it before production rollout.
How should low-cost SMS alerts compare passwordless account notification services?
Twilio, Vonage, and Telnyx are reasonable candidates to put through the same harness because they are the services named in the purchasing question. Their relevant difference here is not a logo or a headline rate; it is how each account and destination combination performs under the same delivery-receipt contract, sender setup, and regional traffic mix. Don't infer that result from an API accepting a request.
Use one scorecard and fill it from documentation review plus a controlled preproduction run:
| Decision input | Evidence to collect | Failure that should block launch |
|---|---|---|
| Receipt semantics | Redacted payload fixtures and documented status meanings | Permanent and temporary failures cannot be separated |
| Authenticity | A verified callback test and rejected invalid signature | An unauthenticated receipt can change suppression state |
| US/EU routing setup | Required sender and registration steps for the intended countries | The team cannot complete the required setup |
| Idempotency | Duplicate and out-of-order receipt replays | Replays corrupt recipient state |
| Recovery latency | Submit-to-terminal-receipt samples by country and notification class | Backup flow outlives the code's useful window |
| Cost model | Current account quote for the exact route and sender | The quote cannot be reconciled to message records |
This table deliberately doesn't crown a winner. Pricing, sender availability, and contractual terms can depend on country, traffic type, account, and date; current provider documentation and an account quote resolve those unknowns. Your mileage may vary, especially when a US-heavy test set is used to predict EU delivery. Record the evaluation date, country mix, sender type, and sample selection alongside every result.
Prompt-cost awareness applies here even though no model belongs in the delivery loop. If an AI system drafts account-notification copy, freeze the approved rendered templates before sending and evaluate them for length, forbidden content, and stable placeholders. Don't spend tokens regenerating the same security message per retry. More important, never let a model classify provider failures into suppression states at runtime when a deterministic adapter and fixture suite can do it audibly and cheaply.
Measure, then decide.
Put consent and recovery boundaries around delivery
The catch is that a well-built suppression ledger cannot make SMS a possession-proof channel, guarantee delivery, or repair a number the user no longer controls. If the risk model requires stronger resistance to interception or account takeover, stick with recovery codes, a registered authenticator, or another independently reviewed recovery mechanism rather than treating a second SMS route as the entire fallback plan. SMS can be one recovery side channel; it should not silently become the authorization decision.
It is also not suitable when the business cannot establish the required consent or other legal basis for the intended notification purpose. Separate transactional account notices from promotional messaging in both policy and data. A user withdrawing marketing consent should not be modeled as a carrier failure, and an invalid-recipient signal should not be interpreted as a privacy preference.
Provider failover has a narrower role than it first appears. A temporary, provider-specific submission failure may justify trying another configured route if the message is still useful and the attempt budget allows it. A normalized permanent invalid-recipient result should stop all providers. Otherwise failover turns one known-bad destination into repeated spend and noisy telemetry — exactly the behavior suppression was meant to prevent.
Stop there.
Release and operate it as a recovery control
Before deployment, review the state diagram with security, privacy, support, and the team that owns sender registration. Run the adapter fixtures in CI. In staging, verify callback authentication, duplicate delivery, out-of-order delivery, unknown statuses, and a corrected-recipient reactivation. Then confirm that passwordless recovery exits to a non-SMS option when may_send is false, while an account notice records a clear non-delivery outcome for downstream support tooling. After launch, watch ratios by country, sender, provider, notification class, and normalized outcome; raw totals hide route-specific changes. Alert on shifts rather than inventing a universal success threshold. Reconcile submitted messages, terminal receipts, suppressions, and invoices from the same intent IDs. Keep content generation metrics separate from transport metrics so a prompt change cannot masquerade as a carrier problem.
The operational review should be short but regular: sample unknown outcomes, confirm that permanent mappings still match current documentation, inspect reactivations, and replay the fixture corpus before changing an adapter. If a vendor change requires edits throughout the passwordless workflow, the boundary is too leaky. The application should know notification intent and recipient eligibility; only the adapter should know a provider's field names.
That is the selection rule: prefer the service whose verified receipt path fits this contract and meets the measured regional reliability target for the actual traffic. Price can break a tie after the recovery control works. It cannot substitute for one.
References
- OWASP, “Forgot Password Cheat Sheet”: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- GDPR Article 7, “Conditions for consent”: https://gdpr-info.eu/art-7-gdpr/
Top comments (0)