AWS SES: The Unglamorous Setup That Keeps You Off Blocklists
SES sandbox mode is easy. Production SES that doesn't get you blocklisted is a different project entirely. Here's what I've learned the hard way.
Getting SES out of sandbox mode feels like an accomplishment. It isn't. It's just the beginning of a setup that, if you skip any of it, will eventually get your sending domain blacklisted and your clients calling you on a Saturday morning.
I've done this setup probably a dozen times now — for e-commerce clients, healthcare portals, a couple of real estate platforms, a print management system that sends job confirmation emails all day long. Every time I think I've got a clean checklist. Every time something bites me anyway.
Here's the full picture, including the parts AWS doesn't exactly foreground in the docs.
What SES Actually Is (And Isn't)
SES is a high-volume, low-cost email sending service. It's not a marketing platform. It's not SendGrid with a nice UI. It's closer to a raw SMTP relay with an HTTP API bolted on. That's fine — that's what I want for transactional email coming out of a Laravel app. I don't need drag-and-drop templates. I need reliable delivery at sane prices.
The tradeoff is that AWS puts real responsibility on you to manage your sender reputation. They will suspend your account if your bounce rate climbs above 5% or your complaint rate climbs above 0.1%. Those thresholds sound generous until you realize a single bad import of a client's old contact list can blow through them in an afternoon.
Getting Out of Sandbox
Out of the box, SES only lets you send to verified addresses. That's sandbox mode. To get production access you open a support case requesting a sending limit increase. AWS wants to know:
- What you're sending
- How recipients opted in
- How you handle bounces and complaints
That last one is not a checkbox. If your answer is vague, they'll push back. Write a real paragraph explaining that you process SNS bounce/complaint notifications and suppress addresses automatically. They want to see that you've thought about it.
Approval usually takes 24 hours. I've had it take four days for a healthcare client where the support rep asked follow-up questions about HIPAA. Answer honestly and specifically.
The Actual Work: Bounce and Complaint Handling
This is where most people cut corners and eventually pay for it.
When SES can't deliver an email (hard bounce) or a recipient marks it as spam (complaint), AWS fires a notification to an SNS topic. You need to subscribe an HTTPS endpoint to that topic and process those events. If you don't, those addresses keep getting emailed, your rates climb, and AWS comes knocking.
Here's how I set it up in Laravel.
Infrastructure
- Create an SNS topic, e.g.
ses-notifications - In SES, under your verified identity, configure notifications to send bounces and complaints to that topic
- Add an HTTPS subscription pointing to a route in your app
- Confirm the subscription (SNS sends a
SubscriptionConfirmationmessage first)
The Webhook Controller
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Models\SuppressedEmail;
class SesNotificationController extends Controller
{
public function handle(Request $request): \Illuminate\Http\Response
{
$payload = json_decode($request->getContent(), true);
// SNS sends a subscription confirmation first — handle it
if (($payload['Type'] ?? '') === 'SubscriptionConfirmation') {
file_get_contents($payload['SubscribeURL']);
return response('confirmed', 200);
}
if (($payload['Type'] ?? '') !== 'Notification') {
return response('ignored', 200);
}
$message = json_decode($payload['Message'], true);
$notificationType = $message['notificationType'] ?? '';
match ($notificationType) {
'Bounce' => $this->handleBounce($message),
'Complaint' => $this->handleComplaint($message),
default => Log::info('SES: unhandled notification type', ['type' => $notificationType]),
};
return response('ok', 200);
}
private function handleBounce(array $message): void
{
$bounce = $message['bounce'];
// Only hard bounces should be permanently suppressed
// Soft bounces (transient) you may want to retry or just log
if ($bounce['bounceType'] !== 'Permanent') {
Log::info('SES: soft bounce', ['subtype' => $bounce['bounceSubType']]);
return;
}
foreach ($bounce['bouncedRecipients'] as $recipient) {
$email = strtolower($recipient['emailAddress']);
SuppressedEmail::firstOrCreate(
['email' => $email],
['reason' => 'bounce', 'raw' => json_encode($recipient)]
);
Log::warning('SES: hard bounce suppressed', ['email' => $email]);
}
}
private function handleComplaint(array $message): void
{
$complaint = $message['complaint'];
foreach ($complaint['complainedRecipients'] as $recipient) {
$email = strtolower($recipient['emailAddress']);
SuppressedEmail::firstOrCreate(
['email' => $email],
['reason' => 'complaint', 'raw' => json_encode($complaint)]
);
Log::warning('SES: complaint suppressed', ['email' => $email]);
}
}
}
The SuppressedEmail table is simple — email, reason, timestamps. The important part is that before you send any email, you check this table.
// In whatever service or job queues your outbound mail
public function shouldSendTo(string $email): bool
{
return ! SuppressedEmail::where('email', strtolower($email))->exists();
}
Don't skip this check. Don't assume your marketing team won't re-import that address from a CSV next month.
Route Registration
Make sure this route bypasses CSRF middleware:
// routes/api.php or a dedicated webhook route file
Route::post('/webhooks/ses', [SesNotificationController::class, 'handle']);
And add it to your VerifyCsrfToken exceptions if you're using the web middleware group.
The Gotchas That Bit Me
SNS message signature verification. The payload from SNS is signed. I'm embarrassed to admit how long I ran without verifying that signature. Anyone who discovers your webhook URL can post fake bounce notifications and suppress real addresses. AWS provides a verification process using the SigningCertURL and Signature fields. There are packages that handle this; use one. At minimum, lock the endpoint to AWS IP ranges at the load balancer level.
Open and click tracking kills you with privacy-conscious ESPs. SES can track opens and clicks by rewriting URLs and embedding a tracking pixel. This is fine for marketing email. For transactional email going to healthcare or biotech clients, I turn it off. Some corporate mail filters mark tracked emails as suspicious, and some privacy tools cause phantom opens that skew your metrics into uselessness.
The SES account-level suppression list. AWS maintains its own suppression list separate from yours. If an address has bounced anywhere in the SES ecosystem, AWS may suppress it account-wide regardless of what you do. You can check and remove addresses from this list via the console or API, but you have to know it exists. I discovered this when a client was baffled that their own test address wasn't receiving emails — it had bounced years earlier from a different sender using the same SES region.
// Check the SES account-level suppression list
use Aws\SesV2\SesV2Client;
$client = new SesV2Client([
'region' => 'us-west-2',
'version' => 'latest',
]);
try {
$result = $client->getSuppressedDestination([
'EmailAddress' => 'someone@example.com',
]);
// Address is on the suppression list
$reason = $result['SuppressedDestination']['Reason']; // BOUNCE or COMPLAINT
} catch (\Aws\SesV2\Exception\SesV2Exception $e) {
if ($e->getAwsErrorCode() === 'NotFoundException') {
// Not suppressed — good
}
}
Feedback loop notifications don't always include the recipient address. Some ISPs (notably some Microsoft domains) strip the recipient from complaint notifications before passing them to the feedback loop. You'll get a complaint event with an empty complainedRecipients array. There's nothing you can do about this technically — log it, monitor your complaint rate, and know it's happening.
SES sending limits are regional and per-second. If you've got a job that blasts 50,000 order confirmations, you need to respect your maximum send rate or SES will throttle you with Throttling errors. I use a queued job with rate limiting:
// In your queued mail job
public function middleware(): array
{
return [new RateLimited('ses-sends')];
}
// In AppServiceProvider or a boot method
RateLimiter::for('ses-sends', function () {
return Limit::perSecond(14); // Under your SES account's max send rate
});
When I'd Reach for SES
For transactional email at volume — order confirmations, password resets, appointment reminders, LIMS result notifications — SES is my default. The pricing is hard to argue with ($0.10 per thousand emails once you're past the free tier from EC2). If the app is already running on AWS, the IAM integration is clean and there's no extra vendor relationship to manage.
I wouldn't reach for SES if:
- The client needs a non-technical person managing email templates and campaigns. There's no real UI for that.
- Deliverability consulting is part of the engagement. SES assumes you know what you're doing. If a client has a damaged domain reputation from a previous provider, SES isn't going to hold your hand through the recovery.
- You need sophisticated list management out of the box. SES has some of this now with contact lists, but it's still basic compared to a proper ESP.
For outbound marketing email — newsletters, drip campaigns — I'll usually still recommend a dedicated ESP like Postmark or SendGrid for that workload and keep SES for transactional. Two different risk profiles, two different tools.
The Bottom Line
SES is genuinely good infrastructure, but "set it and forget it" will get you blacklisted. The bounce handling and complaint feedback loop aren't optional features you wire up when you have time — they're the whole deal, and they need to be in place before you send a single production email. Do the unglamorous setup once, do it right, and it runs quietly in the background for years.
Need help shipping something like this? Get in touch.