One tenant's bad list is spending your SES reputation
If you run a multi-tenant SaaS on Amazon SES, you probably have one SES account, one or two verified identities, and every tenant's email flowing through them.
That means you also have exactly one sender reputation. And every tenant you onboard gets to spend it.
Here's the scenario that should worry you. A tenant imports a purchased list — or an old one, scraped from a CRM export nobody cleaned. They send a campaign. Hard bounces spike. A few recipients mark it as spam. SES watches two numbers across your whole account: bounce rate and complaint rate. Drift toward a 5% bounce rate or a 0.1% complaint rate and your account goes under review. Push further and SES pauses your sending.
Not that tenant's sending. Your sending. Every tenant. The good ones — the ones with clean lists and double opt-ins — wake up to the same dead deliverability as the one who caused it. Their password resets land in spam. Their invoices never arrive. And your dashboard, if it only tracks account-level numbers, tells you something is wrong long after all your tenants already know.
This isn't a monitoring problem you can alert your way out of. It's an architecture problem. Reputation on SES is pooled by default, so isolation has to be something you build — in three separate places.
1. Give each tenant its own sending identity
Reputation signals attach to identities: domains, MAIL FROM domains, IP pools. If all your tenants send from one domain on the shared IP pool, there is nothing for those signals to segment by.
What I build instead:
-
A sending subdomain per tenant —
mail.tenant.yourdomain.com, or the tenant's own domain once they're serious — with its own SPF/DKIM alignment. A tenant who destroys their subdomain's standing has hurt their subdomain, not your root domain and not the tenant next door. - A configuration set per tenant. This is the unglamorous piece everything else hangs off. Configuration sets are how you tag a tenant's sends so every bounce, complaint, and delivery event comes back labelled with which tenant produced it. One shared configuration set means unattributed events, and unattributed events mean you're back to guessing.
- Dedicated IP pools only for tenants who earn them. SES lets you assign configuration sets to dedicated IP pools. A tenant with steady, real volume can graduate to their own pool, fully out of the shared blast radius. Tenants without consistent volume stay on shared IPs — a cold dedicated IP with sporadic sends is its own deliverability problem.
2. Keep a per-tenant ledger, not an account-level blob
SES hands you an account-level suppression list and a stream of events. Most teams wire the events to an SNS topic, glance at a dashboard, and move on.
Split the stream by tenant (that's what the per-tenant configuration sets are for) and persist it as a ledger: every send, bounce, complaint, and suppression, recorded per tenant, append-only. Two things fall out of this:
- Suppression becomes per-tenant too. An address that hard-bounced for Tenant A gets suppressed for Tenant A. On a shared account-level list, one tenant's typo'd import can silently block addresses your other tenants legitimately need to reach.
- You get a reputation balance per tenant. Bounce rate, complaint rate, suppression growth — per tenant, over time. Not "the account looks bad this week," but "this tenant has been decaying for six days." That's the difference between an incident and a conversation.
3. Gate sending on per-tenant outcomes
Isolation without enforcement is just better observability of your own outage. The point of measuring per tenant is that you can act per tenant.
The pattern I use — and the one I keep coming back to when I talk to other builders about this — is an outcome gate per tenant:
- Thresholds per tenant. When a tenant's bounce or complaint rate crosses your line, the gate pauses that tenant's sending automatically. The account stays healthy, the other tenants never notice, and the paused tenant gets a specific reason instead of your whole platform getting an SES review.
- A ramp for new tenants. Nobody sends at full volume on day one. New tenants start on a paced schedule with a daily ceiling that rises as their outcome history proves out. A bad list then burns a small, contained allowance — not your account.
- List hygiene before the first campaign, not after. The gate is the last line of defence. Checking an import for the obvious rot before it's allowed to send is the first one.
I sketched this gate design out with another builder in a GitHub thread recently — he'd been running deliverability as one global dashboard and treating every incident as platform-wide. He folded the per-tenant gate into his product's roadmap the same week and told me it filled a real gap in his design. It usually does. Most multi-tenant stacks built on SES have exactly this hole; they just haven't had the tenant yet who finds it.
What to check in your stack today
You don't need a rebuild to find out how exposed you are. Four questions:
- If one tenant spiked your bounce rate tonight, could you name them from your own data tomorrow morning — or only that "the account" had a bad day?
- Do your bounce and complaint events arrive tagged by tenant, or in one undifferentiated stream?
- Is suppression enforced per tenant, or is one tenant's bad import blocking addresses for everyone?
- What, mechanically, stops a tenant from sending their worst list at full volume right now? If the answer is "we'd notice," you don't have a control — you have a hope.
If you can't answer those cleanly, your tenants are sharing more than an SES account. They're sharing a reputation, a suppression list, and an outage waiting for its trigger.
I'm building these three pieces — per-tenant identities, per-tenant ledgers, per-tenant outcome gates — into rootmail, an email infrastructure layer on SES, and the beta is open if you want to see the approach in practice: https://rootmail.io/beta
Top comments (17)
The cheapest early guard is at import time, before any send: run a new tenant's first list through a small gate. Reject or quarantine syntax-invalid addresses, role accounts and known disposable domains, and cap the first campaign to a sample (say a few hundred) so a bad list shows its bounce rate before it reaches the full audience. Then promote the cap only after the sample comes back under your per-tenant threshold. It's a rule you can state and test, and it keeps the per-tenant auto-pause from being the first line of defense.
Exactly — import time is the cheapest place to catch it, and the sample cap is the part most people skip. Syntax and disposable-domain checks catch the obvious junk, but a stale list passes all of those and still bounces 8% — the addresses are well-formed, they're just dead. A few hundred sends surfaces that in minutes because hard bounces come back immediately. In rootmail the order is: import gate first (reject/quarantine), sample send second, and the per-tenant threshold only as the backstop for lists that decay after import. The threshold reacting should be the rare case, not the routine one.
Right - well-formed and dead is the failure shape. Two things beyond the syntax check: an activity signal where you have one (last open/click/login, imported with consent) to score staleness before the sample, and a suppression table fed by your own bounce events, so an address that hard-bounced for tenant A never gets attempted for tenant B. SES events already come back labelled per tenant via the configuration set, so building that shared suppression list is a nightly job over the event stream, no extra verification calls.
Both of those are the difference between a check and a system. The activity signal is underrated exactly because it answers the well-formed-and-dead case that syntax never will — and "imported with consent" is the operative phrase, because a house list the tenant hasn't mailed in two years can have perfect syntax and zero signal. The shared suppression table is the one I'd underline hardest: a hard bounce is a hard fact, and re-attempting it for tenant B isn't just wasted spend, it's reputation they're burning on your account. In the model I run, bounce events write to a shared table that's read for every tenant at send time — the tenant's address, everyone else's protection.
One thing I'd add to the per-tenant gate: when it pauses a tenant, what happens to that tenant's own password resets and invoice emails? If transactional mail goes out on the same subdomain and configuration set as their campaigns, the pause (or the damaged subdomain) takes those down too, and that's the tenant's users getting punished for a marketing list. Do you split transactional onto its own identity per tenant, or keep it on yours?
That's the exact edge the gate design has to survive, and you've spotted where naive pausing breaks things. Split them: campaigns run on mail.tenant (its own subdomain and configuration set) and transactional gets its own identity per tenant — in the rootmail design, the outcome gate only ever pauses the campaign identity, never the transactional one. A paused marketing tenant still gets password resets and invoices out; the pause just stops them burning the shared account. Anything else punishes the tenant's end users for a marketing mistake, which is the opposite of what the isolation is for. How are you handling the split on your side — shared transactional domain across tenants, or per-tenant there too?
The pooled reputation problem is easy to miss until one tenant imports an old list and everyone's password reset emails start landing in spam. Splitting by subdomain, configuration set and suppression list per tenant puts the damage where it belongs.
The import time gate from the Launch Gate comment is the part I would build first. Checking a new list before the first send, with a sample cap and a bounce threshold that pauses the rest, is far cheaper than repairing reputation afterwards. Splitting transactional from campaign traffic belongs in the same first step, because a login email should never share a fate with a newsletter.
On client builds we treat each of these as a separate, testable piece: tenant isolation, import gate, suppression sync. Each has a clear pass condition, so the team knows when it is done rather than tuning it forever.
How do you handle catch all domains, which Damiana raised, given they rarely bounce but rarely engage either?
Catch-alls are the quiet tax on every multi-tenant sender: they sail through verification as "accepted," so the bounce-rate gate never fires, but they drag engagement down and pollute every deliverability signal downstream.
What I do on client builds: treat catch-alls as their own bucket at import time, exactly along the lines of Damiana's four-way split. The catch-all bucket goes out in its own paced micro-batch first — small enough that if it poisons the domain, only that tenant's own subdomain pays for it. I gate that batch on engagement, not bounces: if a catch-all domain produces zero opens or clicks after the first two campaign cycles, it gets suppressed for bulk mail. Those addresses stay sendable for transactional traffic (password resets, receipts), because those get opened anyway and the mailbox providers score them separately.
The honest version: I never "solve" catch-alls. They are too common in small B2B lists to reject outright. I just keep them from ever sharing a reputation budget with the addresses that actually engage.
What ratio of catch-all to verified are you treating as the line where an import gets blocked versus just throttled?
The per-tenant suppression point is underrated. A shared suppression list quietly turns one tenant's bad import into another tenant's deliverability bug.
One gate I'd add in front of all three: check the list at import time, before the first send. Per-tenant thresholds react after the bounces have already landed on the account; a pre-send check on any newly imported list (especially one that hasn't been mailed in months) stops the 5% spike from happening at all. It also gives you something concrete to show the tenant: "these 800 addresses don't exist", rather than "your sending is paused".
The "show the tenant proof" point is the one I'd underline. A pause notification reads like punishment and starts an argument; "these 800 addresses don't exist" ends one. It's also why suppression has to be per-tenant in the data model, not just in the thresholds: SES's account-level list is shared and invisible to tenants, so when tenant B's mail to an address silently stops, nobody can explain why. Import gate + per-tenant ledger turns both problems into something you can show your work on.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.