Skip to main content

Choosing and Integrating a Transactional Email Service Provider

Compare Amazon SES, SendGrid, Postmark, and Mailgun for transactional mail, then build a provider-agnostic send abstraction with retries and idempotency.

Picking the wrong transactional sender is expensive to undo: your sending reputation, suppression history, and webhook plumbing all become coupled to one vendor's API shape. This guide breaks down the selection criteria that actually matter for transactional senders, weighs the real tradeoffs across Amazon SES, SendGrid, Postmark, and Mailgun, then shows how to build a provider-agnostic send abstraction so the choice is reversible. It sits under Transactional Email Delivery Infrastructure alongside the deliverability and event-processing work that any production sender depends on.

The problem: vendor coupling kills your exit options

A naive integration imports the vendor SDK directly in the code path that sends a password reset. Six months later the team wants to move because of pricing, a deliverability incident, or a data-residency requirement, and the migration touches every call site. Worse, the operational primitives an Email Service Provider gives you — suppression lists, bounce classification, complaint feedback, signed event webhooks — are modeled differently by each vendor, so switching means rebuilding behavior you assumed was "just email."

The selection decision therefore has two layers. First, which provider fits your volume, deliverability needs, and operational appetite. Second, how you integrate so that the first decision is not permanent. Both layers are covered below.

Selection criteria that matter for transactional senders

Evaluate every candidate against these axes. Marketing-oriented "feature count" comparisons are mostly noise for transactional mail; what follows is the short list that changes your architecture.

  • Deliverability reputation model. Do you sit on shared IPs the provider polices (Postmark, SES shared pool), or do you own a dedicated IP you must warm and protect (SES dedicated, SendGrid dedicated)? Shared pools trade control for the provider's reputation management; dedicated IPs give isolation but require steady volume to stay warm.
  • API ergonomics. REST + a maintained SDK in your language, synchronous send with a returned message ID, and clear error codes. SES uses the AWS SDK and IAM auth; SendGrid, Postmark, and Mailgun use bearer/API-key REST.
  • Webhook quality. How fast events arrive, whether payloads are signed, whether delivery/open/click/bounce/complaint are all represented, and whether redelivery is idempotent. SES does not send HTTP webhooks itself — it publishes to SNS, and you own the endpoint. Design that endpoint per the webhook event pipeline guide.
  • Suppression handling. A built-in, queryable suppression list that automatically drops hard bounces and complaints protects your reputation. Postmark and SendGrid maintain this for you; with SES you build and enforce it yourself, which ties directly into bounce and complaint handling.
  • Pricing model. Per-email (SES, ~$0.10/1k) versus monthly tier with included volume (Postmark, SendGrid, Mailgun). Cost crosses over sharply at high volume.
  • Dedicated IP availability. Required for very high volume or strict reputation isolation; an add-on cost everywhere.
  • Inbound parsing. If you accept replies (support@, reply-to-ticket), you need inbound routing. Mailgun's routing and SendGrid's Inbound Parse are strong; SES inbound runs through receipt rules + S3/SNS; Postmark has inbound streams.
  • Regional / data residency. EU data residency is a hard requirement for some teams. Mailgun and SES offer EU regions; verify where event data and message content are stored.

Deep tradeoffs across the four providers

The criteria above interact, so it helps to read each provider as a coherent set of tradeoffs rather than a feature checklist.

Amazon SES is the cheapest and the most assembly-required. You pay roughly $0.10 per thousand messages with no monthly floor, and you can run dedicated IPs at modest cost. In exchange, SES gives you almost nothing operationally: there is no managed suppression list enforced on every send (there is an account-level suppression list, but you must populate and reason about it), no HTTP event webhook — events go to SNS and you own the consuming endpoint — and no curated shared pool insulating you from your own mistakes. You own your sending reputation outright. That ownership is a liability for a careless sender and an asset for a disciplined one: nobody else's spam drags down your IP. SES fits teams with engineering capacity who send enough volume that the cost difference dwarfs the build cost.

