Platforms: send email for your customers
If your product sends email on behalf of your own customers (an AI app builder, an agent platform, a SaaS with white-label notifications), one bad customer list can drag down delivery for everyone else. Groundmail tenants keep each customer apart inside one account: their own domains, suppression list, stats, sending ramp and pause switch.
This guide sets up one customer end to end. Reference for every endpoint: Tenants.
1. Create a tenant per customer
Create the tenant when the customer signs up. Use your own customer id as
external_id, so you never need to store a Groundmail id.
import { Groundmail } from 'groundmail'
const client = new Groundmail(process.env.GROUNDMAIL_API_KEY!)
await client.tenants.create({
external_id: 'cust_8412',
name: 'Acme Inc',
default_from_domain: 'mail.acme.com',
})curl -X POST https://api.groundmail.dev/tenants \
-H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"external_id":"cust_8412","name":"Acme Inc","default_from_domain":"mail.acme.com"}'Set stream_policy: "transactional_only" for customers who should only send receipts,
sign-in codes and alerts, and daily_limit if you sell sending in tiers.
2. Give the tenant its domain
Customers who send from their own domain add it straight to their tenant. Show them the DNS records from the response (or apply them for them), then verify as usual.
const { records } = await client.domains.create({ name: 'mail.acme.com', tenant: 'cust_8412' })Already added the domain? Assign it: client.tenants.domains.assign('cust_8412', 'mail.acme.com').
Only that tenant can send from its domains. Customers without a domain of their own can
send from an account-wide domain of yours, such as mail.yourplatform.com: every tenant
can use those.
3. Send with tenant
await client.emails.send({
tenant: 'cust_8412',
from: 'Acme <billing@mail.acme.com>',
to: 'jane@example.com',
subject: 'Your receipt',
html: '<p>Thanks for your order.</p>',
})With default_from_domain set, from: 'billing' is enough. A send from a tenant's own
domain is that tenant's even without tenant, so SMTP and older code paths are covered too.
The tenant's suppression list, pause switch, ramp and limits apply to every send. A recipient who complained about Acme is suppressed for Acme only; your other customers can still reach them.
4. Hand out tenant-scoped keys
If customers call the API themselves, or a background job acts for one customer, give them a key that can only touch their tenant:
const key = await client.apiKeys.create({ name: 'Acme', scopes: ['full'], tenant: 'cust_8412' })
// key.token is shown once: store it for the customerThat key sends only as cust_8412, sees only its emails, domains and suppressions, and
can read its own tenant and stats. Everything else (webhooks, templates, other tenants,
API keys, account settings) answers 403 forbidden. Deleting the tenant revokes its keys.
5. Route webhooks per tenant
One endpoint for everything works: every email event carries data.tenant, so route on
it. If a customer wants events at their own URL, give them an endpoint filtered to their
tenant:
await client.webhooks.create({
url: 'https://acme.example.com/hooks/email',
events: ['email.delivered', 'email.bounced', 'email.complained'],
tenants: ['cust_8412'],
})A filtered endpoint gets only those tenants' email events (no inbound, contact or
automation events). Test it with POST /emails/test and "tenant": "cust_8412".
6. Expect the ramp
Each new tenant ramps up like a new account: it starts at 1,000 product and 3,000 transactional recipients per UTC day, and the limit doubles each day it uses at least half of it with bounces under 2% and complaints under 0.1%. It sits inside your account's own limits; the tighter one wins.
A send over the tenant's limit gets 429 daily_ramp_limit with details.tenant and
details.resets_at. Only that tenant waits; queue its mail until resets_at and keep
sending for everyone else.
import { GroundmailError } from 'groundmail'
try {
await client.emails.send({ tenant: 'cust_8412', from, to, subject, html })
} catch (err) {
if (err instanceof GroundmailError && err.code === 'daily_ramp_limit' && err.details?.tenant) {
await queueForLater(err.details.tenant, err.details.resets_at)
} else {
throw err
}
}Moving over a customer who already sends at volume? Email support@groundmail.dev and we can raise their tenant's limit.
7. Pause a customer
Stop one customer's sending (an unpaid invoice, a support case) without touching anyone else:
await client.tenants.pause('cust_8412', 'Invoice overdue')
await client.tenants.resume('cust_8412')Groundmail pauses a tenant on its own when its bounce or complaint rate gets too high,
instead of pausing your account. You will see status: "paused" and
pause_source: "automated". Clean the customer's list, then contact
support@groundmail.dev to resume it.
8. Watch each tenant's numbers
const { stats, ramp } = await client.tenants.stats('cust_8412', { days: 7 })
console.log(stats.bounce_rate, stats.complaint_rate, ramp?.daily_limit)Use bounce_rate and complaint_rate to warn a customer before their list gets them
paused. The Tenants page in the dashboard shows the same numbers for every tenant.
9. Give big senders their own IP
A customer with steady volume can send from its own dedicated IP, or a pool, so its reputation is entirely its own. Dedicated IPs are $50 per IP per month: request one under Settings → Billing → Dedicated IPs and pick the tenant. Nothing changes in your code.
Checklist
- One tenant per customer,
external_id= your customer id - Customer domains added with
tenant(or assigned) - Every send names
tenant, or comes from the tenant's domain -
daily_ramp_limitwithdetails.tenantqueues that tenant's mail, not everyone's - Webhook handler routes on
data.tenant - Tenant-scoped keys for anything that acts for one customer