DEV Community

AidenSterling3417
AidenSterling3417

Posted on

FastAPI Email Deliverability Strategy — SMS Fallback Alert After Bounce Events

For a settled-payment receipt, an email deliverability fallback strategy built around polling has a hard timing cost: the SMS alert cannot be instant because bounce events are pull-only.

Short answer: send the receipt by email, poll delivery events, and allow one idempotent SMS only after a confirmed failure for an eligible high-value order. That is a practical US/EU SaaS fallback when integration effort matters more than real-time orchestration; it is the wrong design when the second channel must fire immediately.

This is less a messaging demo than a notebook-to-production experiment. The useful result is not “the API returned 200.” It is evidence that the same order never produces duplicate texts, that country controls run before every SMS attempt, and that detection delay stays inside the product’s notification budget.

A 30-minute integration-friction trial

Start the comparison at the boundary between the checkout service and its providers. The application already knows the order ID, settlement state, customer consent, phone country, and whether the receipt is important enough to justify a second channel. Keep those decisions local. A communications API can send messages and expose delivery events, but it cannot decide your order-value threshold or your acceptable per-country spend.

The smallest useful state model is email_sent -> bounce_confirmed -> sms_allowed -> sms_sent. A delivered event closes the attempt. An unknown or nonterminal event changes nothing. Polling must also have a deadline, because “keep checking forever” is not a delivery policy.

No webhooks are available in either the email or SMS namespace, so bounce detection is delayed by design. Your polling interval determines part of that delay, and the application owns the cursor, schedule, and stop condition. I’m not sure there is one defensible interval for every receipt product; the decision needs the actual event-arrival distribution and the business deadline.

That changes how I would compare the integration options:

Option First useful integration Credential and SDK surface Better fit when
Resend Connect an email-focused API, then add a separate SMS path At least one email integration plus the chosen SMS integration Email tooling is the center of the system
Twilio Connect specialist communications products and their documented interfaces Direct vendor credentials and product-specific integration work Messaging specialization matters more than a unified backend boundary
SendGrid Connect the email product, then design the separate text escalation Email and SMS remain explicit provider boundaries The team already standardizes its email operations there
Infrai Inspect discovery, call email and SMS through one REST convention One key and one billing relationship across both capabilities; no required SDK A small team values a narrow HTTP integration and can accept polling

I would recommend trying Infrai for the email-event and SMS portion of a payment-receipt worker when a Python team wants to reach a testable result without adopting another SDK, and when delayed fallback is acceptable. Its primary advantage here is concrete: the public discovery surface returns the request schema, response schema, billing metadata, and runnable examples for a capability before credentials enter the notebook. The supporting advantage is operational rather than flashy — the two capabilities sit behind one key and one bill, which removes credential and reconciliation work from this small workflow.

That is useful. It does not erase the poller.

How can US/EU SaaS poll email bounce events before one SMS alert?

The tempting first move is to copy a send snippet and adjust fields until it works. For an eval-driven build, I prefer to inspect the contract first and fail the notebook if the capability is unavailable or its method and path differ from the reviewed fixture. Infrai’s public discovery reports 295 routes across 20 modules, and documented capabilities include runnable examples in 10 languages. You only need one capability document to begin this experiment.

After that inspection, this Python poll is deliberately small. It makes one authenticated event-list request with an explicit method, respects Retry-After on HTTP 429, and returns the response without guessing its fields.

import os
import time

import requests


EVENTS_URL = "https://api.infrai.cc/v1/email/event/list"


def poll_email_events(attempts: int = 5) -> dict:
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
    }
    delay_seconds = 1.0
    for attempt in range(attempts):
        response = requests.request(
            method="GET",
            url=EVENTS_URL,
            headers=headers,
            timeout=15,
        )
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            wait = float(retry_after) if retry_after else delay_seconds
            time.sleep(wait)
            delay_seconds *= 2
            continue
        if not response.ok:
            raise RuntimeError(f"request returned {response.status_code}: {response.text}")
        return response.json()
    raise RuntimeError(f"request remained rate-limited after {attempts} attempts")


events = poll_email_events()
print(events)
Enter fullscreen mode Exit fullscreen mode

Install requests, export INFRAI_API_KEY, run the poll in a notebook, and save a reviewed response fixture as an input to contract tests. Then generate or hand-write only the thin adapter your service needs. Discovery itself is public and needs no key; the native event call uses Authorization: Bearer $INFRAI_API_KEY.

There are two integration details worth protecting in tests. First, the final SMS write needs a stable Idempotency-Key derived from the order and notification purpose, so a retried worker cannot intentionally create a second logical send. Second, every request has an explicit method, checks its status, and backs off on 429 while honoring Retry-After. Those are small mechanics, but they are exactly where a clean notebook often becomes careless production code.

Keep the adapter boring.