SendGrid is the full-featured generalist. It serves marketing and transactional traffic from one account, ships a templating UI, and exposes a single signed Event Webhook covering processed, delivered, open, click, bounce, and spam-report events. Suppression is managed and queryable via API. The tradeoff is that its shared-pool deliverability varies — you may want a dedicated IP sooner than with Postmark — and the breadth means more surface area to configure than a transactional-only tool. It fits teams that want one vendor and one bill for all outbound mail.

Postmark is the transactional specialist. It is fast, has consistently strong inbox placement because it polices its curated shared pool aggressively, and models message streams so transactional and broadcast traffic never share reputation. Suppression and bounce classification are fully managed, and webhooks are signed. The tradeoffs: it is opinionated against bulk marketing on its transactional streams, and per-message cost is higher than SES. It fits teams that prioritize deliverability and low operational load over raw cost.

Mailgun leads on routing and regions. Its inbound and outbound routing rules are the most powerful of the four, and it offers a first-class EU region for data residency. Suppression and signed webhooks are managed. It fits teams with complex inbound parsing needs or an EU residency requirement. For most pure outbound transactional senders, the choice narrows to SES, SendGrid, or Postmark — which is why the adapters and comparisons below focus on those three.

Provider abstraction layer

The architecture that makes provider choice reversible is a single send interface with one adapter per vendor. Your application code depends on the interface, never on a vendor SDK.

Provider abstraction layer Application code calls one EmailSender interface, which dispatches to interchangeable SES, SendGrid, and Postmark adapters. One Interface, Swappable Adapters Application code sender.send(message) EmailSender interface send() returns messageId SesAdapter @aws-sdk/client-sesv2 SendGridAdapter @sendgrid/mail PostmarkAdapter postmark npm
Application code targets one EmailSender interface; each provider lives behind an interchangeable adapter, so switching vendors is a config change.

Core implementation: a provider-agnostic send abstraction

Define a narrow interface that captures only what a transactional send needs: recipients, subject, HTML/text bodies, a tag or message-stream hint, and an optional idempotency key. Each adapter translates that neutral message into the vendor's payload and normalizes the response to a common { messageId, provider } shape.

// email/types.ts
// Neutral message — no vendor field leaks above this line.
export interface EmailMessage {
  from: string;
  to: string[];
  subject: string;
  html: string;
  text: string;
  tag?: string;            // maps to Postmark Tag / SendGrid category / SES tag
  idempotencyKey?: string; // dedupe key, see retry/queue section below
}

export interface SendResult {
  messageId: string;       // provider message id, normalized
  provider: 'ses' | 'sendgrid' | 'postmark' | 'mailgun';
}

export interface EmailSender {
  send(msg: EmailMessage): Promise<SendResult>;
}
// email/adapters/ses.ts
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
import type { EmailMessage, EmailSender, SendResult } from '../types';

export class SesAdapter implements EmailSender {
  constructor(private client = new SESv2Client({ region: process.env.AWS_REGION })) {}

  async send(msg: EmailMessage): Promise<SendResult> {
    const res = await this.client.send(new SendEmailCommand({
      FromEmailAddress: msg.from,
      Destination: { ToAddresses: msg.to },
      // Amazon SES: ConfigurationSetName routes bounce/complaint events to SNS.
      ConfigurationSetName: 'transactional',
      Content: {
        Simple: {
          Subject: { Data: msg.subject },
          Body: { Html: { Data: msg.html }, Text: { Data: msg.text } },
        },
      },
      // Amazon SES: message tags surface in CloudWatch/SNS event metadata.
      EmailTags: msg.tag ? [{ Name: 'tag', Value: msg.tag }] : undefined,
    }));
    return { messageId: res.MessageId!, provider: 'ses' };
  }
}
// email/adapters/postmark.ts
import { ServerClient } from 'postmark';
import type { EmailMessage, EmailSender, SendResult } from '../types';

export class PostmarkAdapter implements EmailSender {
  constructor(private client = new ServerClient(process.env.POSTMARK_TOKEN!)) {}

