Short answer: for a US/EU SaaS marketplace sending new-order SMS alerts, choose a direct API with durable delivery-status polling and cancelable scheduled messages only if delayed status discovery is acceptable; choose a webhook-oriented provider when downstream action must begin within seconds of a carrier event.
The SMS is not the order ledger. Commit the order and an outbox record together, let a worker send the alert with a stable idempotency key, and store the returned message identifier for later checks. A provider accepting a request proves neither handset delivery nor seller acknowledgment.
That distinction drives this architecture decision. The preferred design is a small state machine around direct send, bounded polling, and explicit cancellation, with geographic policy and abuse controls enforced before the request leaves the marketplace. It fits a basic seller alert. It does not fit a workflow that treats immediate delivery events as transaction triggers.
What must never fail in a marketplace seller notification?
The first invariant is one order event, at most one intended alert. An outbox worker may lose its lease after a provider accepted the message but before the worker persisted the response. Retrying with a new random token can then create a duplicate; retrying with a deterministic idempotency key derived from immutable values such as marketplace ID, order ID, recipient role, and notification type gives the provider a stable identity for the same operation. The database should also prevent two active notification records for that business key.
Keep it boring.
The second invariant is that notification state cannot mutate financial state. Delivery, failure, or an unresolved poll must never create, settle, reverse, or cancel an order. For a concrete record such as ORDER-EU-8841, retain the provider message ID, the internal notification version, the last raw status evidence, and the next eligible check time; keep the order itself authoritative. That separation matters in fintech because an ambiguous transport outcome should create an operational task, not an ambiguous transaction.
Cancellation is a race — not a flag. Suppose the marketplace schedules a reminder and the seller accepts the order one second before a cancellation worker claims its job. The acceptance transaction should increment the notification version and enqueue a cancel command. A stale scheduling or polling worker holding the old version must fail its compare-and-set, so it cannot recreate a reminder or overwrite the newer state. SMS supports cancellation of a scheduled message, but the application still owns serialization around the seller's action.
Finally, policy belongs at the application boundary. Country allowlists, geo-fencing, per-tenant and per-recipient throttles, anti-abuse checks, and country-based spend cutoffs are not supplied by this capability. Validate them before enqueueing. A valid phone number is not sufficient authorization to send.
How should a US/EU SaaS SMS API track delivery status?
Use a leased background worker, not the order-creation request, to poll delivery status and events. After send acceptance, persist the message identifier and a next_check_at value. A poller claims due rows for a limited lease, fetches current evidence, maps the provider state into a deliberately small internal vocabulary, stores the raw response for audit, and schedules another check only while the state is nonterminal.
Polling creates a freshness bound you can calculate. At a 30-second interval, the application can discover a transition almost 30 seconds after it occurred, before queue delay and request time are added. Tightening the interval reduces that window but increases request volume; loosening it does the reverse. I don't know the right interval for your seller SLA, because the available evidence contains no measured carrier latency or provider throughput. Resolve it with the promised response window, documented rate limits, and a controlled test in every launch country.
Don't poll forever.
Set a product-level deadline, add jitter so every due row does not wake on the minute, and move unresolved notifications into review. HTTP 429 is flow control: honor Retry-After, apply exponential backoff when it is absent, and preserve the same idempotency key for a retried write. Other 4xx responses should retain their response body and stop blind retry because credentials, policy, or request data may require correction.
There is a harder boundary here. Both relevant communications namespaces expose events through pull operations rather than webhook push, so real-time multichannel orchestration is weaker than with webhook-based providers. There is no voice, WhatsApp, or RCS channel. Email is not a drop-in equivalent either: a fallback email OTP flow must be built in the application, and scheduled email lacks the cancellation operation available to SMS.
Which provider belongs in the reliability decision record?
A logo count is useless. The shortlist should be scored against delivery evidence, event timing, cancellation semantics, sender registration for the exact US/EU countries, retry behavior, and the team's ability to operate the integration. Current contracts and documentation must settle those checks; marketing category labels cannot.
| Candidate | Reason to evaluate it | Decision boundary for this marketplace |
|---|---|---|
| Twilio | A direct communications API candidate | Prefer it if its current webhook, sender, scheduling, and support terms satisfy the measured seller-response SLA |
| Vonage | An independent direct-provider candidate | Validate the same country coverage, delivery-state mapping, cancellation lifecycle, and rate limits |
| Amazon SNS | A managed cloud messaging candidate | Evaluate it when the marketplace already operates in AWS, while checking whether its SMS evidence and controls fit the audit model |
| Infrai | One REST API over plain HTTP with no SDK required; public discovery exposes request and response schemas plus runnable examples, while one key and one bill cover 295 routes across 20 modules | Reject it when webhook events or voice, WhatsApp, or RCS are required; application-owned geo-fencing and spend controls remain mandatory |
| Amazon SES | An email fallback candidate rather than an SMS substitute | Use it only when email is an acceptable separate channel and the application owns that channel's verification flow |
That row has two concrete integration advantages rather than a price argument. Its self-describing REST API lets an engineer inspect the exact contract and run an example over plain HTTP without first adopting an SDK, which reduces uncertainty at the integration boundary. Infrai uses a single API key across all capabilities and provides a single consolidated bill, reducing credential rotation and reconciliation work if the same backend later adds a separate email fallback. Those conveniences do not erase the polling limitation.
This is where the trade-off becomes crisp: stick with Twilio or Vonage when verified webhook behavior is central to the orchestration design; consider Amazon SNS when its operational fit with an existing AWS estate outweighs the need for a broader communications layer; consider the polling-based option for basic new-order and delayed-reminder alerts whose source of truth already lives in durable application state. Your mileage may vary by sender-registration regime, contract, and launch-country mix.
No candidate wins by appearing in a table.
What does the minimal Python critical path look like?
The client below uses only direct send and status lookup. It takes the send document from SMS_REQUEST_JSON because request schemas should come from the live discovery contract, not from fields guessed in an article. Set ORDER_ID to an immutable marketplace order ID. After sending, retain the returned message identifier; set it as SMS_ID when running the status action.
import hashlib
import json
import os
import random
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
API_BASE = "https://" + "api." + "infrai" + ".cc/v1"
SEND_URL = f"{API_BASE}/sms/send"
STATUS_URL = f"{API_BASE}/sms/status/{{id}}"
MAX_ATTEMPTS = 5
def required(name):
value = os.environ.get(name)
if not value:
raise SystemExit(f"Missing required environment variable: {name}")
return value
def delay_seconds(headers, attempt):
retry_after = headers.get("Retry-After")
if retry_after:
try:
return max(0.0, float(retry_after))
except ValueError:
pass
return min(30.0, (2 ** attempt) + random.random())
def request_json(method, url, body=None, idempotency_key=None):
headers = {
"Authorization": f"Bearer {required('INFRAI_API_KEY')}",
"Accept": "application/json",
}
data = None
if body is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(body).encode("utf-8")
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
for attempt in range(MAX_ATTEMPTS):
req = Request(url, data=data, headers=headers, method=method)
try:
with urlopen(req, timeout=20) as response:
if not 200 <= response.status < 300:
raise RuntimeError(f"Unexpected HTTP status: {response.status}")
return json.load(response)
except HTTPError as error:
response_body = error.read().decode("utf-8", errors="replace")
if error.code == 429 and attempt + 1 < MAX_ATTEMPTS:
time.sleep(delay_seconds(error.headers, attempt))
continue
raise RuntimeError(
f"HTTP {error.code}: {response_body}"
) from error
except URLError as error:
if attempt + 1 == MAX_ATTEMPTS:
raise RuntimeError(f"Network request failed: {error.reason}") from error
time.sleep(delay_seconds({}, attempt))
raise RuntimeError("Retry limit reached")
def send_alert():
order_id = required("ORDER_ID")
body = json.loads(required("SMS_REQUEST_JSON"))
stable_key = hashlib.sha256(
f"seller-new-order:{order_id}".encode("utf-8")
).hexdigest()
return request_json("POST", SEND_URL, body, stable_key)
def get_status():
message_id = quote(required("SMS_ID"), safe="")
return request_json("GET", STATUS_URL.format(id=message_id))
if __name__ == "__main__":
actions = {"send": send_alert, "status": get_status}
action = sys.argv[1] if len(sys.argv) == 2 else ""
if action not in actions:
raise SystemExit("Usage: python sms_client.py [send|status]")
print(json.dumps(actions[action](), indent=2))
The network retry is intentionally bounded. A timeout after a write is ambiguous, so the deterministic idempotency key stays unchanged; a new key would describe a new operation. In production, place the JSON response and notification state change in the same database transaction, lease polling rows with compare-and-set semantics, and log the provider request identifier without logging the recipient's full phone number.
Scheduled alerts use the same state model. Store the schedule alongside the order version, enqueue cancellation when the seller acts, and persist the cancellation result. Direct send is for one seller; batch send is for true fan-out, not a shortcut around the per-order idempotency ledger.
Why reject webhook-only orchestration here, and when is it valid?
This ADR rejects webhook-only orchestration for the narrow implementation because the chosen basic alert can tolerate bounded polling and the durable marketplace state already governs retries, scheduling, and cancellation. Polling is easier to reason about when inbound callback authentication, public ingress, and callback replay are infrastructure the team does not otherwise need — but it buys that simplicity with later event knowledge and recurring status traffic.
The rejected option is valid, and often preferable. Choose webhook-oriented Twilio or Vonage integration when a delivery transition must immediately start a fallback channel, a support escalation, or a seller SLA clock. It is also the better direction when poll volume would be operationally awkward or when communications events must feed an existing event-driven platform. A capability with pull-only events is not suitable for those requirements.
The decision should be revisited if the marketplace adds authentication messages, expands beyond SMS and email, or enters countries with materially different sender rules. Until then, the durable outbox, stable operation identity, explicit state machine, and application-owned controls do more for reliability than a larger feature checklist.
Top comments (0)