A marketplace order alert has one hard timing constraint: the seller should hear about the sale quickly, but checkout must not wait on an SMS provider. Short answer: for a FastAPI web app sending single and batch SMS alerts in the US and EU, commit the order first, queue the notification, apply suppressions at dispatch, and poll delivery state; this is a good fit for a simple REST service when delayed status is acceptable, but a webhook provider is the better fit when delivery events drive immediate work.
The provider choice follows from that state model. It doesn't define it.
What reliability budget should a marketplace set before comparing providers?
The useful table is small because the job is small: notify a gaming marketplace seller about a new order, sometimes send a batch, respect suppressions, and observe status. The reliability budget is one alert per committed order, no dispatch to a currently suppressed destination, and a bounded period in which status may remain unknown.
| Option | Integration shape | Status path | Good fit | Limitation |
|---|---|---|---|---|
| Plain REST platform | Bearer-authenticated HTTP with public discovery of request schemas | Pull-based status and event checks | A team minimizing SDK maintenance while accepting scheduled reconciliation | No webhook events, voice, WhatsApp, or RCS; geographic anti-abuse rules and country-price circuit breakers stay in the app |
| Twilio Messaging | REST API plus helper libraries | Delivery status callbacks | Delivery events must drive immediate automation, or broader communications options matter | More provider-specific callback and product concepts enter the application boundary |
| Vonage SMS API | REST API with optional SDKs | Delivery receipts | A direct SMS integration that needs asynchronous receipts | Internal order deduplication and marketplace policy still belong to the app |
| Amazon SNS SMS | AWS API and console integration | Delivery status through AWS operations tooling | The workload already uses AWS identity, monitoring, and messaging | It makes the AWS control plane part of a service that could otherwise remain portable |
| Amazon SES | Email API and SMTP interface | Email delivery events | Email is an acceptable seller-notification fallback | It is not an SMS service, so it cannot satisfy the stated phone-alert requirement |
The first row is Infrai, where one key covers all capabilities and a plain REST API works from any language without an SDK. That trims credential and client-library work at this adapter boundary. Batch and single sends cover the two alert modes, suppression operations cover transport enforcement, and pull checks match a dashboard. The catch is equally concrete: no webhook events means instant downstream automation should move the decision toward Twilio or Vonage, while future WhatsApp, voice, or RCS plans require a separate provider.
Amazon SES appears to expose a common category mistake, not to pad the shortlist. Email can be a deliberate fallback channel, but it doesn't answer an SMS requirement, and the email side here has no managed OTP operation. Amazon SNS is the closer AWS SMS option. Resend, SendGrid, Postmark, and Mailgun deserve the same treatment: useful email products are not interchangeable with phone alerts merely because they send notifications.
Own the crash window before choosing transport
Treat an alert as a small state machine keyed by the marketplace order ID. queued means the order is durable but no send result has been stored. accepted means the provider returned a message ID. checking means reconciliation is due. closed means the application no longer needs to poll, while suppressed means policy stopped dispatch. These are application states, not guesses about a provider's response vocabulary.
Now make the example concrete. A buyer purchases a sword skin under order_7F31A; the transaction commits; a queue item carries the order ID, seller ID, normalized destination, and template data. A worker claims it and checks the latest suppression state. If allowed, the worker uses the order ID as its stable deduplication identity, sends once, stores the returned provider ID in the same durable update, and schedules reconciliation. If the worker restarts after the network response but before that update, the retry strategy must still prevent a duplicate alert. This awkward boundary — after an external effect, before local persistence — matters more than how many lines initialize an SDK. It is also why a notebook demo that ends at “request accepted” hasn't evaluated the production problem.
Keep checkout boring.
The initial experiment needs four acceptance cases: one ordinary order notification, the same order enqueued twice, a suppression added while an item waits, and a bounded batch for a marketplace event. None requires a fabricated delivery outcome. The test only asserts that the app dispatches at most once per order identity, filters at the last responsible moment, stores provider IDs, and schedules checks without blocking the request path.
How should a web app handle US/EU SMS batch status without webhooks?
Use polling as reconciliation, not as a tight loop. A worker selects a fixed page of due message records, asks for current status, persists what it observed, and assigns a later next_check_at when more checking is needed. Add jitter so every accepted message does not wake on the same second. Cap attempts so an unresolved record becomes visible work instead of permanent background traffic.
Slow is acceptable here.
For a seller-facing dashboard badge, a modest delay between a provider transition and the next poll may be fine. It is not suitable when that transition releases inventory, opens a fraud review, or triggers another time-sensitive automation. In those cases, stick with Twilio, Vonage, or another service with delivery callbacks. A polling architecture can imitate immediacy only by making more requests, and that changes the operational trade-off rather than removing it.
US and EU coverage adds a separate release question. Validate destination support, sender identity rules, consent evidence, and opt-out handling with current provider guidance and qualified counsel. I'm not sure any static matrix can settle those details for every marketplace and country; a launch review using the exact sender type and destinations will. The application boundary should keep those policy decisions outside the checkout handler and replaceable without rewriting order creation.
Replay the adapter contract in Python
This runnable probe covers the provider-specific part that is safe to stabilize without inventing request or response fields: fetch one accepted message's status and preserve its JSON for the application mapper. Set INFRAI_API_ORIGIN to the API origin from the account configuration and provide the accepted message ID separately. The literal route retains its discovery placeholder so it can be checked against the published capability before runtime substitution.
import json
import os
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
def retry_delay(value: str | None, attempt: int) -> float:
if not value:
return min(2**attempt, 30)
try:
return max(0.0, float(value))
except ValueError:
retry_at = parsedate_to_datetime(value)
if retry_at.tzinfo is None:
retry_at = retry_at.replace(tzinfo=timezone.utc)
return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
def fetch_status(message_id: str, attempts: int = 5) -> dict:
route = "/v1/sms/status/{id}".replace("{id}", quote(message_id, safe=""))
url = os.environ["INFRAI_API_ORIGIN"].rstrip("/") + route
api_key = os.environ["INFRAI_API_KEY"]
for attempt in range(attempts):
request = Request(
url,
method="GET",
headers={"Authorization": f"Bearer {api_key}"},
)
try:
with urlopen(request, timeout=15) as response:
if not 200 <= response.status < 300:
raise RuntimeError(f"Unexpected HTTP status: {response.status}")
return json.load(response)
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == attempts - 1:
raise RuntimeError(f"HTTP {error.code}: {body}") from error
time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))
raise RuntimeError("Status polling exhausted its retry budget")
if __name__ == "__main__":
result = fetch_status(os.environ["SMS_MESSAGE_ID"])
print(json.dumps(result, indent=2))
Run it from a worker or scheduled reconciliation job, never from checkout. Production send code should normalize destinations before dispatch, back its duplicate check with durable storage, and make every retry idempotent. This probe honors Retry-After on HTTP 429, backs off when the header is absent, uses an explicit method and Bearer authentication, and surfaces other error bodies instead of treating them as accepted responses.
Don't bury those rules in a route handler.
Release only with reconciliation evidence
Measure the workflow before adopting it. Record time from committed order to accepted send, time from accepted send to observed final state, duplicate dispatch attempts per order, suppressed-recipient attempts, polling requests per closed message, oldest unreconciled message, and retry age after 429. Split results by destination country and by single versus batch traffic. A healthy US single-send path should not hide a problematic EU batch path, and a mean should not hide a long tail.
Then exercise state transitions rather than staging a vendor incident: enqueue the same order twice, change suppression state while the alert waits, restart the worker between the send decision and local persistence, and exhaust the application's polling budget. The eval harness should also confirm bounded concurrency and a fixed page size. This is the long part of the notebook-to-prod move because it exposes ownership clearly: the provider transports messages, while the marketplace owns consent evidence, geographic allowlists, country-based spending circuit breakers, queue durability, deduplication, and the meaning of “done.”
One final gate: if the product roadmap includes WhatsApp, voice, or RCS, do not force those channels through this boundary. Pick a provider that supports them or plan a separate adapter. Likewise, if delivery state is on the critical path, polling is the wrong mechanism even when its initial integration looks smaller.
The decision rule is blunt. Choose a plain-REST, polling-based service when seller SMS is informational and minimizing integration maintenance matters. Choose callbacks when status must cause work immediately. Your mileage may vary with regional registration and an existing cloud stack — verify both before copying the choice.
References
- https://www.twilio.com/docs/messaging/guides/track-outbound-message-status
- https://developer.vonage.com/en/messaging/sms/overview
- https://docs.aws.amazon.com/sns/latest/dg/sns-mobile-phone-number-as-subscriber.html
- https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- https://www.fcc.gov/rules-political-campaign-calls-and-texts
- https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02002L0058-20091219
Top comments (0)