  async send(msg: EmailMessage): Promise<SendResult> {
    const res = await this.client.sendEmail({
      From: msg.from,
      To: msg.to.join(','),
      Subject: msg.subject,
      HtmlBody: msg.html,
      TextBody: msg.text,
      Tag: msg.tag,
      // Postmark: message streams separate transactional from broadcast traffic.
      MessageStream: 'outbound',
    });
    return { messageId: res.MessageID, provider: 'postmark' };
  }
}
// email/adapters/sendgrid.ts
import sg from '@sendgrid/mail';
import type { EmailMessage, EmailSender, SendResult } from '../types';

export class SendGridAdapter implements EmailSender {
  constructor() { sg.setApiKey(process.env.SENDGRID_API_KEY!); }

  async send(msg: EmailMessage): Promise<SendResult> {
    const [res] = await sg.send({
      from: msg.from,
      to: msg.to,
      subject: msg.subject,
      html: msg.html,
      text: msg.text,
      // SendGrid: category is the closest analog to a tag for event filtering.
      categories: msg.tag ? [msg.tag] : undefined,
    });
    // SendGrid returns the message id in the X-Message-Id response header.
    return { messageId: res.headers['x-message-id'], provider: 'sendgrid' };
  }
}

The application wires one adapter at startup from an environment variable. Nothing downstream knows which vendor is active.

// email/factory.ts
import { SesAdapter } from './adapters/ses';
import { PostmarkAdapter } from './adapters/postmark';
import { SendGridAdapter } from './adapters/sendgrid';
import type { EmailSender } from './types';

export function makeSender(): EmailSender {
  switch (process.env.EMAIL_PROVIDER) {
    case 'ses':      return new SesAdapter();      // Amazon SES: cheapest, you own reputation
    case 'sendgrid': return new SendGridAdapter(); // SendGrid: all-in-one marketing + transactional
    default:         return new PostmarkAdapter(); // Postmark: transactional-only default
  }
}

Second pattern: retry/queue with idempotency keys

A direct synchronous send couples the user's request latency to the provider's API and loses the message if the provider throttles or returns 5xx. Put sends behind a queue, and make the worker safe to retry by attaching an idempotency key.

// email/worker.ts
import { makeSender } from './factory';
import { redis } from './redis';
import type { EmailMessage } from './types';

const sender = makeSender();

export async function handleJob(msg: EmailMessage): Promise<void> {
  const key = `sent:${msg.idempotencyKey}`;
  // Skip if a prior attempt already succeeded — protects against double-send
  // when SQS/BullMQ redelivers a job after a worker crash mid-send.
  if (msg.idempotencyKey && await redis.get(key)) return;

  try {
    const res = await sender.send(msg);
    if (msg.idempotencyKey) {
      // Record success for 7 days so retried jobs become no-ops.
      await redis.set(key, res.messageId, 'EX', 604_800);
    }
  } catch (err: any) {
    // SES throttling surfaces as ThrottlingException / 429; SendGrid as 429.
    // Re-throw so the queue applies exponential backoff and retries.
    if (err.$metadata?.httpStatusCode === 429 || err.code === 429) throw err;
    // 4xx like sender-not-verified are permanent — do not retry; dead-letter.
    throw new PermanentError(err);
  }
}
class PermanentError extends Error {}

The idempotency key should be deterministic for a given logical email — for a password reset, hash the user id plus the reset-token issuance timestamp — so a retried HTTP request that re-enqueues the job collapses to a single send. This is the same idempotency discipline used when building an idempotent webhook consumer on the receive side.

Two failure modes drive this design. First, the user's request can succeed at the API layer but the worker can crash after the provider accepts the message and before the success is recorded — without the idempotency check, the redelivered job sends a duplicate. The Redis success marker closes that window. Second, the provider can be temporarily throttling: SES returns a ThrottlingException, SendGrid a 429. Those are transient and must be retried with exponential backoff, never dropped. By contrast, a sender not verified or a malformed-recipient 400 is permanent — retrying it only burns the queue, so it should dead-letter for a human to inspect. Distinguishing transient from permanent failures is the single most important decision in the worker, because getting it wrong either drops legitimate mail or floods a struggling provider with retries.

