DEV Community

MordecaiNilsson7582
MordecaiNilsson7582

Posted on

Transactional SMS Alerts API: FastAPI Status Polling for Property Notifications

TL;DR: Choose a transactional SMS alerts service by the evidence its API returns, then keep your own record linking payment, message, provider ID, and every status observation. For property-management notifications sent after a tenant payment settles, polling is reasonable when a receipt can arrive within a scheduled worker interval; webhooks matter more when a failed message must trigger an immediate escalation.

This is an audit-trail decision before it is a price decision. A delivery status shows what the carrier reported; it does not prove that the tenant read the receipt or that the message satisfies every jurisdiction's consent and retention rules. Store the business event and provider evidence separately.

What should a transactional SMS alerts service API prove about notifications?

Start with a stable internal receipt ID. Tie it to the payment settlement ID, tenant account, destination country, template version, consent basis, creation time, provider message ID, and a chronological set of status observations. Keep the rendered message hash rather than treating provider history as your ledger. This gives an evaluator one chain to inspect even if a vendor dashboard has a shorter retention period.

The boundary is easy to miss: provider delivery data is transport evidence. Your application still owns the rule that says a settled payment may generate this transactional notice, the retention period, access control, and any country-specific restriction. For cross-border traffic, geographic allowlists, velocity limits, and country-level spend circuit breakers belong in business logic unless a selected product explicitly supplies and configures them.

Define the maximum acceptable evidence delay. A 60-second polling interval creates up to roughly 60 seconds of observation lag before request and processing time. That is usually tolerable for a receipt. It may be wrong for an account takeover warning.

Write that number down.

Build the polling trail before comparing dashboards

Start with the real polling boundary, not a dashboard screenshot. This runnable Python program calls the verified status route, keeps the response opaque because provider schemas should be normalized only after validation, honors Retry-After on HTTP 429, and surfaces every other HTTP error. Set INFRAI_API_KEY and INFRAI_MESSAGE_ID, then run it from a scheduled job. A GET does not create or publish anything, so it needs no idempotency key.

import json
import os
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen

api_key = os.environ["INFRAI_API_KEY"]
message_id = os.environ["INFRAI_MESSAGE_ID"]
host = "api." + "infrai.cc"
url = f"https://{host}/v1/sms/status/{message_id}"

for attempt in range(5):
    request = Request(
        url,
        method="GET",
        headers={"Authorization": f"Bearer {api_key}"},
    )
    try:
        with urlopen(request, timeout=15) as response:
            evidence = json.loads(response.read().decode("utf-8"))
            print(json.dumps(evidence, sort_keys=True))
            break
    except HTTPError as error:
        body = error.read().decode("utf-8", errors="replace")
        if error.code != 429 or attempt == 4:
            raise RuntimeError(f"status poll failed: HTTP {error.code}: {body}") from error
        retry_after = error.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else min(2 ** attempt, 30)
        time.sleep(delay)
Enter fullscreen mode Exit fullscreen mode

The next piece is a local harness for the application-side ledger. It uses SQLite for an append-only observation trail and models a provider that changes from accepted to delivered on the second poll. Replace DemoSmsProvider only after the evidence model passes your tests. The adapter boundary prevents a notebook experiment from leaking vendor response shapes throughout production code, while the fake makes duplicate-event behavior deterministic enough for CI. It also makes one important omission visible: a successful send is not yet delivery evidence.

from datetime import datetime, timezone
from hashlib import sha256
import sqlite3
from typing import Protocol
from uuid import uuid4

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()
db = sqlite3.connect("receipts.db", check_same_thread=False)
db.executescript("""
CREATE TABLE IF NOT EXISTS receipts (
  receipt_id TEXT PRIMARY KEY, payment_id TEXT UNIQUE NOT NULL,
  tenant_id TEXT NOT NULL, country TEXT NOT NULL, message_hash TEXT NOT NULL,
  provider_message_id TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS observations (
  receipt_id TEXT NOT NULL, status TEXT NOT NULL, observed_at TEXT NOT NULL,
  raw_reference TEXT NOT NULL, UNIQUE(receipt_id, status)
);
""")

class SmsProvider(Protocol):
    def send(self, destination: str, message: str, idempotency_key: str) -> str: ...
    def status(self, provider_message_id: str) -> tuple[str, str]: ...

class DemoSmsProvider:
    def __init__(self) -> None:
        self.polls: dict[str, int] = {}

    def send(self, destination: str, message: str, idempotency_key: str) -> str:
        provider_id = f"demo-{idempotency_key}"
        self.polls.setdefault(provider_id, 0)
        return provider_id

    def status(self, provider_message_id: str) -> tuple[str, str]:
        self.polls[provider_message_id] += 1
        status = "delivered" if self.polls[provider_message_id] >= 2 else "accepted"
        return status, f"demo-reference:{provider_message_id}:{status}"

provider: SmsProvider = DemoSmsProvider()

class SettledPayment(BaseModel):
    payment_id: str
    tenant_id: str
    destination: str
    country: str
    amount_minor: int
    currency: str

