Tenants
If your product sends email for your own customers, create a tenant for each of them. Each tenant gets its own sending domains, suppression list, stats, sending ramp, daily limit and pause switch, so one customer's bad list never hurts your other customers.
You keep one account, one dashboard and as many API keys and webhook endpoints as you like. Keys and webhook endpoints can be limited to one or more tenants when your customers need their own.
New to tenants? The Platforms guide walks through the whole setup.
Base URL: https://api.groundmail.dev
Plans
Tenants are included on Scale (up to 1,000 tenants) and Dedicated (up to 10,000). There is no per-tenant fee: billing stays on your sending volume. Need more tenants, or tenants on another plan? Contact support@groundmail.dev.
On an account without tenants, any request that names a tenant (tenant on a send,
domain, suppression, API key or test event, tenants on a webhook, or a /tenants
endpoint) gets 403 tenants_not_enabled. Remove tenant to send from the account as
a whole.
Authentication
Authorization: Bearer gm_…Every /tenants endpoint needs a full-access key. A key limited to one tenant
can read its own tenant, its stats and its domains.
Create a tenant
POST /tenants
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"
}'| Field | |
|---|---|
external_id |
Required. Your id for this customer: 1–128 letters, digits, ., _, :, @ or -. Unique in your account; cannot change. |
name |
Display name. Defaults to external_id. |
default_from_domain |
Completes a bare from such as "billing" to billing@mail.acme.com. |
stream_policy |
all (default) or transactional_only: refuse product email for this tenant. |
daily_limit |
Your own cap: most recipients this tenant may email per UTC day, on top of its sending ramp. null (default) for none. |
metadata |
Any JSON object (up to 4 KB) for your own use. |
201
{
"tenant": {
"id": "6f1c2a7e-…",
"object": "tenant",
"external_id": "cust_8412",
"name": "Acme Inc",
"status": "active",
"paused_reason": null,
"paused_at": null,
"pause_source": null,
"default_from_domain": "mail.acme.com",
"stream_policy": "all",
"daily_limit": null,
"metadata": null,
"created_at": "2026-10-18T12:00:00.000Z",
"updated_at": "2026-10-18T12:00:00.000Z"
}
}A tenant with the same external_id is a 409 conflict. Creating more tenants than
your plan includes is a 403 plan_limit with details.tenant_limit.
GET /tenants (cursor pagination, optional ?status=active|paused),
GET /tenants/{tenant}, PATCH /tenants/{tenant} and DELETE /tenants/{tenant} work as
you would expect. {tenant} is the external_id or the tenant id.
Delete a tenant
DELETE /tenants/{tenant} deletes the tenant and its suppression list. Its domains go
back to your account, its sent emails stay in your history (with tenant: null), API
keys limited to it are revoked, and it is removed from webhook tenant filters. A webhook
endpoint that only listened to this tenant is disabled rather than left listening to
everything.
Send for a tenant
Add tenant to POST /emails (or to each item of POST /emails/batch):
{
"tenant": "cust_8412",
"from": "Acme <billing@mail.acme.com>",
"to": "jane@example.com",
"subject": "Your receipt",
"html": "<p>Thanks!</p>"
}await client.emails.send({
tenant: 'cust_8412',
from: 'Acme <billing@mail.acme.com>',
to: 'jane@example.com',
subject: 'Your receipt',
html: '<p>Thanks!</p>',
})If you leave tenant out and send from a domain that belongs to a tenant, the email is
that tenant's. So SMTP and existing code get per-tenant isolation as soon as the
customer's domain belongs to their tenant.
| Response | When |
|---|---|
403 tenants_not_enabled |
Tenants are not included on your plan |
404 not_found |
No tenant with that id |
403 domain_not_allowed |
The from domain belongs to a different tenant |
403 forbidden |
The tenant is paused, it is transactional_only and the email is product email, or a key limited to another tenant sent it |
429 daily_ramp_limit |
The send would go over the tenant's sending ramp or its daily_limit; details.tenant names it and details.resets_at says when it resets |
422 validation_error |
A recipient is on the tenant's suppression list or your account's |
Emails sent for a tenant have "tenant": "cust_8412" in GET /emails and
GET /emails/{id}, and in every email webhook. Filter with GET /emails?tenant=cust_8412.
Domains
A domain belongs to one tenant, or to your whole account. Only that tenant can send from
its domains; every tenant can send from your account-wide domains (for example a shared
mail.yourplatform.com).
- Add a domain straight to a tenant:
POST /domainswith"tenant": "cust_8412". - Give an existing domain to a tenant:
POST /tenants/{tenant}/domainswith{"domain": "mail.acme.com"}. A domain another tenant owns is a 409: remove it from that tenant first. - List a tenant's domains:
GET /tenants/{tenant}/domains, orGET /domains?tenant=cust_8412. - Give a domain back to your account:
DELETE /tenants/{tenant}/domains/{domain}.
Domain objects carry tenant (the external_id) when a tenant owns them. See Domains.
Suppressions
Bounces, complaints and unsubscribes on a tenant's email go on that tenant's own list. A complaint to one of your customers never stops another customer emailing the same person. Sends for a tenant check its list and your account-wide list.
GET /suppressions?tenant=cust_8412, POST /suppressions with "tenant", and
DELETE /suppressions?email=…&tenant=cust_8412 work on a tenant's list. See
Suppressions.
Tenant API keys
Give a customer, or the part of your system that acts for them, a key that can only
touch their tenant: POST /api-keys with tenant.
{ "name": "Acme", "scopes": ["full"], "tenant": "cust_8412" }A key limited to a tenant:
- sends only as that tenant. It does not need
tenanton the send; naming a different tenant gets 403forbidden. - sees and manages only that tenant's emails, domains and suppressions (
/emails,/domains,/suppressions). - can read its own tenant:
GET /tenants/{tenant},/statsand/domains. - gets 403
forbiddeneverywhere else: webhooks, templates, received email, deliverability, events, API keys, other tenants and account settings.
Key objects carry tenant when they are limited to one. Deleting the tenant revokes its
keys. A tenant key can combine allowed_domains with full, since it cannot create other
keys. See API keys.
Webhooks per tenant
By default a webhook endpoint gets events for every tenant, and each email event carries
data.tenant. To send one customer's events to their own URL, set tenants on the
endpoint:
{
"url": "https://acme.example.com/hooks/email",
"events": ["email.delivered", "email.bounced", "email.complained"],
"tenants": ["cust_8412"]
}A filtered endpoint gets only email events of those tenants, and no email.received,
contact.* or automation.* events. PATCH /webhooks/{id} with "tenants": [] or
null removes the filter. Webhook objects carry tenants only when filtered. To test a
filtered endpoint, call POST /emails/test with tenant. See
Webhooks.
Sending ramp
Each new tenant ramps up its sending, like a new account does. A tenant starts at 1,000 product and 3,000 transactional recipients per UTC day, and its limit doubles each day it used at least half of it with bounces under 2% and complaints under 0.1%. So one new customer with a bad list cannot burn the reputation your other customers send on.
The tenant's ramp sits inside your account's own ramp:
a send has to fit both, and the tighter limit wins. A daily_limit you set on the tenant
applies too.
A send that would go over the tenant's limit is refused with 429 daily_ramp_limit.
Nothing is sent, and your other tenants keep sending:
{
"error": {
"code": "daily_ramp_limit",
"message": "New tenants ramp up their sending: today's limit for tenant `cust_8412` is 1,000 recipients of product email, and this send would go over it. It resets at 00:00 UTC and grows as the tenant sends clean mail. Your other tenants are not affected.",
"docs_url": "https://docs.groundmail.dev/errors#daily_ramp_limit",
"details": {
"tenant": "cust_8412",
"stream": "product",
"daily_limit": 1000,
"used_today": 990,
"recipients": 20,
"ramp_stage": 1,
"resets_at": "2026-10-20T00:00:00.000Z"
}
}
}Send the rest after details.resets_at. Moving a customer over who already sends at
volume? Email support@groundmail.dev and we can raise their tenant's limit.
Stats
curl "https://api.groundmail.dev/tenants/cust_8412/stats?days=7" \
-H "Authorization: Bearer $GROUNDMAIL_API_KEY"{
"tenant": "cust_8412",
"stats": {
"object": "tenant_stats",
"days": 7,
"from": "2026-10-11T12:00:00.000Z",
"total": 1840,
"queued": 0,
"scheduled": 0,
"sent": 1840,
"delivered": 1812,
"bounced": 9,
"complained": 0,
"failed": 0,
"suppressed": 3,
"opened": 0,
"clicked": 0,
"bounce_rate": 0.0049,
"complaint_rate": 0
},
"ramp": {
"object": "tenant_ramp",
"active": true,
"stage": 3,
"daily_limit": { "transactional": 12000, "product": 4000 },
"used_today": { "transactional": 220, "product": 0 },
"completed": false,
"resets_at": "2026-10-19T00:00:00.000Z"
}
}stats counts emails created in the last days (1–90, default 7). ramp is the tenant's
sending ramp today: active is false when no ramp limit applies (the tenant has not sent
yet, or its ramp is complete), and daily_limit is then null. The Tenants page in
the dashboard shows the same numbers for every tenant.
Pausing
Pause a tenant with PATCH /tenants/{tenant} and {"status": "paused", "reason": "…"};
resume it with {"status": "active"}. Its new emails are refused with 403
forbidden until you resume it; your other tenants keep sending.
Groundmail also pauses a single tenant when its own bounce or complaint rate is too high,
instead of pausing your whole account. The tenant shows status: "paused" with
pause_source: "automated" and the reason. Fix the list, then contact
support@groundmail.dev to resume it.
Dedicated IPs per tenant
A tenant can send from its own dedicated IP, or a pool of them, so its reputation is fully its own. Dedicated IPs are $50 per IP per month. Request one in the dashboard under Settings → Billing → Dedicated IPs and pick the tenant; we set it up and let you know when the tenant's mail moves over. Nothing changes in your code.
SDK
import { Groundmail } from 'groundmail'
const client = new Groundmail(process.env.GROUNDMAIL_API_KEY!)
await client.tenants.create({ external_id: 'cust_8412', name: 'Acme Inc' })
await client.tenants.domains.assign('cust_8412', 'mail.acme.com')
const { stats, ramp } = await client.tenants.stats('cust_8412', { days: 7 })
await client.tenants.pause('cust_8412', 'Customer asked us to stop')
await client.tenants.resume('cust_8412')
const { data } = await client.tenants.list()CLI
groundmail tenants create cust_8412 --name "Acme Inc" --default-from-domain mail.acme.com
groundmail tenants domains add cust_8412 mail.acme.com
groundmail emails send --tenant cust_8412 --from billing@mail.acme.com --to jane@example.com --subject "Your receipt" --html "<p>Thanks!</p>"
groundmail tenants stats cust_8412 --days 7
groundmail tenants pause cust_8412 --reason "Customer asked us to stop"
groundmail keys create --name Acme --tenant cust_8412
groundmail webhooks create --url https://acme.example.com/hooks/email --tenants cust_8412OpenAPI
Machine-readable spec: openapi/groundmail.yaml (tag Tenants).