Run the worker pool with bounded concurrency tuned to your SES send-rate quota. If your account is approved for 50 messages/second and you run 200 concurrent workers with no rate limit, you will trip throttling constantly and rely entirely on backoff to recover — slower and noisier than simply capping concurrency near the quota. Treat the provider's documented rate as a hard ceiling and stay comfortably under it.

Provider constraint comparison

Criterion Amazon SES SendGrid Postmark Mailgun
Pricing model Per-email (~$0.10/1k) Tiered + overage Tiered per 10k Tiered + per-email
Deliverability posture You own reputation Mixed, policed shared Strong, curated shared Good, routing-focused
Webhooks Via SNS, you build endpoint Signed Event Webhook Signed webhooks Signed webhooks
Suppression list Build it yourself Managed Managed Managed
Dedicated IP Yes (add-on) Yes (add-on) Yes (higher tier) Yes (add-on)
Message streams No (use config sets/tags) No (categories) Yes No
Inbound parsing Receipt rules → S3/SNS Inbound Parse Inbound streams Routes (strong)
EU data residency Yes (eu regions) Limited US-based Yes (EU region)
Assembly required Highest Medium Lowest Medium

The pattern is consistent: SES is the cheapest and most flexible but hands you the operational burden; Postmark removes the most work for transactional-only senders; SendGrid covers marketing and transactional in one account; Mailgun leads on routing and EU regions. For a decision walkthrough see SendGrid vs Postmark vs Amazon SES.

Read the "assembly required" row as the true cost row. The per-message price of SES understates its total cost because you must build and operate the suppression store, the SNS event consumer, IP warm-up, and bounce classification. At low volume that engineering time outweighs any savings, which is why a small team is usually better served by Postmark or SendGrid even though the per-message price is higher. The savings only become real once monthly volume is high enough that the per-message delta covers the salary cost of maintaining the infrastructure — typically in the hundreds of thousands to millions of messages per month. Model both numbers before committing, and revisit the decision as volume grows rather than treating it as permanent.

Data residency and regional sending

For teams subject to GDPR or contractual data-residency clauses, where message content and event data are processed and stored is a hard selection constraint, not a nice-to-have. The recipient address, message body, and bounce/complaint events are all personal data, and routing them through a US region can breach an EU residency commitment. The four providers differ sharply here:

  • Amazon SES runs independently in each AWS region (eu-west-1 Ireland, eu-central-1 Frankfurt, and others). You choose the region at client construction, and message processing plus the SNS event topics stay in that region — the cleanest residency story of the four, because you control the region rather than the vendor. The cost is that reputation and configuration are per-region: a sender warmed in us-east-1 has no reputation in eu-west-1, so a residency-driven region switch is effectively a migration.
  • Mailgun offers a first-class EU region with a separate API hostname (api.eu.mailgun.net). Domains and event data created in the EU region stay in the EU. You must explicitly create the domain in the EU region — a domain made in the US region is not retroactively moved.
  • SendGrid has historically processed in the US; EU data residency is limited and tier-gated, so verify the current offering against your contract before committing for an EU workload.
  • Postmark is US-based, which rules it out where strict EU residency is mandatory regardless of how strong its deliverability is.
// SES: region selection is the residency control — events follow the region.
const client = new SESv2Client({ region: 'eu-west-1' });   // Ireland; SNS topics also live here
// Mailgun: the EU region requires the EU API host, not just an EU domain name.
const mg = new Mailgun(formData).client({ key: process.env.MG_KEY!, url: 'https://api.eu.mailgun.net' });

Two residency traps catch teams late. First, subprocessors: even an EU-region ESP may use US-based subprocessors for click-tracking or analytics — check the DPA. Second, your own webhook endpoint and event store must also sit in-region; an EU-region ESP that POSTs events to a US-hosted consumer has still moved the data across the boundary. Keep the entire return path described in the webhook event pipeline guide in the same region as the sender.

