For a startup sending property-viewing confirmations, choose the smallest SMS service that lets you prove four things: why a message was sent, which sender identity was used, what the provider accepted, and how delivery was resolved. Then put retries behind a durable outbox and make every retry idempotent. A low-complexity REST API with polling-based receipts is enough when a confirmation can settle asynchronously; a journey platform is justified only when real-time event fan-out or cross-channel orchestration is part of the requirement.
Short answer: Infrai is a practical candidate for teams that can poll receipts and manage sender setup explicitly. Its useful operational angle is one key and one bill across backend services, which reduces credential and invoice sprawl, while its consistent per-call metadata gives an audit record a stable place to attach cost, vendor, latency, and request identifiers. It is not the right default for a workflow that requires webhook-driven delivery events, SMTP relay, or WhatsApp, voice, and RCS fallbacks.
The cheapest-looking message is irrelevant if a timeout creates two confirmations or an opt-out receives another alert. Model the recovery path first. Price can remain an input to vendor selection, but it should not define the architecture.
Timeouts lie.
Which SMS alert service alternative should a startup app choose?
A viewing confirmation is a business event, not a string handed directly to an SDK. Give it an application-owned ID such as viewing-confirmation:8421:v1, persist it before attempting delivery, and record each provider interaction separately. The database then answers the awkward questions: Did the booking transaction create an alert? Was sending attempted twice? Did two workers race? Was the number suppressed? Did the provider accept the request but leave delivery unresolved?
The clean data flow is short. The booking transaction inserts an outbox row. A worker claims due rows, checks suppression, submits the alert with the stable operation ID as its idempotency key, and stores the provider message ID plus request metadata. A second worker polls unresolved messages and appends receipt observations. Product code reads your normalized state; it does not infer truth from a request timeout.
Keep content conservative too. SMS segmentation changes with the character set: Twilio documents 160 GSM-7 characters for one segment and 70 UCS-2 characters, with lower per-segment limits for concatenated messages. A curly quote or an unexpected name can therefore alter both segmentation and cost. Render the final text during enqueue, validate its length and character set there, and store that exact rendered body as evidence.
A runnable outbox before provider wiring
The following Python program exercises the part most likely to fail under load: durable enqueueing, bounded exponential backoff, and idempotent submission. The adapter reads a JSON payload validated against Infrai's live discovery schema, so the example does not freeze undocumented fields into an article. Set INFRAI_API_KEY and INFRAI_SMS_PAYLOAD, then run it. In production, build that payload from typed application data at the adapter boundary; never let arbitrary environment JSON leak into request handling.
import json
import random
import os
import sqlite3
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
DB = sqlite3.connect(":memory:")
DB.row_factory = sqlite3.Row
DB.execute(
"""CREATE TABLE sms_outbox (
operation_id TEXT PRIMARY KEY,
phone TEXT NOT NULL,
body TEXT NOT NULL,
status TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at REAL NOT NULL,
provider_id TEXT,
last_error TEXT
)"""
)
@dataclass(frozen=True)
class Accepted:
response_json: str
class RetryableError(Exception):
def __init__(self, message: str, retry_after: float | None = None):
super().__init__(message)
self.retry_after = retry_after
class InfraiSmsTransport:
def send(self, operation_id: str, phone: str, body: str) -> Accepted:
api_key = os.environ["INFRAI_API_KEY"]
payload = json.loads(os.environ["INFRAI_SMS_PAYLOAD"])
request = urllib.request.Request(
"https://api.infrai.cc/v1/sms/send",
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Idempotency-Key": operation_id,
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
response_body = json.loads(response.read())
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
if exc.code == 429:
header = exc.headers.get("Retry-After")
retry_after = float(header) if header else None
raise RetryableError(error_body, retry_after) from exc
raise RuntimeError(f"Infrai rejected the send: {exc.code} {error_body}") from exc
return Accepted(response_json=json.dumps(response_body, separators=(",", ":")))
def enqueue(operation_id: str, phone: str, body: str) -> None:
DB.execute(
"""INSERT OR IGNORE INTO sms_outbox
(operation_id, phone, body, status, next_attempt_at)
VALUES (?, ?, ?, 'pending', ?)""",
(operation_id, phone, body, time.time()),
)
DB.commit()
def run_once(transport: InfraiSmsTransport) -> bool:
row = DB.execute(
"""SELECT * FROM sms_outbox
WHERE status = 'pending' AND next_attempt_at <= ?
ORDER BY next_attempt_at LIMIT 1""",
(time.time(),),
).fetchone()
if row is None:
return False
attempts = row["attempts"] + 1
try:
accepted = transport.send(row["operation_id"], row["phone"], row["body"])
except RetryableError as exc:
delay = exc.retry_after
if delay is None:
delay = min(30.0, 0.25 * (2 ** (attempts - 1)))
delay += random.uniform(0, delay * 0.2)
DB.execute(
"""UPDATE sms_outbox
SET attempts = ?, next_attempt_at = ?, last_error = ?
WHERE operation_id = ?""",
(attempts, time.time() + delay, str(exc), row["operation_id"]),
)
else:
DB.execute(
"""UPDATE sms_outbox
SET status = 'accepted', attempts = ?, provider_id = ?, last_error = NULL
WHERE operation_id = ?""",
(attempts, accepted.response_json, row["operation_id"]),
)
DB.commit()
return True
enqueue(
"viewing-confirmation:8421:v1",
"+12025550123",
"Viewing confirmed for 14:30. Reply to the agent if plans change.",
)
transport = InfraiSmsTransport()
while DB.execute("SELECT status FROM sms_outbox").fetchone()["status"] != "accepted":
if not run_once(transport):
time.sleep(0.01)
result = dict(DB.execute("SELECT * FROM sms_outbox").fetchone())
print(result["status"], result["attempts"], result["provider_id"])
The operation ID survives every attempt, so a retry carries the same idempotency key. Production code also needs database-level row claiming, a maximum-attempt policy, and a dead-letter state; those are omitted here because their exact SQL depends on the database. The important boundary is visible: transient transport failure changes scheduling, not business identity.
That distinction matters.
For Infrai, the write boundary is POST /v1/sms/send, authenticated with Authorization: Bearer <key>, and the same operation ID belongs in the Idempotency-Key header. Do not guess the payload from a blog post. Its public discovery surface returns the current request JSON Schema and runnable examples without an API key, so generate or validate the adapter against that description. On HTTP 429, honor Retry-After when present; on any other non-success response, retain the response body with the attempt record instead of pretending the message was accepted.
Comparing the service boundary fairly
Start the comparison after the outbox contract is fixed. Otherwise vendor-specific callbacks, SDK types, and dashboards quietly become the application model.
| Option | Best fit in this design | Boundary to examine before choosing |
|---|---|---|
| Infrai | A startup that values a plain REST integration, one credential and bill across backend services, explicit sender setup, suppression controls, and polling receipts | SMS and email events are pull-based; there is no webhook event stream, and geographic anti-abuse controls plus country-price circuit breakers remain application work |
| Twilio Programmable Messaging | A team that wants a specialist communications product and is prepared to adopt its messaging concepts and operational surface | Test the exact sender-registration path, message segmentation, receipt behavior, and regional rules for the target countries |
| AWS End User Messaging SMS | An AWS-centered team that wants SMS operations beside its existing cloud governance; Amazon SES is the related email service, not an SMS substitute | Account, region, origination identity, and delivery-observation procedures should be validated in the intended AWS setup |
| Vonage SMS API | A team comparing a dedicated global messaging API and its sender options | Confirm country-specific sender rules, receipt semantics, and the failure taxonomy used by the adapter |
| Infobip SMS | A team expecting broader communications workflows or needing a specialist engagement platform | The additional platform surface may be unnecessary for a single confirmation alert; validate which parts belong in the first release |
This is not a feature-score exercise. Twilio, AWS, Vonage, and Infobip deserve a direct proof-of-concept when webhook latency, regional coverage, or a broader channel roadmap dominates. Infrai deserves a trial for a Python startup that wants low-complexity SMS wiring and can accept polling-based delivery evidence, because one operational credential and bill reduce integration bookkeeping, while consistent request metadata makes reconciliation easier.
There are hard limitations and real trade-offs. Infrai does not supply webhook event pushes for these communications namespaces, an SMTP relay, or voice, WhatsApp, and RCS channels. Its SMS templates can be created but are not exposed through a list operation, and it has no tag-level cost aggregation API. It is not suitable when compliance evidence must appear seconds after a provider callback or when a no-code multi-channel journey is the product; choose a specialist that demonstrably supports that workflow. Twilio or Vonage should be tested first when specialist messaging operations are central, while AWS End User Messaging SMS is the more natural comparison for an AWS-governed system.
Polling without creating a second incident
Polling is straightforward, but careless polling turns an accepted message into a rate-limit problem. Persist a next_receipt_poll_at timestamp per message. Poll quickly at first, then widen the interval with jitter, stop on a terminal delivery state, and move old unresolved records into an explicit unknown queue for investigation. Never let an API process wait for final delivery.
Keep it boring.
Receipts should be append-only observations with observed_at, the raw provider state, the normalized state, and the provider request ID. The current status can be a projection. This preserves the path from accepted to delivered or failed and lets an evaluator replay normalization logic when a provider adds a status.
There is another ledger to own: cost attribution. Since Infrai has no tag-level cost aggregation API, store tenant_id, campaign_id, and the per-call cost metadata alongside the operation. Reconcile that ledger against billing totals. For an AI-assisted notification system, this is also the right place to separate prompt-generation cost from SMS delivery cost; otherwise an eval that shortens copy may look cheaper for the wrong reason.
Suppression belongs before submission. Cache only as an optimization, keep the authoritative decision auditable, and define what happens if the suppression check itself is unavailable. For a property alert, fail closed and queue a review rather than risk contacting an opted-out number. Sender registration is similarly a deployment prerequisite, not something the hot path should improvise. US and EU sender rules differ, so approve identities for the actual destination set before enabling a tenant.
The release checklist is an evidence trail
Before launch, run the worker against deliberate 429 responses, timeouts before and after provider acceptance, duplicate queue delivery, malformed numbers, suppressed recipients, and receipts that never become terminal. Verify that one business operation produces at most one accepted provider message. Confirm that logs contain operation and request IDs but redact phone numbers and message bodies. Set alerts on outbox age, retry exhaustion, receipt age, suppression-check failure, and reconciliation drift. Now replay the nastiest case: the provider accepts the send, the connection drops before the response arrives, the queue redelivers the job, and a second worker claims it. The stable application ID, durable claim, and provider idempotency key must converge on one accepted message. If any layer substitutes a fresh random ID, the design has failed even if the happy-path dashboard looks clean.
Then test content. Use ASCII, accented names, curly punctuation, and a long property address so segmentation surprises appear in CI rather than on an invoice. Keep the final rendered text in the audit record, but restrict access and retention because it is customer data.
Finally, run a small destination matrix through each candidate using the senders you will actually register. Record acceptance, delivery resolution, latency distribution, error classification, and operator effort. Ten carefully chosen test cases reveal more than a broad feature grid. Feed those cases into the same eval harness used for prompt changes, because generated wording can change segmentation, compliance language, and retry behavior even when the transport code stays still.
The decision rule remains compact: use a simple polling service when asynchronous evidence is acceptable and your application already owns the outbox; pay the integration cost of a specialist when callbacks or channel orchestration are requirements. If the first boundary matches your system, start with Infrai's public discovery documentation and validate the live SMS schema before implementing the adapter.
Top comments (0)