Skip to main content

Transactional Email Delivery Infrastructure: Authentication, ESPs, and Event Pipelines

Build reliable transactional email delivery: SPF/DKIM/DMARC alignment, ESP selection, bounce handling, and idempotent webhook event pipelines for SaaS.

For full-stack developers and SaaS founders shipping password resets, receipts, and verification codes, the inbox is an infrastructure problem long before it is a content one. A perfectly coded template that never authenticates, never reconciles a bounce, or silently drops complaint events will still land in spam — and the fix lives in DNS, API contracts, and event stores, not in your HTML.

This guide covers the sending side: how a message actually reaches the inbox reliably, and how the feedback it generates flows back into your system. It assumes you have already nailed the rendering layer with Mastering Email HTML & CSS and are templating dynamic content through modern email templating engines. The remaining work — authentication, provider selection, bounce reconciliation, and webhook ingestion — is what separates a demo that emails your own Gmail from a system that delivers to millions of mailboxes without degrading sender reputation.

The Deliverability Ecosystem

Deliverability is governed by a reputation model maintained by mailbox providers — Gmail, Yahoo, and Microsoft (Outlook.com/Hotmail) account for the majority of consumer inboxes. Each provider scores senders continuously, and that score, not your content, determines inbox placement. Reputation is tracked along two axes that you must manage independently:

  • IP reputation is tied to the sending IP address. On a shared IP, you inherit the aggregate behavior of every other tenant on that pool; one bad neighbor can drag your placement down, but you also benefit from established volume without warm-up. On a dedicated IP, your reputation is entirely your own, which is powerful at scale but requires deliberate warm-up — ramping volume gradually over two to four weeks so providers learn to trust the IP. A dedicated IP sending fewer than roughly 50,000 messages a month rarely accumulates enough signal to stay warm and often performs worse than a well-run shared pool.
  • Domain reputation is tied to your sending domain (the From and d= DKIM domain) and increasingly outweighs IP reputation at Gmail. It follows you even if you migrate IPs or change providers, which is why authentication must be anchored to a domain you control.

Since February 2024, Gmail and Yahoo enforce bulk-sender requirements that are now table stakes for any serious sender. The non-negotiable list:

Requirement What it means Enforced by
SPF and DKIM Both must pass, not either/or Gmail, Yahoo, Microsoft
DMARC alignment A published DMARC record with the From domain aligning to SPF or DKIM Gmail, Yahoo
One-click unsubscribe List-Unsubscribe + List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058) Gmail, Yahoo
Spam rate threshold Keep reported spam below 0.30%, ideally under 0.10% Gmail (Postmaster Tools)

Transactional senders sometimes assume the unsubscribe rules apply only to marketing mail. They apply to any sender crossing the bulk threshold (~5,000 messages/day to a provider), and Gmail does not draw a clean line between transactional and marketing traffic — mixing them on one domain pollutes the reputation of both. Keep transactional and marketing streams on separate subdomains (mail.example.com vs news.example.com) so a marketing spam spike never grounds your password-reset emails.

Core Architecture: The Transactional Send Pipeline

Every reliable transactional system implements the same loop: an application event triggers a template render, the rendered message is handed to an ESP over an authenticated API, the ESP signs and dispatches it over SMTP, the mailbox provider accepts or rejects it, and the resulting events flow back into your store over webhooks. The diagram below shows that loop end to end.

Transactional send pipeline and event feedback loop An application event renders a template, dispatches through an ESP API over SMTP to the mailbox, and emits delivered, bounce, and complaint events that flow back through a webhook consumer into the event store and suppression list. Send Pipeline and Event Feedback Loop Forward send path App Event signup, reset Render template + inline ESP API sign + headers SMTP DKIM signed Mailbox Gmail, Yahoo Provider emits events: delivered, open, bounce, complaint Webhook signed POST Webhook Consumer verify + dedupe Event Store append-only Suppression List blocks resend Suppression check gates the next App Event render before any new send The feedback loop is the system: events suppress future sends and feed reputation monitoring. Authentication anchors the forward path; idempotent ingestion protects the return path.
The end-to-end transactional pipeline: a forward send path (event to mailbox) and a return event path (webhooks to suppression) that together form a closed loop.

