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 /domains with "tenant": "cust_8412".
  • Give an existing domain to a tenant: POST /tenants/{tenant}/domains with {"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, or GET /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 tenant on the send; naming a different tenant gets 403 forbidden.
  • sees and manages only that tenant's emails, domains and suppressions (/emails, /domains, /suppressions).
  • can read its own tenant: GET /tenants/{tenant}, /stats and /domains.
  • gets 403 forbidden everywhere 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_8412

OpenAPI

Machine-readable spec: openapi/groundmail.yaml (tag Tenants).