Amazon SES transactional email is the cheapest reliable way to send password resets, receipts, and verification links at scale, but every new SES account starts in the sandbox, where you can only send to verified addresses and you're capped at 200 messages a day and 1 message per second. That's fine for testing and useless in production. The fix is a four-part setup: verify your sending domain with DKIM, request production access to leave the sandbox, wire up bounce and complaint notifications through SNS, then plug credentials into your app. I've shipped this for several production Laravel apps, so here's the end-to-end version with the exact commands.
How do I verify my sending domain in SES?
Verify a domain, not a single email address. A domain identity lets you send from any address on it (no-reply@, receipts@, support@) and is what reviewers expect. Pick your region first, since it sets the API endpoint and the latency to your recipients, and identities don't carry across regions, so verify in the same region you'll send from. Then create the identity with the SESv2 CLI.
# Create the domain identity (Easy DKIM is enabled by default)
aws sesv2 create-email-identity \
--email-identity example.com \
--region us-east-1
# Pull the 3 DKIM CNAME tokens SES generated
aws sesv2 get-email-identity \
--email-identity example.com \
--region us-east-1 \
--query 'DkimAttributes.Tokens'create-email-identity returns three DKIM tokens. Each becomes a CNAME record. On top of those, set a custom MAIL FROM subdomain (for example mail.example.com) so the bounce/Return-Path domain aligns with your domain instead of amazonses.com, which matters for DMARC alignment. Here are the DNS records you need to publish:
- DKIM CNAME #1 — token1._domainkey.example.com → token1.dkim.amazonses.com
- DKIM CNAME #2 — token2._domainkey.example.com → token2.dkim.amazonses.com
- DKIM CNAME #3 — token3._domainkey.example.com → token3.dkim.amazonses.com
- MAIL FROM MX — mail.example.com → feedback-smtp.us-east-1.amazonses.com (priority 10)
- MAIL FROM SPF (TXT) — mail.example.com → "v=spf1 include:amazonses.com ~all"
SES detects the CNAMEs and flips the identity to verified on its own, usually within minutes if your DNS TTLs are low. If you want the full breakdown of what SPF, DKIM, and DMARC actually do and how they interact, I wrote a deep dive on email deliverability for developers; read that before you start guessing at DNS records.
How do I get out of the SES sandbox?
Request production access from the SES console (Account dashboard → Request production access). It's a short support form, and the quality of your answers decides whether you're approved, usually within 24 hours. Be specific and honest about four things:
- Your use case — say it's transactional (account verification, password resets, receipts), not bulk marketing.
- Expected sending volume — a realistic daily number, not 'unlimited'.
- How you handle bounces and complaints — name the SNS notifications and suppression you set up in the next step.
- How recipients opt in — describe the signup flow that generates the mail.
After approval, confirm it from the CLI rather than trusting the console banner. get-account returns a ProductionAccessEnabled boolean along with your current sending quota:
aws sesv2 get-account --region us-east-1 \
--query '{Production:ProductionAccessEnabled,Quota:SendQuota}'
# Expected once approved:
# {
# "Production": true,
# "Quota": {
# "Max24HourSend": 50000.0,
# "MaxSendRate": 14.0,
# "SentLast24Hours": 0.0
# }
# }The sandbox isn't a bug to bypass. It's AWS asking whether you'll wreck their IP reputation. Answer that question well and you're out in a day.
Why do I need bounce and complaint handling?
If you keep sending to addresses that hard-bounce, or to people who hit 'mark as spam', AWS will throttle or suspend your account, and your sender reputation tanks for every mailbox provider. SES tracks a bounce rate and a complaint rate per account, and reviewers explicitly look for a handling plan. The simplest reliable pattern: create an SNS topic, point the identity's bounce and complaint notifications at it, subscribe a handler, and add every flagged address to the SES suppression list so you never mail it again.
# 1. Create the SNS topic and capture its ARN
TOPIC_ARN=$(aws sns create-topic \
--name ses-notifications \
--region us-east-1 \
--query 'TopicArn' --output text)
# 2. Point the identity's Bounce + Complaint notifications at the topic
# (classic SES API; no configuration set required)
aws ses set-identity-notification-topic \
--identity example.com \
--notification-type Bounce \
--sns-topic "$TOPIC_ARN" \
--region us-east-1
aws ses set-identity-notification-topic \
--identity example.com \
--notification-type Complaint \
--sns-topic "$TOPIC_ARN" \
--region us-east-1
# Stop the default email-forwarding now that SNS carries feedback
aws ses set-identity-feedback-forwarding-enabled \
--identity example.com \
--no-forwarding-enabled \
--region us-east-1
# 3. Suppress an address that bounced or complained
aws sesv2 put-suppressed-destination \
--email-address bad@example.com \
--reason BOUNCE \
--region us-east-1Subscribe an HTTPS endpoint or a Lambda to the SNS topic, parse the notification JSON, and call put-suppressed-destination (or drop the user from your own send list) for every bounced or complained address. Account-level suppression also kicks in automatically, but owning the logic means you can stop generating the mail in the first place.
How do I connect SES to Laravel?
You have two credential paths: generate SMTP credentials and talk SMTP, or use the SES API directly with IAM access keys. For Laravel the API route is cleaner; install the AWS SDK and Laravel's ses transport handles the rest. Create a dedicated IAM user scoped to ses:SendRawEmail and ses:SendEmail only, and don't reuse an admin key.
composer require aws/aws-sdk-phpMAIL_MAILER=ses
MAIL_FROM_ADDRESS="no-reply@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=AKIAxxxxxxxxxxxxxxxx
AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AWS_DEFAULT_REGION=us-east-1Laravel reads those keys from the ses block in config/services.php (it ships there by default in Laravel 11/12). Send a test mail with php artisan tinker and watch the SES sending statistics, or your SNS topic, for delivery. One gotcha when deploying on EC2: if you take the SMTP path and your security group blocks outbound TCP on ports 25/465/587, mail silently hangs with no useful error. The API path over 443 sidesteps it entirely, which is one more reason I prefer it.
What should I do before sending real volume?
Getting out of the sandbox is permission, not reputation. A brand-new account sending 10,000 emails on day one looks exactly like a compromised account to spam filters. Warm up gradually over a couple of weeks, and keep transactional traffic isolated.
- Warm up volume in steps (hundreds, then thousands per day) so your reputation builds instead of tripping filters.
- Keep transactional mail on a separate subdomain from any marketing mail, so a marketing complaint spike never poisons your password-reset deliverability.
- Pick the region closest to your recipients; it sets the endpoint and shaves real latency.
- Monitor bounce and complaint rates in the SES dashboard and act before AWS does.
- For the wider production setup this fits into, see my Laravel on AWS production architecture post.
Done right, SES is the boring, cheap, reliable backbone you stop thinking about: fractions of a cent per email, no third-party SaaS markup, and full control over your sending domain. The work is all upfront. Verify the domain, answer the sandbox review honestly, and build bounce handling before you need it. Skip the bounce handling and you'll eventually learn why reviewers ask about it the hard way, with a suspended account in the middle of a launch.