The forward path is where most teams stop, and it is exactly where reputation is silently lost. The two details that matter most at the API boundary are a stable, unique Message-Id (so every downstream event can be correlated to the original send) and a correct List-Unsubscribe pair (so Gmail and Yahoo accept the message at all). The annotated dispatch below shows both, plus the suppression check that the feedback loop depends on.

# Production transactional dispatch using Amazon SES v2 (boto3) + a DynamoDB suppression check.
import uuid, boto3
from botocore.exceptions import ClientError

ses = boto3.client("sesv2", region_name="us-east-1")          # Amazon SES v2 client
suppression = boto3.resource("dynamodb").Table("email_suppressions")

def send_password_reset(to_addr: str, rendered_html: str, rendered_text: str) -> str | None:
    # 1. Gate on the suppression list FIRST. SES also has its own account-level
    #    suppression, but a local check avoids a wasted API call and a reputation hit.
    if suppression.get_item(Key={"address": to_addr.lower()}).get("Item"):
        return None  # hard-bounced or complained previously: never re-send

    message_id = f"{uuid.uuid4()}@example.com"  # stable id we control, for event correlation
    try:
        resp = ses.send_email(
            FromEmailAddress="no-reply@mail.example.com",   # transactional subdomain, DKIM-signed
            Destination={"ToAddresses": [to_addr]},
            Content={"Simple": {
                "Subject": {"Data": "Reset your password"},
                "Body": {
                    "Html": {"Data": rendered_html},
                    "Text": {"Data": rendered_text},      # SES requires a text part for best placement
                },
            }},
            # Custom headers SES forwards verbatim. Gmail/Yahoo 2024 rules require the
            # one-click unsubscribe pair even on transactional mail above the bulk threshold.
            Headers=[
                {"Name": "Message-Id", "Value": f"<{message_id}>"},
                {"Name": "List-Unsubscribe", "Value": "<https://example.com/u/abc>, <mailto:u@example.com>"},
                {"Name": "List-Unsubscribe-Post", "Value": "List-Unsubscribe=One-Click"},  # RFC 8058
            ],
            # Tags ride through to SES event webhooks (SNS/EventBridge) for correlation.
            EmailTags=[{"Name": "stream", "Value": "transactional"}],
        )
        return resp["MessageId"]  # SES's own id; store it alongside our Message-Id
    except ClientError as e:
        # MessageRejected/AccountSuspended from SES must NOT be retried blindly — log and alert.
        raise RuntimeError(f"SES send failed: {e.response['Error']['Code']}") from e

The suppression check at the top is what closes the loop: events ingested on the return path write to the same table this function reads. Skip it, and you will re-send to addresses that have already hard-bounced or filed complaints — the single fastest way to destroy domain reputation.

Email Authentication: SPF, DKIM, and DMARC Alignment

Authentication is the foundation of the forward path. Without it, mailbox providers cannot verify that you are authorized to send for your domain, and post-2024 they will reject or junk you outright. Three records work together, and the subtlety that trips up most teams is alignment — the published SPF, DKIM, and DMARC configuration guide walks through each in depth, but the relationships are summarized below.

Mechanism Verifies Aligns when Common failure
SPF Sending IP is authorized by the Return-Path domain Return-Path domain matches From domain >10 DNS lookups → permerror; ESP Return-Path differs from From (no alignment)
DKIM Body + headers signed by a key in d= domain DNS d= domain matches From domain ESP signs with its own domain, not yours → passes but does not align
DMARC Publishes policy; requires SPF or DKIM to align Either aligned mechanism passes Record present but p=none forever; no alignment despite SPF/DKIM passing

DMARC does not care whether SPF or DKIM merely pass — it requires at least one of them to pass and align with the From domain. An ESP that signs DKIM with its own domain (d=esp.com) will show a green DKIM pass while DMARC still fails alignment because From: you@example.com does not match. The fix is provider-side DKIM with a CNAME delegating a signing selector to your domain, so the d= value is example.com. A minimal aligned DMARC record looks like:

; DNS TXT record at _dmarc.example.com
; p=quarantine with rua aggregate reporting — the recommended steady state for a transactional sender.
v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com; adkim=s; aspf=s; pct=100