Integration steps

  1. Store the API key in a secrets manager. Never commit it. Use AWS Secrets Manager / Vault / platform env injection; for SES, prefer IAM role credentials over long-lived keys.
  2. Verify your sending domain. Add the provider's DKIM CNAME/TXT records and an SPF include, then confirm verification in the dashboard. Get the records right per the email authentication guide before sending a single message.
  3. Move from sandbox to production. SES starts in sandbox (you can only send to verified addresses, capped at 200 recipients/24h, 1 msg/sec) — request production access. SendGrid and Postmark require sender/domain verification before live traffic.
  4. Stand up the webhook endpoint. Point the provider (or SNS for SES) at an HTTPS endpoint, verify signatures, and persist events. Build it as described in the webhook event pipeline guide.
  5. Wire the adapter via EMAIL_PROVIDER. Deploy with the factory above, send a test through the queue, and confirm a normalized messageId and a matching delivery event.
  6. Enforce suppression before send. Query the managed suppression list (Postmark/SendGrid) or your own store (SES) inside the worker and short-circuit any address that has hard-bounced or complained. Skipping this step is the fastest way to erode a new sender's reputation.
  7. Add observability. Emit a metric per send keyed by provider and outcome, and alert on a rising bounce or complaint rate. You want to detect a deliverability problem from your own dashboards before a mailbox provider starts filtering you.

Treat these as ordered: domain verification and production access gate everything, and the suppression and observability steps are not optional polish — they are what keep a correctly-integrated sender from quietly degrading over its first months of real traffic.

Debugging named failures

  • 403 / Forbidden on send. API key is missing the send scope or is from the wrong region/environment. For SES this is an IAM policy lacking ses:SendEmail; attach a policy scoped to your verified identity. For SendGrid the key needs "Mail Send" permission.
  • 429 / ThrottlingException. You exceeded the per-second send rate. SES enforces an account-level rate (raised over time); the queue's backoff should absorb spikes. Request a quota increase rather than removing retries.
  • SES sandbox 200-recipient cap. Symptom: sends to unverified addresses fail with MessageRejected: Email address is not verified, or you hit the 200/24h limit. Cause: account still in sandbox. Fix: request production access in the SES console; until granted, only verified identities receive mail.
  • "Sender not verified" / MessageRejected. The From domain or address has no completed DKIM/identity verification. Re-check the DNS records and wait for propagation; verification can lag DNS by minutes to hours.
  • Webhook receives nothing (SES). SES does not POST webhooks. Confirm the configuration set has an event destination publishing to an SNS topic, and that your endpoint confirmed the SNS subscription.
  • Duplicate sends after a deploy or crash. A retried queue job re-sent a message because no idempotency key was set, or the success marker was never written. Cause: the worker recorded success only after a step that can fail, or the key was non-deterministic. Fix: set a deterministic idempotency key per logical email and write the success marker immediately after the provider returns a message id.
  • Events arrive but suppression never updates. The webhook persists events but nothing reads bounces/complaints into the suppression store. Cause: missing consumer logic, not a provider bug. Fix: process bounce and complaint events into the suppression list as covered in bounce and complaint handling.
  • Sends or events appear to vanish after a region change. Symptom: a domain verified and sending fine suddenly returns MessageRejected or stops emitting events, often after pointing the client at a different region. Cause: SES domain identities, DKIM verification, configuration sets, and SNS topics are all per-region — a eu-west-1 client cannot use an identity verified only in us-east-1. For Mailgun, a domain created in the US region is invisible to the EU API host. Fix: re-verify the domain and recreate the configuration set/event destination in the target region, and confirm the SDK's region/url matches where the identity actually lives.

Validation & deployment checklist

  • Sending domain shows verified DKIM and a passing SPF record at the provider
  • API credentials loaded from a secrets manager, not source control
  • SES account moved out of sandbox (production access granted) before launch
  • Send path goes through the queue/worker, not a synchronous call in the request handler
  • Idempotency key set on every enqueued message and checked in the worker
  • 429/throttling errors trigger backoff-and-retry; permanent 4xx errors dead-letter
  • Webhook (or SNS) endpoint verifies signatures and persists delivery, bounce, and complaint events
  • Suppression enforced before send (managed list queried, or self-built list checked for SES)
  • A second adapter is wired and smoke-tested so provider switch is a config change
  • Test send produces a normalized messageId and a matching delivery event end to end

← Back to Transactional Email Delivery Infrastructure