Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Groundmail Tenants API
version: 0.16.0
description: 'Transactional email API for developers.
Auth: `Authorization: Bearer gm_…` with an API key, or `Bearer gm_oat_…` with an OAuth
access token from the device flow (`groundmail login`). Dashboard session cookies work
for integrations.
Azure managed identity (keyless): an Azure workload can send a Microsoft Entra access
token (a JWT) as the Bearer credential instead of a key. Register the identity''s tenant
ID, object ID and token audience in the dashboard (API keys → Azure managed identity);
it then has the same access options as an API key (scopes, domain limit, tenant).
App-only tokens only. Any authentication failure is the same 401 `unauthorized`.
See https://docs.groundmail.dev/authentication/azure-managed-identity.
Test mode: a test key (`gm_test_…`, `POST /api-keys` with `test: true`) behaves
like a live key, but its sends are never delivered: they are stored with
`test: true`, get simulated events and webhooks (`data.test: true`), and do not
count toward plan limits, the sending ramp, stats or billing. Simulator
addresses (`delivered@`, `bounce@`, `block@`, `complaint@`, `suppressed@`,
`delayed@simulator.groundmail.dev`, plus addressing allowed) produce one outcome
each with any key. See https://docs.groundmail.dev/testing.
Tenants: a platform that sends email for its own customers creates one tenant per
customer (`/tenants`), each with its own domains, suppression list, stats, sending
ramp and pause switch. Name the tenant with `tenant` on sends, domains and
suppressions, or send from a domain the tenant owns. API keys and webhook
endpoints can be limited to tenants. Included on pay as you go and the Dedicated plan;
elsewhere a request that names a tenant gets 403 `tenants_not_enabled`. See
https://docs.groundmail.dev/tenants.
Data retention: sent emails (content and events) and received emails are kept for
your plan''s window, then deleted: 7 days on Free, 30 on Pro, 90 on Scale, up to 365
on Dedicated. Past it, `GET /emails/{id}` and `GET /received-emails/{id}` return
404 `not_found` and lists no longer include them. Aggregate stats
(`GET /deliverability`) are kept. Store webhook events to keep history longer. See
https://docs.groundmail.dev/emails#data-retention.
Scopes: every credential carries scopes (`full`, `emails:send`, `emails:read`,
`domains:read`, `domains:write`, `webhooks:write`, `templates:write`,
`suppressions:write`, `account:read`). Each operation lists the scope it needs under
`x-required-scope` (space-separated: all of them; `a | b`: either); a credential
without it gets 403 `insufficient_scope` with `required_scope`. `full` grants everything; `domains:write` includes `domains:read`.
Legacy `sending` API keys have `emails:send` + `emails:read`.
Production base URL: https://api.groundmail.dev'
servers:
- url: https://api.groundmail.dev
description: Production
tags:
- name: Tenants
description: Your own customers inside one Groundmail account, each with its own domains, suppression list, stats, sending ramp and pause switch. Included on Scale and Dedicated.
paths:
/tenants:
get:
operationId: listTenants
x-required-scope: full
summary: List tenants
description: Tenants are your own customers inside one Groundmail account, each with its own domains, suppression list, stats, sending ramp and pause switch. Included on the Scale (up to 1,000 tenants) and Dedicated (up to 10,000) plans; other plans get 403 `tenants_not_enabled`.
tags:
- Tenants
security:
- bearerAuth: []
parameters:
- name: status
in: query
schema:
type: string
enum:
- active
- paused
description: Only tenants with this status
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 25
description: Maximum number of tenants to return (1–100)
- name: cursor
in: query
schema:
type: string
description: Opaque cursor for the next page
responses:
'200':
description: A page of tenants
content:
application/json:
schema:
$ref: '#/components/schemas/ListTenantsResponse'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Tenants are not included on this account's plan (`tenants_not_enabled`), or the key is not full access or is limited to one tenant
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
post:
operationId: createTenant
x-required-scope: full
summary: Create a tenant
tags:
- Tenants
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateTenantRequest'
example:
external_id: cust_8412
name: Acme Inc
default_from_domain: mail.acme.com
responses:
'201':
description: Tenant created
content:
application/json:
schema:
$ref: '#/components/schemas/TenantResponse'
'400':
description: Invalid field
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: '`tenants_not_enabled`: tenants are not included on this account''s plan. `plan_limit`: the account has as many tenants as its plan includes (`details.tenant_limit`).
'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: A tenant with this `external_id` already exists
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/tenants/{id}:
parameters:
- name: id
in: path
required: true
description: The tenant's `external_id` or `id`
schema:
type: string
get:
operationId: getTenant
x-required-scope: full
summary: Retrieve a tenant
description: A key limited to one tenant can read its own tenant.
tags:
- Tenants
security:
- bearerAuth: []
responses:
'200':
description: The tenant
content:
application/json:
schema:
$ref: '#/components/schemas/TenantResponse'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Tenants not enabled, or a tenant key asking for another tenant
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: Tenant not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
patch:
operationId: updateTenant
x-required-scope: full
summary: Update, pause or resume a tenant
description: 'Change any setting from create (except `external_id`, which cannot change), or
pause and resume the tenant with `status` and an optional `reason`. A paused
tenant''s new sends get 403 `forbidden`; your other tenants keep sending.
A tenant Groundmail paused (`pause_source` `automated` or `admin`) cannot be
resumed here: fix the cause and contact support.'
tags:
- Tenants
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateTenantRequest'
example:
status: paused
reason: Customer asked us to stop sending
responses:
'200':
description: The updated tenant
content:
application/json:
schema:
$ref: '#/components/schemas/TenantResponse'
'400':
description: Invalid field
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Tenants not enabled, or resuming a tenant Groundmail paused
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: Tenant not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
delete:
operationId: deleteTenant
x-required-scope: full
summary: Delete a tenant
description: Deletes the tenant and its suppression list. Its domains go back to the account, its sent emails stay in your history, API keys limited to it are revoked, and it is removed from webhook tenant filters (an endpoint left with no tenants is disabled).
tags:
- Tenants
security:
- bearerAuth: []
responses:
'200':
description: Tenant deleted
content:
application/json:
schema:
type: object
required:
- ok
- id
- external_id
- deleted
properties:
ok:
type: boolean
id:
type: string
external_id:
type: string
deleted:
type: boolean
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: Tenant not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/tenants/{id}/stats:
parameters:
- name: id
in: path
required: true
description: The tenant's `external_id` or `id`
schema:
type: string
get:
operationId: getTenantStats
x-required-scope: full
summary: A tenant's email counts and sending ramp
description: Counts and rates for the tenant's emails created in the last `days`, and its sending ramp today. A key limited to one tenant can read its own tenant's stats.
tags:
- Tenants
security:
- bearerAuth: []
parameters:
- name: days
in: query
schema:
type: integer
minimum: 1
maximum: 90
default: 7
description: Window in days (1–90)
responses:
'200':
description: The tenant's stats
content:
application/json:
schema:
$ref: '#/components/schemas/TenantStatsResponse'
'400':
description: '`days` out of range'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: Tenant not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/tenants/{id}/domains:
parameters:
- name: id
in: path
required: true
description: The tenant's `external_id` or `id`
schema:
type: string
get:
operationId: listTenantDomains
x-required-scope: full
summary: List a tenant's domains
description: A key limited to one tenant can list its own tenant's domains.
tags:
- Tenants
security:
- bearerAuth: []
responses:
'200':
description: The tenant's domains, each with `tenant`
content:
application/json:
schema:
type: object
required:
- data
properties:
data:
type: array
items:
$ref: '#/components/schemas/Domain'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: Tenant not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
post:
operationId: assignTenantDomain
x-required-scope: full
summary: Give an existing domain to a tenant
description: Only this tenant can then send from the domain. A send from it without `tenant` is attributed to this tenant. To add a new domain straight to a tenant, use `POST /domains` with `tenant`.
tags:
- Tenants
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- domain
properties:
domain:
type: string
description: Domain name or id
example:
domain: mail.acme.com
responses:
'200':
description: The domain, now with `tenant`
content:
application/json:
schema:
type: object
required:
- domain
properties:
domain:
$ref: '#/components/schemas/Domain'
'400':
description: '`domain` missing'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: No such tenant, or no such domain in this account
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: Another tenant owns the domain. Remove it from that tenant first.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/tenants/{id}/domains/{domain}:
parameters:
- name: id
in: path
required: true
description: The tenant's `external_id` or `id`
schema:
type: string
- name: domain
in: path
required: true
description: Domain name or id
schema:
type: string
delete:
operationId: removeTenantDomain
x-required-scope: full
summary: Give a tenant's domain back to the account
description: The domain itself stays; every tenant can send from it again.
tags:
- Tenants
security:
- bearerAuth: []
responses:
'200':
description: The domain, now account-wide
content:
application/json:
schema:
type: object
required:
- domain
properties:
domain:
$ref: '#/components/schemas/Domain'
'404':
description: The tenant has no such domain
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
schemas:
TenantStats:
type: object
properties:
object:
type: string
const: tenant_stats
days:
type: integer
from:
type: string
format: date-time
total:
type: integer
queued:
type: integer
scheduled:
type: integer
sent:
type: integer
delivered:
type: integer
bounced:
type: integer
complained:
type: integer
failed:
type: integer
suppressed:
type: integer
opened:
type: integer
clicked:
type: integer
bounce_rate:
type: number
complaint_rate:
type: number
Tenant:
type: object
required:
- id
- object
- external_id
- name
- status
- stream_policy
- created_at
- updated_at
properties:
id:
type: string
format: uuid
object:
type: string
const: tenant
external_id:
type: string
name:
type: string
status:
type: string
enum:
- active
- paused
paused_reason:
type:
- string
- 'null'
paused_at:
type:
- string
- 'null'
format: date-time
pause_source:
type:
- string
- 'null'
enum:
- api
- admin
- automated
- null
description: 'Who paused the tenant: `api` (you), `automated` (Groundmail, after a high bounce or complaint rate) or `admin` (Groundmail support).
'
default_from_domain:
type:
- string
- 'null'
stream_policy:
type: string
enum:
- all
- transactional_only
daily_limit:
type:
- integer
- 'null'
metadata:
type:
- object
- 'null'
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
CreateTenantRequest:
allOf:
- type: object
required:
- external_id
properties:
external_id:
type: string
pattern: ^[A-Za-z0-9._:@-]{1,128}$
description: Your id for this customer. Unique in your account; cannot change.
- $ref: '#/components/schemas/TenantSettings'
ErrorResponse:
type: object
required:
- error
properties:
error:
type: object
required:
- message
properties:
message:
type: string
description: What went wrong, in plain English. Wording can change; branch on `code`.
code:
$ref: '#/components/schemas/ErrorCode'
hint:
type: string
description: 'How to fix the error. Specific to the failure when known (for example a sandbox recipient), otherwise the default for `code`.
'
docs_url:
type: string
format: uri
description: Link to this code on the errors page, `https://docs.groundmail.dev/errors#<code>`.
example: https://docs.groundmail.dev/errors#validation_error
upgrade_url:
type: string
description: 'Dashboard path to upgrade when code is plan_limit or domain_limit. Absent for `daily_ramp_limit`, which upgrading does not lift.
'
required_scope:
type: string
description: 'With `insufficient_scope`: the scope (or space-separated scopes) the operation needs. Also sent as `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`.
'
details:
description: 'Optional structured details. For send blocks this may include `suppressed` (global suppression list) or `category_unsubscribed` plus `category` and `email_id` (category opt-out). A `daily_ramp_limit` carries `stream` (`transactional` or `product`), `daily_limit` (today''s recipient limit for that stream), `used_today`, `recipients` (in this send), `ramp_stage` and `resets_at` (next 00:00 UTC, ISO 8601), plus `tenant` when the tenant''s own limit refused the send.
'
ListTenantsResponse:
type: object
required:
- data
- has_more
- next_cursor
properties:
data:
type: array
items:
$ref: '#/components/schemas/Tenant'
has_more:
type: boolean
next_cursor:
type:
- string
- 'null'
UpdateTenantRequest:
allOf:
- $ref: '#/components/schemas/TenantSettings'
- type: object
properties:
status:
type: string
enum:
- active
- paused
description: '`paused` stops the tenant''s new sends; `active` resumes them.'
reason:
type:
- string
- 'null'
description: Why you paused the tenant.
TenantRamp:
type: object
description: 'The tenant''s sending ramp today. Each new tenant starts at 1,000 product and 3,000 transactional recipients per UTC day, and the limit doubles each day it used at least half of it with bounces under 2% and complaints under 0.1%.
'
required:
- object
- active
- stage
- daily_limit
- used_today
- completed
- resets_at
properties:
object:
type: string
const: tenant_ramp
active:
type: boolean
description: '`false` when no ramp limit applies: the tenant has not sent yet, its ramp is complete, or support lifted it.'
stage:
type:
- integer
- 'null'
description: Current stage while the ramp is active.
daily_limit:
type:
- object
- 'null'
description: Today's recipient limits per stream; `null` when the ramp is not active.
properties:
transactional:
type:
- integer
- 'null'
product:
type:
- integer
- 'null'
used_today:
type: object
properties:
transactional:
type: integer
product:
type: integer
completed:
type: boolean
resets_at:
type: string
format: date-time
description: The next 00:00 UTC.
Domain:
type: object
required:
- id
- name
- status
- created_at
- verified_at
- open_tracking
- click_tracking
- tracking_status
- tracking_error
- tls_mode
- inbound_enabled
- inbound_status
- inbound_error
properties:
id:
type: string
name:
type: string
example: example.com
status:
type: string
enum:
- pending
- verified
- failed
created_at:
type: string
format: date-time
verified_at:
type: string
format: date-time
nullable: true
open_tracking:
type: boolean
description: Open tracking preference for this domain.
click_tracking:
type: boolean
description: Click tracking preference for this domain.
tracking_status:
type: string
enum:
- disabled
- pending
- active
- error
description: Whether open/click tracking is applied to outgoing mail. `disabled` when both preferences are off, `pending` until the tracking CNAME is accepted and a certificate is issued for the tracking hostname (links and opens are not tracked yet; this takes a few minutes after the CNAME is added), `active` once both are done, `error` when tracking or the certificate could not be set up. Re-checked when you verify the domain and when you read it.
tracking_error:
type: string
nullable: true
description: Why tracking is not active yet, when `tracking_status` is `pending` or `error`.
tls_mode:
type: string
enum:
- opportunistic
- enforced
description: Preferred outbound TLS behavior for this domain.
inbound_enabled:
type: boolean
description: True when Groundmail should accept inbound mail for this domain.
inbound_status:
type: string
enum:
- disabled
- pending
- active
- error
description: '`pending` before verification, `active` once inbound receiving is configured, or `error` when setup fails.'
inbound_error:
type: string
nullable: true
description: Last inbound configuration error, if any.
inbound_spam_threshold:
type: number
nullable: true
description: Received mail scoring at or above this is `spam` (and dropped when `inbound_spam_action` is `drop`). Null uses the default (10) and drops nothing.
inbound_spam_action:
type: string
enum:
- flag
- drop
description: '`flag` keeps spam with `spam_status: spam`; `drop` discards it (no stored email, no webhook).'
tenant:
type: string
description: The `external_id` of the tenant that owns this domain. Present only on a tenant's domain.
TenantResponse:
type: object
required:
- tenant
properties:
tenant:
$ref: '#/components/schemas/Tenant'
TenantSettings:
type: object
properties:
name:
type: string
maxLength: 200
description: Display name. Defaults to `external_id`.
default_from_domain:
type:
- string
- 'null'
description: Completes a bare `from` local part (`"billing"` → `billing@<domain>`) on sends that name this tenant.
stream_policy:
type: string
enum:
- all
- transactional_only
default: all
description: '`transactional_only` refuses product email for this tenant.'
daily_limit:
type:
- integer
- 'null'
minimum: 0
description: 'Most recipients this tenant may email per UTC day, on top of its sending ramp. `null` for no limit of your own.
'
metadata:
type:
- object
- 'null'
description: Any JSON object, up to 4 KB, for your own use.
TenantStatsResponse:
type: object
required:
- tenant
- stats
properties:
tenant:
type: string
description: The tenant's `external_id`.
stats:
$ref: '#/components/schemas/TenantStats'
ramp:
$ref: '#/components/schemas/TenantRamp'
ErrorCode:
type: string
description: 'Stable, machine-readable error code. Each code is documented at
`https://docs.groundmail.dev/errors#<code>`. New codes may be added;
existing codes keep their meaning.
- `validation_error` (400, 422): the request failed validation, or every recipient is suppressed or opted out (422). Fix the field named in `message`/`details`.
- `unauthorized` (401): missing, malformed or revoked API key. Send `Authorization: Bearer gm_…`.
- `forbidden` (403): the key is valid but the action is not allowed (unverified `from` domain, sandbox send to someone other than the account email, no payment method on file, sending restricted).
- `insufficient_scope` (403): the API key''s scope does not cover this endpoint.
- `domain_not_allowed` (403): the API key is limited to other sending domains than the one in `from`, or `from` is on another tenant''s domain.
- `tenants_not_enabled` (403): the request names a tenant, but tenants are not enabled for this account. They are included on pay as you go and the Dedicated plan.
- `newsletter_not_enabled` (403): the send uses `category: "newsletter"`, but newsletter sending isn''t enabled on this account. Newsletter email is accepted only for approved accounts.
- `not_found` (404): no resource with that id for this team.
- `conflict` (409): the resource already exists or its state blocks the change.
- `plan_limit` (429): the plan''s monthly or daily sending limit was reached. See `upgrade_url`.
- `daily_ramp_limit` (429): the send would go over a new account''s daily ramp-up limit for its stream, or its tenant''s own ramp or daily limit (`details.tenant`). Upgrading does not lift it; wait for `details.resets_at`.
- `domain_limit` (403): the plan''s domain limit was reached. See `upgrade_url`.
- `idempotency_conflict` (409): the `Idempotency-Key` was reused with a different body, or the first request is still running.
- `rate_limited` (429): too many requests. Wait `Retry-After` seconds.
- `timeout` (408): a long-poll (GET /test-inbox/wait) ended before a matching email arrived. Safe to call again.
- `upstream_error` (502, 503, 504, or a passed-through 4xx): the mail server did not accept the request. 502/503 are safe to retry with the same `Idempotency-Key`; 504 means the outcome is unknown.
- `not_configured` (503): the feature is temporarily unavailable.
- `internal_error` (500): unexpected server error. Retry with backoff.
'
enum:
- validation_error
- unauthorized
- forbidden
- insufficient_scope
- not_found
- conflict
- plan_limit
- daily_ramp_limit
- domain_limit
- idempotency_conflict
- rate_limited
- timeout
- upstream_error
- not_configured
- internal_error
- domain_not_allowed
- tenants_not_enabled
- newsletter_not_enabled
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: 'Groundmail API key (`gm_…`), OAuth access token (`gm_oat_…`), or a Microsoft Entra access token from an Azure managed identity registered in the dashboard (keyless; see https://docs.groundmail.dev/authentication/azure-managed-identity). All carry scopes; see the API description.
'