The strict alignment tags (adkim=s; aspf=s) prevent subdomain spoofing from counting as alignment, which matters once you split streams across subdomains.

ESP Selection and Integration

The provider you integrate determines your operational ceiling: deliverability tooling, webhook fidelity, inbound parsing, and how much reputation work you must do yourself. The ESP selection and integration guide covers migration mechanics, but the headline tradeoffs among the three most common transactional choices:

Concern Amazon SES SendGrid Postmark
Cost at scale Lowest ($0.10/1k) Mid Highest
Out-of-box deliverability You manage reputation/warm-up Managed pools available Strongest transactional reputation
Event delivery SNS/EventBridge (you wire it) Native webhooks + Event Webhook Native webhooks, fast
Best fit Cost-sensitive scale, AWS-native Mixed marketing + transactional Pure transactional, want it to just work

A useful rule: pick Postmark when you want excellent transactional placement with minimal reputation management and are willing to pay for it; pick Amazon SES when you are AWS-native, send at high volume, and have the engineering capacity to manage IPs and wire SNS event delivery yourself; pick SendGrid when you need one provider to span transactional and marketing. Whatever you choose, abstract the send call behind an interface so the choice is reversible — hardcoding one ESP's SDK throughout your codebase is a pitfall covered below.

Integration mechanics differ enough to shape your architecture. Amazon SES gives you the lowest cost and the deepest AWS integration, but event delivery is BYO: you subscribe an SNS topic or EventBridge rule to a configuration set and process notifications yourself, which is more wiring than the turnkey webhooks the others ship. Postmark draws a hard line between transactional and broadcast streams at the account level, refusing to send marketing-shaped mail on a transactional Server — an opinionated constraint that protects your reputation but means you cannot lazily route a newsletter through it. SendGrid and Mailgun sit in between, offering native signed webhooks and sub-account models out of the box. Across all four, anchor DKIM to your own domain via CNAME delegation on day one; doing so is what keeps the migration cost low, because your authentication survives the provider swap. The send interface itself should be narrow — a single send(message) taking a normalized message object and returning a provider message id — so each provider adapter is a thin translation layer rather than a leak of provider concepts into your application code.

Bounce and Complaint Handling

Every send eventually produces a non-delivery signal, and how you classify it determines whether your list stays clean. The distinction that matters most is permanence:

  • Hard bounce — permanent: the address does not exist or the domain is invalid. Suppress immediately and never retry.
  • Soft bounce — transient: full mailbox, greylisting, temporary server error. Retry with backoff; suppress only after repeated failures (typically 3–5 over several days).
  • Complaint — the recipient clicked "report spam." This is the most damaging signal to reputation; suppress on the first complaint, always, with no retry under any circumstance.

The bounce and complaint handling guide details feedback-loop (FBL) registration and the suppression schema. The decision logic for an incoming event:

event.type == "bounce" && event.bounceType == "Permanent"  → suppress(address), do not retry
event.type == "bounce" && event.bounceType == "Transient"  → increment soft_count; suppress if soft_count >= 5
event.type == "complaint"                                  → suppress(address) immediately, log for FBL audit

Treating every bounce as permanent — a frequent shortcut — needlessly burns deliverable addresses when a mailbox was merely temporarily full. Treating complaints as ordinary bounces is worse: complaints feed directly into the provider spam-rate metric that gates inbox placement.

The asynchronous nature of these signals is what makes them easy to mishandle. A bounce often arrives seconds after the send, but a complaint can land hours later, after the recipient has opened the mail and decided it was junk — which is why the suppression decision belongs on the return path, driven by webhook events, and never inline in the send response. SES surfaces bounces and complaints through SNS notifications whose bounceType/complaintFeedbackType fields carry the classification; SendGrid and Mailgun expose equivalent bounce, dropped, and spamreport event types. Register for each provider's feedback loop so complaints filed at the mailbox propagate back to you rather than accumulating silently against your reputation. One subtlety worth encoding explicitly: a transient bounce that later resolves should clear its soft-bounce counter, otherwise a recipient who was merely on vacation eventually crosses your soft-bounce threshold and gets wrongly suppressed.