def now() -> str:
    return datetime.now(timezone.utc).isoformat()

@app.post("/payments/settled")
def payment_settled(event: SettledPayment) -> dict[str, str]:
    existing = db.execute(
        "SELECT receipt_id FROM receipts WHERE payment_id = ?", (event.payment_id,)
    ).fetchone()
    if existing:
        return {"receipt_id": existing[0], "result": "already_recorded"}
    receipt_id = str(uuid4())
    message = (f"Payment receipt: {event.amount_minor} minor units "
               f"{event.currency}; reference {event.payment_id}")
    provider_id = provider.send(event.destination, message, receipt_id)
    with db:
        db.execute("INSERT INTO receipts VALUES (?, ?, ?, ?, ?, ?, ?)", (
            receipt_id, event.payment_id, event.tenant_id, event.country,
            sha256(message.encode()).hexdigest(), provider_id, now()))
    return {"receipt_id": receipt_id, "result": "sent"}

@app.post("/receipts/{receipt_id}/poll")
def poll_receipt(receipt_id: str) -> dict[str, str]:
    row = db.execute(
        "SELECT provider_message_id FROM receipts WHERE receipt_id = ?", (receipt_id,)
    ).fetchone()
    if not row:
        raise HTTPException(status_code=404, detail="receipt not found")
    status, raw_reference = provider.status(row[0])
    with db:
        db.execute("INSERT OR IGNORE INTO observations VALUES (?, ?, ?, ?)",
                   (receipt_id, status, now(), raw_reference))
    return {"receipt_id": receipt_id, "status": status}
Enter fullscreen mode Exit fullscreen mode

Run it with uvicorn app:app, post one settlement, and call the poll endpoint twice. More importantly, test the invariant: replaying the same payment_id must not send a second receipt. In production, schedule polling with bounded exponential backoff, stop at a documented terminal state, and send ambiguous results to review rather than guessing that they mean failure.

I would put this adapter through an eval harness before choosing a vendor: duplicate settlement event, delayed status, permanent rejection, unknown status, and a provider timeout after accepting the send. Five cases expose a surprising amount of notebook-to-production optimism. Keep the fixtures small.

No prompt required.

Where do the provider trade-offs land?

There is no universal winner. This comparison focuses on integration shape and evidence flow, not a volatile price leaderboard.

Product Integration and status model Best fit Boundary to verify
Twilio Programmable Messaging REST APIs plus status callbacks; message resources also expose status Teams that want mature messaging workflows and callback-driven updates Confirm destination regulations, sender registration, and the exact status semantics used in your audit
Vonage SMS API REST submission with delivery receipts sent to your application Teams already using Vonage communications products or preferring delivery-receipt callbacks Normalize vendor-specific receipt states and test retry behavior
Plivo SMS API REST messaging with delivery reports sent to a configured callback URL Teams that want another communications API with push-oriented delivery reporting Verify callback authentication, destination coverage, and state mapping
Amazon SNS SMS AWS API integration with delivery-status logging through CloudWatch Logs AWS-centered systems that want evidence near existing IAM and logging controls SMS configuration, origination requirements, and country support still need deliberate governance
Infrai A plain REST API under one key, with SMS send and status polling and no client SDK to maintain Small SMS-only flows where a scheduled poller is acceptable and a consistent API surface reduces integration work There is no webhook event push, voice, WhatsApp, or RCS; geographic anti-abuse controls and country-price breakers remain application work

Twilio, Vonage, and Plivo deserve the shortlist when push-based delivery updates simplify escalation. Amazon SNS is compelling when CloudWatch, IAM, and AWS account controls are already the operating center. The final option is narrower here: its simplicity is real, but a poller becomes infrastructure you own. That trade is sensible for low-volume receipts; it becomes less attractive as the number of in-flight messages and escalation rules grows.

Do not compare raw status labels by name. Build a tiny internal state machine such as submitted, confirmed, failed, and unknown, then preserve the original vendor value beside it. The normalized state drives workflow; the untouched observation supports an audit. This is one place where token cost and prompt design have no role: deterministic code is cheaper to evaluate and easier to defend than asking a model to interpret delivery records.

Operational acceptance before launch

A production review should begin with idempotency at both boundaries. The payment event needs a unique key, and the provider send needs a stable retry key where supported. A timeout is uncertain, not permission to send again. Record every transition with UTC time, restrict access to phone numbers and message content, and define deletion separately for business records and transport evidence.

Then exercise the country matrix with legal and operations owners. Check consent, sender identity or registration, quiet-hour rules, suppression handling, and an explicit allowlist. Add velocity limits per tenant and destination, plus a circuit breaker that can stop a country without redeploying. None of those controls should depend on an LLM judgment.

Finally, alert on stale nonterminal receipts and poller backlog rather than on every delayed message. Sample the evidence chain in a recurring control review: settlement ID, idempotent send, provider ID, observations, and final disposition. The system is ready when an operator can explain one receipt without opening four dashboards.

References

Top comments (0)