---
title: "Amazon SES"
description: "Use Amazon SES for MainSDK email and the notify email channel - verified identities, sandbox and production access, region, main.yaml and swapping."
section: "integrations"
canonical: "https://mainsdk.dev/docs/integrations/amazon-ses"
updated: "2026-10-28"
---

# Amazon SES

> Use Amazon SES for MainSDK email and the notify email channel - verified identities, sandbox and production access, region, main.yaml and swapping.

Canonical: https://mainsdk.dev/docs/integrations/amazon-ses · Updated: 2026-10-28

Amazon Simple Email Service (SES) is one of the two email providers the email spec ships, next to Resend. Your app sends with `email.send()` and `email.marketing()` as usual, and SES delivers from your AWS account. The same driver carries the email channel of [`notify`](https://mainsdk.dev/docs/interfaces/notify). Pick SES when your app already runs on AWS, when you want sending to stay inside your AWS account and region, or as a `fallback` behind Resend.

## Interfaces it serves

### Notifications (`notify`)

- Status: Building for 0.3 · ships as a stable API
- Driver: `ses` (Amazon SES, planned driver)
- Docs: https://mainsdk.dev/docs/interfaces/notify.md

```yaml
notify:
  driver: ses
```

Swap to another provider: `whatsapp-cloud`, `bird`, `msg91`, `twilio`, `local`, `fcm`, `apns`, `expo`, `resend`, `onesignal`, `slack`, `pagerduty`, `opsgenie`, `webhook`.

### Email (`email`)

- Status: Building for 0.3 · ships as a stable API
- Driver: `ses` (Amazon SES)
- Docs: https://mainsdk.dev/docs/interfaces/email.md

```yaml
email:
  driver: ses
```

Swap to another provider: `resend`, `local`, `postmark`, `bird`, `smtp`.

## Before you start

You need an AWS account with permission to use SES in the region you choose.

1. **Pick a region.** SES is a regional service: identities, sending limits and production access are set per region. Choose the region that matches your MainSDK data region (US, EU or India) and do every step below in it.
2. **Verify an identity.** Add your sending domain as a verified identity (or, for early testing, a single email address). For a domain, SES gives you DKIM records to add at your DNS host; the identity is usable once SES shows it as verified. A custom MAIL FROM domain and a DMARC record help deliverability.
3. **Request production access.** New SES accounts start in the sandbox in each region: you can send only to verified addresses, with low sending limits. Request production access for the region before you send to real users. AWS reviews the request, so allow time for it.
4. **Set up bounce and complaint notifications.** Create a configuration set with an SNS event destination for bounces, complaints and deliveries, and subscribe your app's webhook URL to the SNS topic. MainSDK feeds bounces and complaints to its suppression list (see below).
5. **Give the app credentials.** Create an IAM identity whose policy allows sending through SES, scoped to your identities, and nothing more. On AWS compute, prefer an IAM role over long-lived access keys.

Store any access keys in your secrets manager, never in code or a committed env file.

## Configure

```yaml title="main.yaml"
email:
  driver: ses
  region: eu-west-1                  # the region of your verified identity
  from: "Pinecart <hello@mail.pinecart.dev>"
  configuration-set: mainsdk-events  # publishes events to SNS
  webhook: ses-events                # bounces and complaints, see below
  access-key-id: AKIAEXAMPLE         # leave out on AWS compute to use the role
  key: secret:AWS_SECRET_ACCESS_KEY  # the secret for access-key-id
  tracking: { opens: false, clicks: false }   # the defaults
webhooks:
  inbound:
    ses-events:
      preset: ses
      topics: ["arn:aws:sns:eu-west-1:123456789012:mainsdk-events"]
      path: /api/webhooks/ses
```

- **Region:** `email.region`, or the `AWS_REGION` environment variable. Without either, the app fails to start with `MAIN-EMAIL-0006`.
- **Credentials:** with `access-key-id` and `key` set, the driver signs with that pair. With neither, it uses the runtime IAM role. A literal key in `main.yaml` fails to load with `MAIN-CONFIG-0004`.

> **Warning:** The SES request and event shapes are written from AWS documentation and are unverified against the live API. Verify with a sandbox key before production: send one message to the mailbox simulator and deliver one test event through SNS.

### What the driver sends

Each message is one `POST https://email.<region>.amazonaws.com/v2/email/outbound-emails` (the SES v2 `SendEmail` call) signed with AWS Signature Version 4 for the `ses` service. The JSON body holds `FromEmailAddress`, `Destination.ToAddresses`, `ReplyToAddresses`, a simple `Content` with subject, text and HTML parts, `Headers` (the `List-Unsubscribe` pair on marketing mail), `EmailTags` for the template, the kind, the campaign and the tenant, and `ConfigurationSetName` when you set one. The call goes through MainSDK's network layer, is never retried by it, does not follow redirects, and reads at most 64 KiB of the answer. A 429, a 5xx or no answer is a retryable `MAIN-EMAIL-0004`, so a `fallback` driver is tried.

### Bounces and complaints

Name an inbound webhook with the `ses` preset in `email.webhook`. MainSDK confirms the SNS subscription for the topics you list, then reads each event: a `Permanent` bounce and a complaint add the address to the suppression list, other bounces do not, and a delivery is only reported to `email.on` listeners. The tags tie each event to its campaign and tenant. Without `email.webhook` in production, `mainsdk check` warns with `MAIN-EMAIL-0010`.

For the `notify` email channel, add `email` to the channel list; it uses your `email` block:

```yaml title="main.yaml"
notify:
  channels: [push, sms, email]
```

## Use it

```ts title="app/billing/invoice.ts"
import { email } from "mainsdk";

export async function sendInvoice(to: string, invoiceId: string) {
  return email.send({
    to,
    template: "invoice",
    data: { invoiceId },
    idempotencyKey: `invoice:${invoiceId}`,
  });
}
```

Through `notify`, SES delivers whenever email is the channel chosen:

```ts title="app/billing/remind.ts"
import { notify } from "mainsdk";

await notify({ id: invoice.customerId }, {
  template: "invoice.reminder",
  data: { invoiceId: invoice.id },
  category: "billing",
});
```

## Local development and testing

- **No AWS account:** `mainsdk dev` sends every email to the local inbox.
- **SES sandbox:** while your account is in the sandbox, you can send to your own verified addresses. The SES mailbox simulator gives addresses that simulate delivery, bounces and complaints without hurting your sending reputation.
- **Tests:** `createTestApp()` from `@mainsdk/testing` runs `email` on the local mailbox, and `mailbox(app).failNext(1)` simulates an outage. `fakeMessagingServer("ses")` records the signed request the real driver makes, with no network.

`mainsdk check --env prod` fails with `MAIN-CHECK-0026` if `email` still resolves to the local driver.

## What MainSDK adds on top

- Marketing email does not compile without an unsubscribe, and carries one-click `List-Unsubscribe` headers.
- Unsubscribes, hard bounces and complaints are suppressed for every later send, in MainSDK, whichever provider sends.
- No open-tracking pixels or rewritten links by default.
- A retry with the same idempotency key never sends twice.

See [Email](https://mainsdk.dev/docs/interfaces/email) for the full contract.

## Limits and notes

- The SES driver declares its own host, `email.<region>.amazonaws.com`, so `network.allow` does not change.
- The `email:ses` driver is built (spec `email-notify`). `notify` has no SES driver of its own: its email channel uses your `email` block.
- Sandbox limits and production access are per region. Moving to another region means verifying identities and requesting production access again.
- SES keeps its own account-level suppression list as well. MainSDK checks its own list first, so a suppressed address never reaches SES.
- `email.raw()` returns the SES client for features `email` does not wrap, such as configuration sets. `mainsdk check` lists every use.

## Swap to or from it

```yaml title="main.yaml"
email:
  driver: resend   # was ses
```

Point `key` at the new provider's credential and verify your domain with it first. Email is stateless, so the swap needs no migration; see [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver). To keep SES as a backup, use `fallback: { email: [resend, ses] }`.

## FAQ

**Why do my emails only reach some addresses?** Your account is probably still in the SES sandbox in that region, which only sends to verified addresses. Request production access for the region.

**Can SES send for my India users from India?** Choose an SES region that matches your data region, verify your identity there and set `email.region` to it.

**Do I keep SES's suppression list in sync?** No. MainSDK keeps its own suppression list and checks it before every send.

## Next

- [Email](https://mainsdk.dev/docs/interfaces/email) and [Notifications](https://mainsdk.dev/docs/interfaces/notify)
- [Resend](https://mainsdk.dev/docs/integrations/resend) and [AWS Secrets Manager](https://mainsdk.dev/docs/integrations/aws-secrets-manager)
- [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver)