Webhook Event Pipelines

The return path is only trustworthy if it is idempotent. Providers retry webhook deliveries aggressively and make no at-most-once guarantee, so the same delivered or bounce event will arrive more than once. The webhook event pipeline guide covers signature verification per provider; the core ingestion contract is to verify, deduplicate on a provider-supplied event id, then write to an append-only store:

// Idempotent SendGrid Event Webhook consumer (Express). Verifies the ECDSA signature,
// then dedupes on sg_event_id before persisting — Postmark/SES/Mailgun follow the same shape.
app.post("/webhooks/sendgrid", verifySendGridSignature, async (req, res) => {
  // 1. ACK fast: respond 2xx so SendGrid stops retrying, then process the batch.
  res.sendStatus(200);
  for (const event of req.body) {
    // 2. sg_event_id is unique and stable per event — the idempotency key.
    const inserted = await db.query(
      "INSERT INTO email_events (event_id, message_id, type, address, ts) " +
      "VALUES ($1,$2,$3,$4,$5) ON CONFLICT (event_id) DO NOTHING RETURNING event_id",
      [event.sg_event_id, event.sg_message_id, event.event, event.email, event.timestamp]
    );
    if (inserted.rowCount === 0) continue;            // duplicate delivery: already stored, skip
    if (event.event === "bounce" || event.event === "dropped") await suppress(event.email);
    if (event.event === "spamreport") await suppress(event.email);  // complaint → immediate suppress
  }
});

The ON CONFLICT ... DO NOTHING clause is the entire idempotency guarantee: a replayed event hits the unique constraint on event_id and is skipped before any side effect (suppression, counter increment) runs twice. Acknowledging with a 2xx before processing keeps the provider from piling on retries while you work through a batch.

Cross-Provider Compatibility Matrix

The capabilities you can rely on differ sharply by provider. This matrix maps the subsystems above onto the four mainstream transactional ESPs:

Feature Amazon SES SendGrid Postmark Mailgun
DKIM signing (your domain) Yes (CNAME delegation) Yes Yes Yes
Dedicated IP Yes (add-on) Yes (paid tier) Yes (paid) Yes (paid)
Inbound parse Yes (SES → S3/Lambda) Yes (Inbound Parse) Yes Yes (Routes)
Webhook signing Via SNS message signature ECDSA Event Webhook HTTP basic / no native HMAC HMAC signature
Suppression API Yes (account-level) Yes Yes Yes
Sub-accounts No (use multiple IAM/configs) Yes (Subusers) Yes (Servers) Yes (Domains)

The two columns that catch teams off guard: SES has no first-class sub-account concept (you partition with IAM and configuration sets instead), and Postmark intentionally separates transactional from broadcast "Servers" rather than offering subusers. Plan multi-tenant isolation around whichever model your provider actually supports.

Common Architectural Pitfalls

  • No DMARC alignment despite passing SPF/DKIM — root cause: the ESP signs DKIM with its own d= domain and uses its own Return-Path, so both pass but neither aligns with your From domain; DMARC still fails. Fix with CNAME-delegated DKIM and a custom MAIL FROM.
  • Treating all bounces as permanent — root cause: collapsing transient and permanent bounce subtypes into one handler suppresses recoverable addresses, shrinking your deliverable base over time.
  • Not honoring complaints — root cause: complaint events are ingested but not acted on (or are merged into bounce logic), so the same recipient keeps receiving mail and keeps reporting it, driving the provider spam rate above the 0.30% kill threshold.
  • Missing idempotency on webhooks — root cause: no unique constraint on the provider event id, so retried deliveries double-count opens, double-suppress, or corrupt reputation dashboards.
  • Hardcoding one ESP — root cause: the provider SDK is called directly throughout the codebase rather than behind an interface, making a migration (or a failover) a multi-week rewrite instead of a config change.
  • Mixing marketing and transactional on one domain/IP — root cause: a marketing spam spike degrades the shared reputation that your password-reset mail depends on; isolate streams on separate subdomains.

Build-Pipeline Integration

