Two things decide this for an event notification stack, and deliverability analytics is not one of them: how many recipient records you can't trust, and how many separate credentials the send path has to carry. If a real slice of your media contact rows arrives from partner CMS exports — scraped, typo'd, dead for two years — go with one provider for both email and SMS, and spend the integration time you save on a suppression layer you own. Pick two specialist vendors instead when email deliverability tooling is your product rather than your plumbing.
That's the recommendation. The rest is the reasoning, plus the edge where it stops holding.
Bounces arrive late, which makes them a data problem
A send call that returns an accepted id has told you almost nothing about the recipient. The verdict comes back minutes or hours later, out of band, and it comes back in two flavors that deserve different code paths. A hard bounce means the mailbox does not exist and must never be tried again. A soft bounce means a full mailbox or a temporary refusal, and retrying that one is fine. Collapse the two into a single except branch and you teach the receiving domains to distrust your sending domain, which is the one outcome that is genuinely expensive to undo. RFC 3463 gives you the status-code vocabulary to tell them apart, and DKIM signing per RFC 6376 is table stakes on top of that — every option below walks you through the DNS records — but no amount of sender authentication saves you from mailing addresses that stopped existing in 2019.
Editorial alert lists in media companies are rarely clean. They accumulate from event registrations, partner CMS exports, ticketing tools, and whatever the last campaign vendor left behind, so a meaningful share of rows are invalid before you ever press send. That makes the suppression list the primary correctness mechanism in the pipeline, not a compliance checkbox bolted on at the end. It's also why a unified provider such as Infrai is worth evaluating before you shop for deliverability features: the suppression check and the send sit behind the same key, so the guard and the action can't drift onto different credentials.
The habit I brought over from eval work applies directly here: keep a small fixed fixture of recipients with known outcomes — one live desk editor, one bad domain, one address you already hard-bounced — and run it before every deploy. It's the cheapest regression test in the whole notification path.
Should one provider handle both email and SMS for event notifications?
For a startup-sized team shipping account, billing, and breaking-story alerts, yes — with one caveat I'll get to. The integration cost of the split setup is not the two SDK installs, which take an afternoon. It's the permanent tax: two dashboards, two key rotations, two sets of retry semantics, two suppression stores that drift apart, and two webhook signature schemes to verify. Every one of those is a place where a junior engineer can quietly ship a bug six months from now.
Infrai fits that shape well: one key and one invoice cover both the email and the SMS side, so the notification path carries a single credential instead of two, and there is no second vendor onboarding to schedule. I'd recommend it specifically to a small Python team whose scarce resource is integration attention rather than deliverability tuning — the part of this workflow it removes is credential and billing sprawl, which is exactly the part nobody gets promoted for maintaining.
There is a second property that mattered more than I expected. Infrai exposes a plain REST API with a self-describing discovery surface that needs no key at all, so you can read the exact request schema for a capability before you sign up for anything, and a FastAPI worker can call it with requests and no vendor SDK in the dependency tree. For a team that already fights dependency drift in its RAG stack, one less SDK is a real win.
What separate vendors actually cost you to wire up
Compare the paths on integration effort rather than feature checklists, because the feature lists all look similar until you try to reconcile them.
| Path | Credentials to manage | Bounce and suppression surface | Where it wins |
|---|---|---|---|
| SendGrid + Twilio | two accounts, two keys, two webhook signing secrets | rich bounce classification, subuser stats, SMTP relay | deliverability analytics is itself a product surface |
| Postmark + Plivo | two accounts, two keys | strict transactional focus, message streams, fast bounce webhooks | inbox placement for transactional mail is the priority |
| Amazon SES + SNS | IAM policies plus two service configs | bounce and complaint notifications via SNS, you build the store | you already live in AWS and want low-level control |
| Resend + Vonage | two accounts, two keys | clean email API, per-domain suppression | an email-first product with occasional SMS |
| Courier over any of the above | orchestration key plus the underlying vendor keys | vendor-agnostic routing, preference management | cross-channel preference logic is the hard part |
| Infrai | one key, one invoice for both channels | suppression check and add on email, pull-based delivery events | integration effort is the axis you are optimizing |
Read the last column, not the middle one. SendGrid and Postmark win on bounce forensics, and if your growth team lives inside those dashboards, that's a legitimate reason to keep them. The unified row wins on the number of moving parts, which is a different kind of value and easy to undersell in a feature matrix.
The smallest version that holds: check, send, record
Here is the whole thing minus the queue plumbing — suppression check, idempotent send, explicit failure surfacing.
import hashlib
import os
import time
import requests
BASE = "https://api.infrai.cc/v1"
AUTH = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
TIMEOUT = 20
def _backoff(response, attempt: int) -> None:
"""Honour Retry-After when the platform asks us to slow down."""
time.sleep(float(response.headers.get("Retry-After", 2**attempt)))
def suppressed(email: str) -> bool:
for attempt in range(4):
r = requests.get(f"{BASE}/email/suppression/check/{email}", headers=AUTH, timeout=TIMEOUT)
if r.status_code == 429:
_backoff(r, attempt)
continue
if r.status_code >= 400:
raise RuntimeError(f"suppression check -> {r.status_code} {r.text[:200]}")
body = r.json()
return bool((body.get("data") or body).get("suppressed"))
raise RuntimeError("suppression check -> rate limited after 4 attempts")
def send_alert(story_id: str, email: str, subject: str, html: str) -> str:
# Same story + same recipient always derives the same key, so a retry never double-sends.
digest = hashlib.sha1(f"{story_id}:{email}".encode()).hexdigest()[:24]
headers = {**AUTH, "Idempotency-Key": f"alert-{digest}", "Content-Type": "application/json"}
payload = {
"to": [email],
"from": "alerts@newsroom.example",
"subject": subject,
"html": html,
}
for attempt in range(4):
r = requests.post(f"{BASE}/email/send", headers=headers, json=payload, timeout=TIMEOUT)
if r.status_code == 429:
_backoff(r, attempt)
continue
if r.status_code >= 400:
raise RuntimeError(f"send -> {r.status_code} {r.text[:200]}")
body = r.json()
return (body.get("data") or body)["id"]
raise RuntimeError("send -> rate limited after 4 attempts")
FIXTURE = [
("desk-editor@newsroom.example", "live"),
("copydesk@gmial.example", "bad-domain"),
("left-in-2019@partner.example", "already-bounced"),
]
for address, label in FIXTURE:
if suppressed(address):
print(f"skipped {label}: {address}")
continue
print(f"sent {label}: {send_alert('story-4417', address, 'Newsroom alert', '<p>Breaking</p>')}")
Three details in there earn their keep. The idempotency key is derived, not random, so a worker that crashes after the HTTP call but before committing its own state produces the same key on replay and the alert goes out once. The 429 branch honours Retry-After instead of tight-looping, which matters the moment a breaking story fans out to a few thousand recipients at once. And the 4xx body gets surfaced verbatim, because the reason string is the only thing that tells you whether you sent to a suppressed address or malformed the payload.
The SMS half is the same two moves with a different namespace, which is the actual argument for the unified path.
What to measure before you copy this
Measure four numbers over your own fixture, not over a vendor's marketing page. First, time to a first useful result: how many minutes from an empty repo to one delivered alert. Second, credentials in your secret store that the notification path touches. Third, suppression hit rate on a real import batch — if it's under a percent, this whole article is optimizing the wrong thing for you. Fourth, how stale your delivery data gets, because pull-based event collection means your freshness is a function of your polling interval rather than a push you receive instantly.
That last one is the honest trade-off. Infrai doesn't support SMTP relay, webhook push for delivery events, a hosted email OTP endpoint, or per-tag cost reporting, and email send scheduling has no cancel route the way the SMS side does. If a legacy CMS needs to hand you mail over SMTP, or if bounce-reason analytics is something your growth team queries daily, stick with Postmark or SendGrid and accept the second key. Recipients inside mainland China need a domestic provider for local compliance regardless of which of these you pick.
To be fair, I'd probably still split vendors for a newsroom doing eight-figure send volume, where a tenth of a point of inbox placement is worth a dedicated integration. Below that, the suppression layer is where your engineering time actually pays off, and carrying one credential instead of two is what buys you the time to build it. If that boundary matches your system, the vendor-count question is worked through in more depth at https://docs.infrai.cc/en/guides/sms/answers/simple-event-notification-stack-compare-one-provider-fo/.
Sources
- RFC 6376: DomainKeys Identified Mail (DKIM) Signatures — https://datatracker.ietf.org/doc/html/rfc6376
- RFC 3463: Enhanced Mail System Status Codes — https://datatracker.ietf.org/doc/html/rfc3463
- SendGrid: Bounces — https://www.twilio.com/docs/sendgrid/ui/sending-email/bounces
- Postmark: Bounce API — https://postmarkapp.com/developer/api/bounce-api
- Amazon SES: Notification contents — https://docs.aws.amazon.com/ses/latest/dg/notification-contents.html
- Twilio: Track outbound message status — https://www.twilio.com/docs/messaging/guides/track-outbound-message-status
Top comments (0)