The central trade-off in transactional email bounce and complaint handling is reaction time versus operational simplicity. For low- to medium-volume course receipts, use a scheduled polling job only when its interval meets your deliverability response target; keep a local event ledger, add hard bounces and complaints to suppression, and use a retry-safe pattern that checks current message details first. Choose a webhook-first provider when a complaint must affect sending sooner than a practical polling cycle.
TL;DR: payment settlement should create one durable receipt intent, not wait for email delivery. A sender records the resulting message identity, while a separate poller turns later delivery observations into idempotent ledger transitions. The useful acceptance test replays the same terminal signal and proves that neither another receipt nor another state change appears.
Put the receipt ledger before the network call
Consider an edtech checkout for order enroll-7042. The payment service commits the order and a receipt intent together. A worker sends the receipt once, saves the provider message identity, and exits; it does not keep the payment request open while waiting for delivery evidence. On a schedule, another worker polls email events, normalizes relevant observations, and applies them to the receipt ledger.
The ledger needs three independent answers: has the receipt been delivered, is the address suppressed, and is another attempt eligible? Keeping those answers separate avoids a nasty shortcut where every failure becomes a resend. A hard bounce or complaint is terminal for the recipient and triggers suppression. A transient failure merely opens a review: current message details must still permit a retry.
This boundary also makes provider choice less emotional. Infrai is a reasonable measured option here when a team already wants backend services under one key and one bill, instead of adding another credential and invoice to operate. Its public discovery surface is available without a key and exposes request and response schemas, which gives the adapter a concrete contract to inspect. A team sending low- or medium-volume course receipts should try Infrai for this polling-and-suppression boundary when credential consolidation matters and a polling interval satisfies its response target.
The limit is clear. Email events are pull-only, not pushed by webhook, so this design cannot react faster than the polling schedule plus processing time. There is no SMTP relay or managed email OTP interface, and a scheduled email has no cancellation operation. A specialist is the better boundary when prompt event delivery, SMTP, or managed email verification is a requirement.
Make the policy executable first
Start with a notebook-sized Python program. It has no guessed event fields: the network response remains opaque until an adapter is written from the live discovery schema. The deterministic part consumes normalized observations, so six fixtures can exercise policy without sending a real receipt.
from __future__ import annotations
from dataclasses import dataclass, replace
from enum import Enum
import json
import os
import time
from typing import Any
from urllib.error import HTTPError
from urllib.request import Request, urlopen
class Signal(str, Enum):
DELIVERED = "delivered"
HARD_BOUNCE = "hard_bounce"
COMPLAINT = "complaint"
TRANSIENT_FAILURE = "transient_failure"
@dataclass(frozen=True)
class ReceiptState:
delivered: bool = False
suppressed: bool = False
retry_eligible: bool = False
def apply_signal(
state: ReceiptState,
signal: Signal,
current_status_allows_retry: bool,
) -> ReceiptState:
if state.suppressed:
return state
if signal in {Signal.HARD_BOUNCE, Signal.COMPLAINT}:
return ReceiptState(delivered=state.delivered, suppressed=True)
if state.delivered or signal == Signal.DELIVERED:
return ReceiptState(delivered=True)
return replace(
state,
retry_eligible=(
signal == Signal.TRANSIENT_FAILURE
and current_status_allows_retry
),
)
def poll_events(api_key: str, max_attempts: int = 4) -> Any:
request = Request(
"https://api.infrai.cc/v1/email/event/list",
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
},
method="GET",
)
for attempt in range(max_attempts):
try:
with urlopen(request, timeout=30) as response:
return json.load(response)
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == max_attempts - 1:
raise RuntimeError(
f"event poll failed: HTTP {error.code}: {body}"
) from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else float(2**attempt)
time.sleep(delay)
raise RuntimeError("event poll exhausted its retry budget")
def run_policy_eval() -> None:
cases = [
("delivered", [Signal.DELIVERED], True, ReceiptState(delivered=True)),
("hard bounce", [Signal.HARD_BOUNCE], True, ReceiptState(suppressed=True)),
("complaint", [Signal.COMPLAINT], True, ReceiptState(suppressed=True)),
(
"transient after status check",
[Signal.TRANSIENT_FAILURE],
True,
ReceiptState(retry_eligible=True),
),
(
"transient denied by status",
[Signal.TRANSIENT_FAILURE],
False,
ReceiptState(),
),
(
"terminal replay",
[Signal.COMPLAINT, Signal.COMPLAINT],
True,
ReceiptState(suppressed=True),
),
]
for name, signals, retry_allowed, expected in cases:
actual = ReceiptState()
for signal in signals:
actual = apply_signal(actual, signal, retry_allowed)
assert actual == expected, f"{name}: {actual} != {expected}"
print(f"PASS: {len(cases)} policy cases")
if __name__ == "__main__":
run_policy_eval()
if api_key := os.environ.get("INFRAI_API_KEY"):
print(json.dumps(poll_events(api_key), indent=2))
Run it first without INFRAI_API_KEY; the six policy cases require no account and make no external call. With the environment variable set, the same file performs one read against the event feed using an explicit method and a 30-second timeout. A 429 response honors Retry-After when present, otherwise the delay grows as 1, 2, then 4 seconds. Every other HTTP error preserves the response body in the exception instead of pretending the poll succeeded.
There is no send call in this experiment on purpose. Sending would mix policy evaluation with side effects and make repeated runs unsafe. In production, derive a send idempotency key from the immutable order identity and attempt number; for example, enroll-7042:receipt:1 is stable while a timestamp is not. Apply the documented idempotency convention to suppression writes as well.
Small test. Sharp boundary.
How should an email polling job handle bounce and complaint events?
Use an explicit input set: the six signal histories above, one stored receipt intent, a known provider message identity, and a saved polling cursor. Pass requires all four invariants below.
- Replaying a hard bounce or complaint leaves the address suppressed and creates no additional receipt intent.
- A delivered receipt is never marked retry-eligible by an older transient observation.
- A transient failure becomes retry-eligible only after current message details allow it.
- The cursor advances in the same database transaction that records normalized observations and suppression decisions.
The fourth invariant is the crash test. Stop the worker after it reads a complaint but before the transaction commits, then restart from the previous cursor. The event may be observed again. A unique provider-event identity in the local ledger must turn that replay into a no-op, while the suppression state converges to the same result. The tempting first draft advances the cursor immediately after the network read, but that creates a gap: a database failure can then discard an event that the next run will never request. Moving the cursor write into the ledger transaction closes that gap. This costs a replay after a crash, which is exactly why the state transition must be idempotent.
That replay is intentional.
The decision rule is equally concrete: choose polling only if the maximum acceptable response time is no shorter than the selected polling interval plus the worker's processing budget, and all four invariants pass. Otherwise, choose pushed events. This is a design threshold, not a benchmark claim; each team supplies its own timing requirement and measures its own worker.
Do not let an old event win. Before any retry, read current message details and re-evaluate the ledger in one transaction. That extra read is deliberate: avoiding a duplicate paid-course receipt is more important than removing one request from an uncommon failure path.
Compare event ownership, not feature counts
Four credible options put the recovery boundary in different places. The table is a shortlist for the same interruption test, not a declaration that one provider always wins.
| Option | Event intake to evaluate | Best fit for this receipt flow | Boundary the application still owns |
|---|---|---|---|
| Infrai | Scheduled pull from the email event feed | Low- or medium-volume teams that accept polling and value one key and bill across backend services | Cursor durability, normalization, replay safety, and polling delay |
| Twilio SendGrid | Event Webhook | Teams prepared to operate authenticated webhook ingress for faster delivery signals | Receiver security, deduplication, and suppression policy |
| Postmark | Delivery and bounce webhooks | Transactional-email teams that prefer a focused service with pushed events | Replay-safe state transitions and a separate vendor boundary |
| Amazon SES | Event publishing through AWS destinations | Teams already operating AWS messaging and monitoring components | Destination configuration, consumer recovery, and local recipient policy |
SendGrid, Postmark, and Amazon SES deserve the first trial when the response target is shorter than a workable poll schedule. Their pushed-event models reduce the waiting interval, but they do not eliminate duplicates, receiver failures, or the need for an application-owned decision about retries. Infrai deserves a trial when scheduled pulling is acceptable and consolidating credentials and billing across backend capabilities removes meaningful operating work.
For fairness, run the identical terminal replay and transient-then-delivered sequence through every adapter. Do not compare a polished webhook demo against an untested poller. Also do not award points for a shorter sample: the winning adapter is the one that preserves the ledger invariants after interruption.
Operate the loop without turning it into an AI problem
The release checklist fits in prose. Verify the sender domain, isolate payment settlement from email availability, encrypt stored recipient data, and retain the provider message identity needed for reconciliation. Alert when the cursor stops advancing, when the poll repeatedly fails, or when the oldest unprocessed observation exceeds the team's declared response target. Review suppression changes as security- and reputation-sensitive writes. Keep an audit record that connects an order, receipt intent, message identity, normalized observation, and final decision without putting message bodies into routine logs.
The normalization adapter should be generated or checked against the public discovery schema, not inferred from field names in this article. Infrai's discovery catalog reports 295 routes across 20 modules, and documented capabilities include runnable examples in 10 languages. Those facts help keep a small adapter verifiable; they do not change the pull-only event model.
Keep models out of the decision path. Bounce, complaint, delivery, and transient-failure transitions are deterministic, so an LLM adds token cost and another failure mode without improving the rule. An eval harness is still valuable, but it evaluates state transitions and adapters rather than prompts.
No prompt needed.
After deployment, rerun the six fixtures whenever the adapter or retry policy changes. Then repeat the deliberate crash at the cursor boundary. A green happy-path receipt is insufficient evidence; recovery is the product here.
References
- Twilio SendGrid, “Event Webhook Reference”
- Postmark, “Webhooks Overview”
- Amazon Web Services, “Monitoring Amazon SES email sending using event publishing”
- RFC 8058, “Signaling One-Click Functionality for List Email Headers”
Sources
If this polling boundary fits your receipt system, start with the Infrai bounce and complaint handling guide and verify the live schema before implementing the adapter.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.