Authentication, ESP integration, and webhooks are not deploy-time afterthoughts — they belong in the same build pipeline that produces your HTML. The render stage that feeds the dispatch function above should run CSS inlining automation so the message that hits the ESP API is already client-safe, and the whole template set should pass through your email testing and QA workflows before any send code runs against it. Tie the pieces together like this:

  • Secrets management — ESP API keys, DKIM private keys (if self-signing), and webhook signing secrets never live in source. Inject them from a secrets manager (AWS Secrets Manager, Vault, GitHub Actions encrypted secrets) at deploy time. A leaked SES key is a spam-cannon handed to an attacker and a near-instant reputation loss.
  • CI checks — fail the build if a template lacks a text part, if the List-Unsubscribe header is missing on a bulk stream, or if DNS authentication records drift (a scheduled DMARC/SPF lookup in CI catches a stale record before it grounds production mail).
  • Staging isolation — point non-production environments at a sandbox (SES sandbox mode, Mailpit, or a Mailtrap-style sink) so test runs never touch real recipients or your production reputation.
  • Event store as the source of truth — wire the webhook consumer's append-only store into the same observability stack as the rest of your services, so a rising bounce or complaint rate pages you the same way a 5xx spike does.

Warming a Dedicated IP: A Concrete Schedule

If you provision a dedicated IP — the right choice only once you reliably exceed ~50,000 messages a month to a single provider — it begins life with zero reputation. Mailbox providers treat a brand-new IP that suddenly emits high volume as the signature of a compromised host or a spam operation, so you must ramp deliberately. The principle is to roughly double daily volume from a low base while watching the feedback the early sends generate, holding the ramp the moment bounce or complaint rates climb.

Day Max messages/day What to watch
1–2 50 Gmail Postmaster "IP reputation" begins populating; all sends should be to your most engaged recipients
3–5 200 First delivered/open signals; abort the ramp if any complaints appear
6–9 1,000 Bounce rate must stay under ~2%; soft bounces (greylisting) are expected and clear on retry
10–14 5,000 Spam rate in Postmaster Tools should read green (<0.10%)
15–21 25,000 Hold a day at each step if reputation dips from "high" to "medium"
22–28 100,000 Approaching steady state; widen the recipient set beyond the most-engaged cohort
29+ double daily to target Sustain consistent daily volume — a dormant IP cools and must be re-warmed

Two rules make or break a warm-up. First, send to your most engaged recipients first — recent signups, active users — because opens and replies are the positive signal that builds reputation fastest; warming on a cold or purchased list does the opposite. Second, keep the daily cadence consistent: a smooth ramp reads as an organically growing sender, while a jagged on/off pattern reads as a botnet. The same warm-up discipline applies when you cut a sender over to new infrastructure, as covered in migrating from SendGrid to Amazon SES. On a reputable shared pool, the provider handles warm-up for you, which is precisely why a low-volume sender should stay shared rather than acquire a dedicated IP it cannot keep warm.

The Gmail and Yahoo 2024 Bulk-Sender Rules in Depth

The February 2024 requirements are worth unpacking beyond the summary table above, because the failure modes are specific and each one silently degrades placement rather than producing a clean error. The rules apply to any domain sending roughly 5,000 or more messages in a day to Gmail or Yahoo users, counted per provider — and crucially, Gmail counts all mail from your domain, so transactional and marketing volume sum toward the threshold.

  • SPF and DKIM both required, not either/or. Pre-2024, passing one was tolerated. Now Gmail and Yahoo expect both to be present and passing on bulk mail. A message with DKIM but no SPF, or vice versa, is increasingly throttled. Anchor both to a domain you control; the mechanics are in the SPF, DKIM, and DMARC guide.
  • DMARC at minimum p=none, with alignment. A published DMARC record is mandatory, and at least one of SPF or DKIM must align with the visible From domain. The most common trap is a record that exists but never enforces and an ESP that signs with its own d= domain — both checkboxes appear green while alignment quietly fails. Move through enforcement using the staged DMARC rollout.
  • One-click unsubscribe (RFC 8058). Bulk mail must carry both List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click, and the unsubscribe must take effect within two days. Gmail renders a prominent unsubscribe link when both headers are present; their absence is a negative signal even on transactional-shaped mail above the threshold.
  • Spam rate below 0.30%, ideally under 0.10%. Measured in Google Postmaster Tools as user-reported spam. Cross 0.30% and Gmail throttles aggressively; sustained breaches effectively block the domain. This is why complaint handling is not optional — the bounce and complaint handling loop exists to keep this number green.
  • Valid forward and reverse DNS (PTR) on sending IPs. The connecting IP must have a PTR record resolving back to a hostname that forward-resolves to the same IP. ESPs handle this on their own IPs; it matters when you send from your own infrastructure.