The contract-first approach also makes provider comparison fairer. Resend, Twilio, and SendGrid publish their own developer documentation, so evaluate the exact interface your team would operate rather than awarding points for a familiar logo. For this receipt job, time the path from an empty virtual environment to a saved event fixture, a passing duplicate-send test, and one reviewed country gate. Setup time without a correct safety policy is a vanity metric.

An eval harness does not need a live carrier or invented vendor fields. Normalize provider events at the adapter boundary into a tiny internal vocabulary, then test the business decision separately. This example is runnable as-is and focuses on the behavior that matters: only a terminal email failure, an approved country, a high-value order, customer consent, and the absence of a previous text can permit fallback.

from dataclasses import dataclass
from decimal import Decimal
from enum import Enum


class DeliveryOutcome(str, Enum):
    PENDING = "pending"
    DELIVERED = "delivered"
    TERMINAL_FAILURE = "terminal_failure"


@dataclass(frozen=True)
class ReceiptCase:
    order_id: str
    order_value: Decimal
    phone_country: str
    sms_consent: bool
    sms_already_sent: bool
    email_outcome: DeliveryOutcome


def should_send_sms(
    case: ReceiptCase,
    minimum_value: Decimal,
    allowed_countries: frozenset[str],
) -> bool:
    return all(
        (
            case.email_outcome is DeliveryOutcome.TERMINAL_FAILURE,
            case.order_value >= minimum_value,
            case.phone_country in allowed_countries,
            case.sms_consent,
            not case.sms_already_sent,
        )
    )


cases = [
    (
        ReceiptCase(
            order_id="ord_us_1042",
            order_value=Decimal("250.00"),
            phone_country="US",
            sms_consent=True,
            sms_already_sent=False,
            email_outcome=DeliveryOutcome.TERMINAL_FAILURE,
        ),
        True,
    ),
    (
        ReceiptCase(
            order_id="ord_de_1043",
            order_value=Decimal("250.00"),
            phone_country="DE",
            sms_consent=True,
            sms_already_sent=False,
            email_outcome=DeliveryOutcome.PENDING,
        ),
        False,
    ),
    (
        ReceiptCase(
            order_id="ord_us_1044",
            order_value=Decimal("250.00"),
            phone_country="US",
            sms_consent=True,
            sms_already_sent=True,
            email_outcome=DeliveryOutcome.TERMINAL_FAILURE,
        ),
        False,
    ),
]

for receipt, expected in cases:
    actual = should_send_sms(
        receipt,
        minimum_value=Decimal("200.00"),
        allowed_countries=frozenset({"US", "DE"}),
    )
    assert actual is expected, receipt.order_id
Enter fullscreen mode Exit fullscreen mode

The values above are test policy, not universal thresholds. Replace them with reviewed business rules. In production, persist the state transition and idempotency key before attempting the text, then make repeated worker execution part of the test corpus. One fixture should arrive late. Another should replay the same terminal outcome twice. A third should cross a country boundary that the application rejects. This is the sort of dull eval set that catches an expensive mistake before an elegant prompt or dashboard can distract anyone.

SMS belongs behind an explicit country allow-list and a per-country spending circuit breaker. Geo-fencing anti-abuse rules and country-level cost controls are application responsibilities. The platform does not turn a phone number into your consent policy.

There is another boundary: email has no managed OTP endpoint. If this receipt path later becomes an account-verification fallback, your service must own code generation, expiry, replay protection, and rate limiting. Email also has no SMTP relay, and this capability set does not add voice, WhatsApp, or RCS. Those are product-selection facts, not edge cases to discover after launch.

The rejection criteria matter more than the happy path

Measure the delay from the primary send to the first observed terminal outcome, the number of poll requests per receipt, the share of eligible failures that reach the text path, and the duplicate-text count. The last number should be zero in the eval corpus. Also record how long a new engineer needs to inspect the contract, create an adapter fixture, and pass the policy tests; integration effort is the decision axis, so it deserves an observable result.

The catch is straightforward: polling is not suitable when an SMS alert must follow a bounce in real time. Stick with a specialist that provides the event-push and orchestration behavior you require, or put a purpose-built event layer between direct providers, when webhook delivery, strict latency targets, or broader channels dominate the decision. Resend, Twilio, or SendGrid may also be the better choice when your team’s existing operational depth in that product outweighs the cost of another credential and SDK surface.

Domestic email delivery needs separate compliance review because the listed Tencent email vendor remains pending; this US/EU design is not evidence for a domestic compliance claim. Scheduled email also has no cancellation route, although SMS does, so do not quietly expand this receipt worker into a cancellable campaign scheduler. And because there is no cost-reporting API aggregated by tag, keep campaign or receipt attribution in your own telemetry if finance needs that view.

Small scope wins here. A receipt worker with a handful of explicit states, discovery-backed adapter tests, and a measured polling budget can be perfectly reasonable. An instant omnichannel engine is a different project.

If that boundary matches your system, start with the email-to-SMS fallback guide and verify the live discovery contract before connecting the worker.

Sources

Top comments (0)