Multi-Tenant SaaS Patterns for Amazon SES
Sending SES on behalf of customers means per-tenant branding, reputation isolation, and template variation. Patterns that work — and that don't.
Multi-tenant SES is one of those architectures that looks simple until you start building it.
You're a SaaS that sends emails to your customers' end users. Maybe you're a customer support tool sending ticket replies. Maybe you're a billing platform sending invoice reminders. Maybe you're a community platform sending notification digests. You want each customer's emails to look like they came from that customer — their domain, their branding, their tone — while keeping your engineering team from drowning in per-tenant configuration.
There are five or six structural decisions that make or break this architecture. Most teams discover them sequentially, in production, over the course of two years. This post tries to compress that timeline.
The first decision: shared identity vs per-tenant identity
The single biggest architectural decision is whether tenants share an SES sending identity or each gets their own.
Shared identity. Every tenant's email is sent from notifications@yourplatform.com (or similar). The tenant's branding is in the From header's display name and the email content. Reputation is shared across all tenants.
Pros: simple. One verified domain, one DKIM key, one configuration set, one quota to manage. Onboarding a new tenant requires no SES changes.
Cons: a misbehaving tenant's reputation hits everyone. A spammy customer's complaint rate raises spam-folder placement for your entire customer base. Tenants who care about deliverability cannot improve their own reputation because they don't have one.
Per-tenant identity. Each tenant verifies their own domain in SES. Sends go from notifications@<tenant>.com. Reputation is per-tenant.
Pros: tenants can build their own reputation. A bad tenant doesn't affect others. The emails look fully native to the tenant's customers.
Cons: significant operational complexity. Each tenant must verify a domain (DKIM, DMARC, SPF). You need a process for onboarding that handles DNS record validation. SES quotas may need adjustment per tenant. Some tenants won't or can't add the DNS records, which forces a fallback to shared identity for those.
Hybrid. A common compromise: a shared identity by default, with per-tenant identity available as a paid feature or for tenants above a certain tier. This puts the operational complexity behind a willingness signal.
For most B2B SaaS at early stages, shared identity is the right call. Switch to hybrid when at least one of these is true: a tenant has explicitly asked for branded sending; you have an enterprise tier where branded sending is differentiating; your shared reputation is bad enough that escaping it for high-value tenants matters. Switch fully to per-tenant identity rarely; the operational cost is high and most tenants don't notice.
Configuration sets as the per-tenant boundary
Configuration sets are SES's mechanism for grouping sends with shared settings. Use them as the per-tenant boundary even if your identity is shared.
A configuration set per tenant gives you:
- Per-tenant event destinations: bounces, complaints, deliveries, opens, clicks routed to per-tenant analytics.
- Per-tenant IP pool assignment: in dedicated IP setups, you can put high-volume tenants on separate pools.
- Per-tenant tracking options: some tenants will want open/click tracking, others won't.
- Per-tenant reputation visibility: SES can report sending statistics per configuration set, which surfaces tenant-level deliverability problems.
The cost is configuration set management overhead. Each tenant onboarding creates a configuration set. Each tenant offboarding deletes one. Your tenant management code has to know about configuration sets.
def send_for_tenant(tenant_id: str, template_name: str, **kwargs) -> dict:
config_set = f"tenant-{tenant_id}"
return ses.send_email(
ConfigurationSetName=config_set,
...
)
The simple alternative — one configuration set for everyone — works at small scale. It stops working when one tenant generates 90% of your bounce events and you can't tell which tenant from the event stream alone.
Template strategy: parameterized vs per-tenant
The second strategic decision: how do you handle templates that vary per tenant?
Single parameterized template. One welcome template with variables for tenant name, logo URL, color, and copy variations. Rendering pulls tenant data and fills in the variables.
<img src="{{tenant.logo_url}}" alt="{{tenant.name}}">
<h1 style="color: {{tenant.brand_color}}">Welcome to {{tenant.name}}</h1>
<p>{{tenant.welcome_copy}}</p>
Pros: one template to maintain, one source of truth for the structure, easy to deploy changes to all tenants.
Cons: customization is limited to what you parameterize. A tenant who wants a fundamentally different layout can't have it without a new template. Variables proliferate over time as tenants ask for new customizations.
One template per tenant. Each tenant has their own copy of every template. Tenant A's welcome is different from Tenant B's welcome.
Pros: tenants can have radically different templates. Per-tenant customization is unlimited.
Cons: template count explodes. Ten templates × 1,000 tenants = 10,000 templates in your SES account. Updates require touching every tenant's copy. Deploying a security fix to all welcome emails is a 1,000-template operation.
Parameterized base + per-tenant overrides. Most tenants use the parameterized base. Tenants who need full customization get an override that uses their custom template. The application picks at send time based on tenant configuration.
def template_for_tenant(tenant_id: str, base_name: str) -> str:
tenant = get_tenant(tenant_id)
if tenant.has_template_override(base_name):
return tenant.template_name(base_name) # e.g. "tenant-12-welcome"
return base_name # e.g. "welcome"
This is the right answer for most multi-tenant SaaS. The base covers the 90% of tenants who want their logo and colors swapped in. The override path serves the 10% who need real customization, without forcing the operational complexity onto every tenant.
Reputation isolation and dedicated IPs
For SaaS sending significant volume, dedicated IPs become relevant. They are also a reputation isolation lever.
Three patterns:
Shared IPs (SES default). AWS pools your sends with everyone else's on shared IP infrastructure. AWS manages the IP reputation. You don't control it, but you also don't have to maintain it.
Dedicated IP pool, shared across tenants. You buy dedicated IPs and assign all tenants to one pool. Your reputation is yours, not AWS's. Your tenants share it.
Dedicated IP pools, per-tenant or per-tier. High-volume tenants get their own pool, or a tier-specific pool. Lower-volume tenants share. Reputation is more isolated.
The pricing math matters: dedicated IPs cost money per IP per month, and IPs need warm-up — you can't ramp from zero to high volume on a fresh IP without deliverability damage. Dedicated IPs are worth it when you have enough volume to fill them and enough deliverability concern to need control.
For most SaaS, shared IPs are correct until they aren't. The signal that it's time to upgrade: deliverability problems you can't trace to your own behavior, suggesting the IP pool you're sharing is being polluted by other AWS customers.
Tenant-level overrides without a tangle
Multi-tenant SaaS develops, over time, a long tail of per-tenant special cases. Tenant A wants a different sender display name. Tenant B doesn't want open tracking. Tenant C needs a footer with their VAT number. Tenant D requires DKIM signing with a specific selector.
If these overrides live in scattered code paths, the system becomes incomprehensible within a year. The pattern that works: a single tenant configuration object, structured, validated, applied consistently.
@dataclass
class TenantEmailConfig:
sending_identity: str # "notifications@acme.com" or "shared@platform.com"
display_name: str # "Acme Notifications"
configuration_set: str # "tenant-acme"
open_tracking_enabled: bool
click_tracking_enabled: bool
template_overrides: dict[str, str] # base name -> tenant-specific name
custom_footer: Optional[str]
suppression_strategy: Literal["account", "tenant"]
Read this once at send time. Use the result to construct the SES call. Add new override types as fields on this object, with defaults that match existing behavior. Resist the temptation to special-case in the calling code.
Observability per tenant
You will eventually be asked, by a customer success person or by a tenant directly, "what's our deliverability looking like?" If your monitoring is account-aggregate, you cannot answer.
The minimum per-tenant observability:
- Send count per tenant per day.
- Bounce count and rate per tenant per day.
- Complaint count and rate per tenant per day.
- Delivery latency per tenant.
The wiring: SES event destinations route bounces, complaints, and deliveries from each tenant's configuration set into your analytics pipeline. Tag every event with the tenant ID — this is the value of per-tenant configuration sets, since the event destination knows which configuration set fired and you can map back to tenant.
A dashboard per tenant becomes possible once you have this. For high-value tenants, this becomes a customer-facing feature: "here's your transactional email health."
Anti-patterns to avoid
A short list of mistakes I see repeatedly.
Per-tenant identity by default at low volume. Operational complexity that buys you nothing because no tenant cares. Wait until somebody asks.
Tenant ID in the From address local-part. tenant-12@platform.com looks unprofessional and burns reputation if tenant-12 is a high-bounce tenant. Use sender display name and per-domain identity for tenant differentiation.
Per-tenant templates without organization. Templates named welcome_tenant_1, welcome_tenant_2... up to four digits. Cannot be navigated. Cannot be audited. Use a structured naming convention from day one (e.g., a hash, an ID prefix, or a tenant slug) that you commit to.
Suppression list collisions. A user unsubscribes from one tenant's emails. The suppression entry, at the SES account level, prevents sends to that address from every other tenant. Use application-layer suppression scoped per tenant when this matters.
Cross-tenant data leakage in templates. A template variable populated from the wrong tenant's data leaks information across the trust boundary. Validate at render time that variable values match the tenant being sent to.
Onboarding without DNS rollback. A tenant adds the SES verification records, gets verified, then later removes the records (or migrates DNS providers and forgets). Sending continues briefly until SES revokes verification, then fails silently. Monitor verification status as part of regular checks.
Where Sovy fits
Sovy treats tenants as first-class objects. A template can have a base version and per-tenant overrides; the override system is structured rather than ad-hoc. Audit logs capture which tenant a template change applies to. Role-based access can be scoped per tenant — a customer-success engineer assigned to one tenant can edit that tenant's overrides without seeing others.
The benefit is mostly that the long tail of per-tenant customizations has a structured place to live, instead of accumulating in application code or in a sprawl of similarly-named templates in the SES Console. For SaaS where multi-tenant email is core to the product, this structure is worth a lot. For SaaS where it's incidental, the parameterized-base pattern with a small bit of application logic is fine.
A starting architecture
For a B2B SaaS with 200 customers, hybrid identity, and growing:
- Shared SES identity by default. Per-tenant identity available on the enterprise tier, with a documented onboarding flow that includes DNS validation.
- One configuration set per tenant, named
tenant-<slug>. - Parameterized base templates for the core flows (welcome, password reset, billing, notification digest). Per-tenant template overrides for tenants that need them, named
tenant-<slug>-<base-name>. - Application-layer suppression list keyed by tenant + address.
- Per-tenant event destinations routing to your analytics warehouse with
tenant_idas a column. - Tenant configuration object centralized, with all overrides as fields.
- Dashboard per tenant for send/bounce/complaint metrics, available to internal customer-success and to enterprise tenants self-serve.
Half of this is plumbing. The other half is discipline about where things live. Get the discipline right early, because retrofitting it across 200 tenants is its own engineering quarter.
Sovy is a control layer for Amazon SES templates with first-class support for multi-tenant SaaS patterns. Tenants, base templates, per-tenant overrides, and per-tenant audit are built in. If you're managing SES across many customers and the configuration sprawl is showing, we'd like to hear from you.