Microsoft announced equivalent enforcement for high-volume senders to Outlook.com and Hotmail in 2025, so treat the Gmail/Yahoo list as the baseline for every consumer mailbox, not a Google-specific quirk. The single most effective structural move is to isolate transactional mail on its own subdomain (mail.example.com) separate from marketing (news.example.com): each stream then sits under the per-provider threshold on its own, and a marketing spam spike can never ground your password-reset mail.

Frequently Asked Questions

Why are my Amazon SES emails going to spam even though sending succeeds?
A 200/MessageId from SES means SES accepted the message, not that the mailbox did. The usual causes are missing DMARC alignment (DKIM signed with SES's domain rather than yours via CNAME delegation), a cold dedicated IP with no warm-up, or a missing List-Unsubscribe pair tripping Gmail's 2024 rules. Verify alignment in Google Postmaster Tools and confirm your From domain matches your DKIM d= value.

Should a transactional sender use a dedicated or shared IP?
Shared, until you are reliably sending well above ~50,000 messages a month to a single provider. Below that volume a dedicated IP never accumulates enough signal to stay warm and typically performs worse than a reputable shared pool. Dedicated IPs pay off only at sustained high volume where you want full control of your own reputation curve.

What DMARC policy should a transactional sender publish?
Start at p=none purely to collect aggregate (rua) reports and confirm alignment, then move to p=quarantine once reports show SPF or DKIM aligning consistently. p=quarantine with strict alignment is the recommended steady state for transactional mail; advance to p=reject only after weeks of clean reports, since reject makes every alignment gap a hard delivery failure.

Do the Gmail/Yahoo bulk-sender rules apply to transactional email?
Yes, once you cross the bulk threshold (~5,000 messages/day to a given provider). The providers do not exempt transactional traffic, so SPF+DKIM+DMARC alignment, the one-click List-Unsubscribe header, and the sub-0.30% spam rate all apply. Isolating transactional mail on its own subdomain keeps it under threshold per stream and protects it from marketing spikes.

How do I stop webhook retries from corrupting my event data?
Make ingestion idempotent: deduplicate on the provider's unique event id (sg_event_id for SendGrid, the SNS MessageId for SES) with a unique constraint and ON CONFLICT DO NOTHING, and acknowledge with a 2xx before processing. A replayed event then hits the constraint and is skipped before any suppression or counter side effect runs twice.

Can I switch ESPs without rebuilding everything?
Only if you designed for it. Put every send behind a thin interface (send(message)) and keep DKIM anchored to your own domain via CNAME delegation rather than the provider's domain. Then a migration is re-pointing DNS selectors and swapping the adapter — the ESP migration path covers the cutover sequence. Hardcoded provider SDKs turn the same move into a rewrite.

How long does it take to warm a dedicated IP?
Plan for two to four weeks of steady ramping for a typical transactional volume, roughly doubling daily sends from a base of ~50 while keeping bounce and complaint rates green in Google Postmaster Tools. Send to your most engaged recipients first and keep the daily cadence consistent — a jagged on/off pattern reads as a botnet and stalls the warm-up. If your volume cannot sustain the IP afterward (below ~50,000/month), the IP cools and you are better off on a reputable shared pool that the provider warms for you.

Should transactional and marketing email share a sending domain?
No. Keep them on separate subdomains — mail.example.com for transactional, news.example.com for marketing. Gmail does not exempt transactional traffic from its bulk-sender rules and counts all mail from a domain toward the 5,000/day threshold, so mixing streams lets a marketing spam spike degrade the reputation your password-reset mail depends on. Separate subdomains keep each stream under threshold on its own and isolate their reputations, which is also why the dispatch example above sends from a dedicated transactional subdomain.


← Back to Home