Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Groundmail Test inbox 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: Test inbox
description: A test domain per team. Mail sent to it from any email provider is caught and never delivered, so tests can wait for an email and read its code and links.
paths:
/test-inbox:
get:
operationId: getTestInbox
x-required-scope: emails:read
summary: Get your team's test domain and usage
description: Your team's test domain (created on the first call), an example address, this month's usage and whether the email testing add-on is on. Mail sent to any address at the test domain, from any email provider, is caught in your test inbox and never delivered. Use a fresh address per test.
tags:
- Test inbox
security:
- bearerAuth: []
responses:
'200':
description: The team's test inbox
content:
application/json:
schema:
$ref: '#/components/schemas/TestInboxResponse'
'401':
description: Missing or invalid API key / session
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/test-inbox/emails:
get:
operationId: listTestInboxEmails
x-required-scope: emails:read
summary: List caught test emails
description: Emails caught at your test domain, plus emails sent with a `gm_test_` key, newest first. Kept for 14 days.
tags:
- Test inbox
security:
- bearerAuth: []
parameters:
- name: to
in: query
schema:
type: string
format: email
description: Only emails to this exact address.
- name: since
in: query
schema:
type: string
format: date-time
description: Only emails received at or after this time (ISO 8601).
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 25
- name: cursor
in: query
schema:
type: string
description: Opaque cursor for the next page
responses:
'200':
description: Caught test emails
content:
application/json:
schema:
$ref: '#/components/schemas/ListTestInboxEmailsResponse'
'400':
description: Invalid `to`, `since` or cursor
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Missing or invalid API key / session
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/test-inbox/emails/{id}:
get:
operationId: getTestInboxEmail
x-required-scope: emails:read
summary: Retrieve a caught test email
description: The full email with bodies, headers and source, plus the extracted `code` and `links`.
tags:
- Test inbox
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: Test email id (`tie_…`)
schema:
type: string
responses:
'200':
description: Test email detail
content:
application/json:
schema:
$ref: '#/components/schemas/TestInboxEmailResponse'
'401':
description: Missing or invalid API key / session
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: Test email not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/test-inbox/wait:
get:
operationId: waitForTestInboxEmail
x-required-scope: emails:read
summary: Wait for a test email (long-poll)
description: Holds the request open until an email to `to` arrives, then returns it (the newest match, received at or after `since` when given). An email that already arrived is returned at once. Waits `timeout` seconds (default 30, at most 60) and returns 408 `timeout` if nothing arrives; calling again is safe.
tags:
- Test inbox
security:
- bearerAuth: []
parameters:
- name: to
in: query
required: true
schema:
type: string
format: email
description: The exact address your app sent to.
- name: since
in: query
schema:
type: string
format: date-time
description: Only emails received at or after this time (ISO 8601).
- name: timeout
in: query
schema:
type: number
minimum: 0
maximum: 60
default: 30
description: Seconds to wait. Values over 60 are treated as 60.
responses:
'200':
description: The email that arrived
content:
application/json:
schema:
$ref: '#/components/schemas/TestInboxEmailResponse'
'400':
description: Missing or invalid `to`, `since` or `timeout`
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Missing or invalid API key / session
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'408':
description: Nothing arrived in time (`code` is `timeout`)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
error:
message: No email to signup-k3m9x2pq@acme.test.groundmail.dev arrived within 30 seconds
code: timeout
hint: Nothing arrived in time. Check that your app sent the email to the exact address you are waiting on, then call again.
docs_url: https://docs.groundmail.dev/errors#timeout
details:
to: signup-k3m9x2pq@acme.test.groundmail.dev
timeout: 30
components:
schemas:
TestInboxResponse:
type: object
required:
- test_inbox
properties:
test_inbox:
$ref: '#/components/schemas/TestInbox'
TestInboxEmail:
type: object
required:
- object
- id
- source
- email_id
- from
- to
- cc
- subject
- code
- links
- received_at
- expires_at
properties:
object:
type: string
enum:
- test_inbox_email
id:
type: string
example: tie_3f9c2a1b7d8e4f6a0b1c2d3e
source:
type: string
enum:
- inbound
- test_key
description: '`inbound`: received at your test domain (from any provider). `test_key`: sent through Groundmail with a `gm_test_` key.
'
email_id:
type: string
nullable: true
description: With `test_key`, the sent email's id (see GET /emails/{id}).
from:
type: string
to:
type: array
items:
type: string
cc:
type: array
items:
type: string
subject:
type: string
code:
type: string
nullable: true
description: 'The one-time code found in the email (4 to 8 digits), preferring one next to words like "code" or "verification". Null when there is none.
'
example: '482913'
links:
type: array
description: http(s) links in the email, in order, without duplicates.
items:
type: string
received_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
description: When the email is deleted (14 days after it arrived).
TestInbox:
type: object
required:
- object
- domain
- address
- addon
- usage
- retention_days
properties:
object:
type: string
enum:
- test_inbox
domain:
type: string
description: Your team's test domain. Any address at it is caught.
example: acme.test.groundmail.dev
address:
type: string
description: An example fresh address. Any local part works; use a new one per test.
example: run-k3m9x2pq@acme.test.groundmail.dev
addon:
type: object
required:
- active
- status
properties:
active:
type: boolean
description: Whether the email testing add-on is on.
status:
type: string
nullable: true
description: Billing status of the add-on, if it was ever bought.
usage:
$ref: '#/components/schemas/TestInboxUsage'
retention_days:
type: integer
description: Days caught emails are kept.
example: 14
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.
'
TestInboxEmailResponse:
type: object
required:
- email
properties:
email:
$ref: '#/components/schemas/TestInboxEmailDetail'
ListTestInboxEmailsResponse:
type: object
required:
- emails
- has_more
- next_cursor
properties:
emails:
type: array
items:
$ref: '#/components/schemas/TestInboxEmail'
has_more:
type: boolean
next_cursor:
type: string
nullable: true
TestInboxHeader:
type: object
required:
- name
- value
properties:
name:
type: string
value:
type: string
TestInboxUsage:
type: object
required:
- month
- received
- rejected
- included
- overage
- resets_at
properties:
month:
type: string
description: Calendar month in UTC, `YYYY-MM`.
example: 2026-10
received:
type: integer
description: Test emails accepted this month.
rejected:
type: integer
description: Test emails refused this month because the limit was reached.
included:
type: integer
description: Test emails included this month (100 free, 5,000 with the add-on).
overage:
type: boolean
description: 'True with the email testing add-on: mail past `included` is accepted and billed per 1,000. False: mail past `included` is refused until `resets_at`.
'
resets_at:
type: string
format: date-time
description: When the monthly allowance resets (the first of next month, UTC).
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
TestInboxEmailDetail:
allOf:
- $ref: '#/components/schemas/TestInboxEmail'
- type: object
required:
- text_body
- html_body
- headers
- raw_source
properties:
text_body:
type: string
nullable: true
html_body:
type: string
nullable: true
headers:
type: array
description: Message headers in order.
items:
$ref: '#/components/schemas/TestInboxHeader'
raw_source:
type: string
nullable: true
description: Raw message source, when available.
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.
'