Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Groundmail Migrations 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: Migrations
description: 'Beta. Import templates, suppressions, sending domains and webhooks from SendGrid, Postmark, Mailgun, Resend or Amazon SES. The provider key (Amazon SES: access key and secret) is used for the request only and never stored.'
paths:
/migrations:
get:
operationId: listMigrations
x-required-scope: account:read | domains:write | webhooks:write | templates:write | suppressions:write
summary: List imports (Beta)
description: 'Your recent imports from SendGrid, Postmark, Mailgun, Resend or Amazon SES, newest first, with running totals. Previews (`dry_run: true`) are not stored.'
tags:
- Migrations
security:
- bearerAuth: []
parameters:
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 20
responses:
'200':
description: Recent imports
content:
application/json:
schema:
$ref: '#/components/schemas/MigrationListResponse'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: The key lacks a scope that can read imports (`insufficient_scope`)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
post:
operationId: createMigration
x-required-scope: domains:write | webhooks:write | templates:write | suppressions:write (each resource imported needs its own)
summary: Import from SendGrid, Postmark, Mailgun, Resend or Amazon SES (Beta)
description: 'Reads your account at SendGrid, Postmark, Mailgun, Resend or Amazon SES with the key you send and imports what you select: sending domains (with the exact DNS changes, compared with what each domain publishes today), webhooks (created disabled, with events mapped; Amazon SES has none, so its configuration set event destinations are listed in `notes` instead), templates (variables converted; anything that cannot convert, and templates with an unsubscribe link, are imported as drafts and listed), and suppressions (hard bounces, complaints, unsubscribes; partial opt-outs become product email opt-outs; bounces caused by a block on the old provider''s servers and temporary failures are skipped). Real imports are stored: see `GET /migrations/{id}` and `/migrations/{id}/report`.
The key is used for this request only. It is never stored, logged or returned. Resend needs a key with Full access (a Sending access key cannot read the account). Amazon SES takes `aws_access_key_id`, `aws_secret_access_key` (and `aws_session_token` for temporary credentials) for an IAM user or role with the read-only import policy, plus `aws_region`: one region per import.
One request works for about 40 seconds. When there is more to do, the response has `has_more: true` and a `next_cursor`: send the same body again with `cursor` set to continue (wait `retry_after_seconds` first when it is set). Each response reports the items it handled and running totals. Re-running an import is safe: domains, webhooks (same URL), templates (same alias) and suppressions that already exist are skipped. Set `dry_run: true` to preview without writing anything.
Rate limits: 10 new imports per workspace per hour; 300 continuation requests per hour.'
tags:
- Migrations
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateMigrationRequest'
responses:
'200':
description: What this request imported, running totals, and (when finished) the code change
content:
application/json:
schema:
$ref: '#/components/schemas/MigrationResponse'
'400':
description: 'Invalid request, or the provider rejected the key (`validation_error` with `details.reason: provider_auth_failed`).
'
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: The key lacks the write scope of a selected resource (`insufficient_scope`)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: Too many imports (`rate_limited`, `Retry-After`)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'502':
description: 'The provider could not be reached (`upstream_error`, `details.reason: provider_unavailable`)'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/migrations/{id}:
get:
operationId: getMigration
x-required-scope: account:read | domains:write | webhooks:write | templates:write | suppressions:write
summary: Retrieve an import report (Beta)
description: 'The stored report of one import, with every request merged: each domain (with its `dns_plan`), webhook and template, suppression totals and skip reasons, errors, next steps, the code change and the agent prompt. Webhook signing secrets are not stored, so `secret` is always null here.'
tags:
- Migrations
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: The import report
content:
application/json:
schema:
$ref: '#/components/schemas/MigrationResponse'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: No import with this id in your workspace (previews are not stored)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/migrations/{id}/report:
get:
operationId: downloadMigrationReport
x-required-scope: account:read | domains:write | webhooks:write | templates:write | suppressions:write
summary: Download an import report (Beta)
description: 'The import report as a file. CSV (the default) has the columns `section`, `item`, `status` and `detail`: one row per domain (with the DNS changes still to make), webhook and template, the suppression totals and each skip reason, resources that could not be read, next steps and notes. `format=json` returns the same report as `GET /migrations/{id}`.'
tags:
- Migrations
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
- name: format
in: query
required: false
schema:
type: string
enum:
- csv
- json
default: csv
responses:
'200':
description: The report, as an attachment
content:
text/csv:
schema:
type: string
application/json:
schema:
$ref: '#/components/schemas/MigrationResponse'
'400':
description: '`format` is not `csv` or `json`'
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: No import with this id in your workspace
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
schemas:
MigrationDnsPlan:
type: object
required:
- checked
- changes
- keep_old_records
properties:
checked:
type: boolean
description: False when DNS could not be read; every record is then listed as `add`.
changes:
type: array
items:
$ref: '#/components/schemas/MigrationDnsChange'
keep_old_records:
type: string
description: Which of the old provider's records to keep while you switch, and when to remove them
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.
'
MigrationItemStatus:
type: string
enum:
- imported
- would_import
- needs_attention
- skipped
- failed
MigrationSummary:
type: object
required:
- id
- provider
- status
- resources
- started_at
- finished_at
- totals
properties:
id:
type: string
format: uuid
provider:
type: string
enum:
- sendgrid
- postmark
- mailgun
- resend
- ses
status:
type: string
enum:
- in_progress
- completed
- partial
- failed
resources:
type: array
items:
type: string
enum:
- domains
- webhooks
- templates
- suppressions
started_at:
type: string
format: date-time
finished_at:
type: string
format: date-time
nullable: true
totals:
type: object
description: Totals per resource imported
additionalProperties:
$ref: '#/components/schemas/MigrationTotals'
MigrationResponse:
type: object
required:
- migration
properties:
migration:
$ref: '#/components/schemas/Migration'
MigrationDnsChange:
type: object
required:
- action
- type
- host
- value
- purpose
- current
- note
properties:
action:
type: string
enum:
- add
- update
- keep
- done
description: '`add` a new record; `update` the record at this name to `value` (for SPF, our include added to your existing record, never a second SPF record); `keep` yours as it is (for example your DMARC record); `done` when it is already published.
'
type:
type: string
host:
type: string
value:
type: string
description: The value to publish (for `keep`, what is there now)
purpose:
type: string
current:
type: string
nullable: true
description: What this name holds today, when it holds something relevant
note:
type: string
nullable: true
Migration:
type: object
required:
- id
- provider
- dry_run
- status
- started_at
- resources
- has_more
- next_cursor
- errors
- notes
- attention
- template_map
properties:
id:
type: string
format: uuid
description: Shared by every request of one import
provider:
type: string
enum:
- sendgrid
- postmark
- mailgun
- resend
- ses
dry_run:
type: boolean
status:
type: string
enum:
- in_progress
- completed
- partial
- failed
description: '`partial` when some items or resources could not be imported (each is listed); `failed` when nothing could be read.
'
started_at:
type: string
format: date-time
resources:
type: array
items:
type: string
enum:
- domains
- webhooks
- templates
- suppressions
has_more:
type: boolean
next_cursor:
type: string
nullable: true
retry_after_seconds:
type: integer
nullable: true
description: Wait this long before continuing (the provider rate-limited the import).
domains:
type: object
properties:
totals:
$ref: '#/components/schemas/MigrationTotals'
items:
type: array
description: Domains handled by this request
items:
type: object
properties:
name:
type: string
status:
$ref: '#/components/schemas/MigrationItemStatus'
domain_id:
type: string
nullable: true
verified_at_source:
type: boolean
nullable: true
records:
type: array
description: DNS records to publish (also available from GET /domains/{id})
items:
type: object
properties:
type:
type: string
host:
type: string
value:
type: string
purpose:
type: string
dns_plan:
allOf:
- $ref: '#/components/schemas/MigrationDnsPlan'
nullable: true
description: 'The records compared with what the domain publishes today. Null in a preview, or when the import ran out of time to check (the domain page runs the same check).
'
dashboard_url:
type: string
nullable: true
message:
type: string
nullable: true
webhooks:
type: object
properties:
totals:
$ref: '#/components/schemas/MigrationTotals'
items:
type: array
items:
type: object
properties:
url:
type: string
status:
$ref: '#/components/schemas/MigrationItemStatus'
webhook_id:
type: string
nullable: true
secret:
type: string
nullable: true
description: Signing secret of a webhook this request created. Shown once.
enabled:
type: boolean
description: Imported webhooks start disabled until your handler accepts Groundmail events.
source_events:
type: array
items:
type: string
events:
type: array
items:
type: string
unmapped_events:
type: array
items:
type: string
message:
type: string
nullable: true
templates:
type: object
properties:
totals:
$ref: '#/components/schemas/MigrationTotals'
items:
type: array
items:
type: object
properties:
source_id:
type: string
name:
type: string
status:
$ref: '#/components/schemas/MigrationItemStatus'
template_id:
type: string
nullable: true
alias:
type: string
nullable: true
published:
type: boolean
variables:
type: array
items:
type: string
notes:
type: array
items:
type: string
description: What changed in conversion, and anything that did not convert
message:
type: string
nullable: true
suppressions:
type: object
properties:
totals:
$ref: '#/components/schemas/MigrationTotals'
skipped_reasons:
type: object
additionalProperties:
type: integer
estimated:
type: boolean
errors:
type: array
description: Resources that could not be read at all (for example, a key without that permission)
items:
type: object
properties:
resource:
type: string
code:
type: string
message:
type: string
notes:
type: array
items:
type: string
attention:
type: array
description: What you still need to do (publish DNS, enable webhooks, review drafts)
items:
type: string
template_map:
type: array
description: Provider template id → Groundmail alias, for updating your send calls
items:
type: object
properties:
source_id:
type: string
alias:
type: string
code_changes:
type: array
nullable: true
description: When the import has finished, the send call before and after, per stack
items:
type: object
properties:
stack:
type: string
enum:
- node
- python
- curl
label:
type: string
diff:
type: string
agent_prompt:
type: string
nullable: true
description: When the import has finished, a prompt for your coding agent to make the code change
MigrationTotals:
type: object
additionalProperties:
type: integer
description: 'Running totals for the whole import. Keys: `found`, `imported`, `skipped`, `failed`; templates add `needs_attention`; suppressions add `already_suppressed`, `bounce`, `complaint`, `unsubscribe`, `manual` and `product_opt_out` (opt-outs that only covered some of your mail at the old provider; they stop product email only). In a dry run `imported` counts what would be imported.
'
MigrationListResponse:
type: object
required:
- data
properties:
data:
type: array
items:
$ref: '#/components/schemas/MigrationSummary'
CreateMigrationRequest:
type: object
required:
- provider
description: '`api_key` for SendGrid, Postmark, Mailgun and Resend; `aws_access_key_id`, `aws_secret_access_key` and `aws_region` for Amazon SES (`ses`).
'
properties:
provider:
type: string
enum:
- sendgrid
- postmark
- mailgun
- resend
- ses
description: The provider to import from. `ses` is Amazon SES.
api_key:
type: string
writeOnly: true
description: 'Required for every provider except `ses`. SendGrid API key, Postmark server API token, or Mailgun private API key (read access is enough), or a Resend API key with Full access. Used for this request only; never stored.
'
account_api_key:
type: string
writeOnly: true
description: Postmark only. The account API token, needed to import sending domains.
region:
type: string
enum:
- us
- eu
default: us
description: SendGrid and Mailgun only. Use `eu` for EU-region accounts.
aws_access_key_id:
type: string
writeOnly: true
description: 'Amazon SES only (required). Access key ID of an IAM user or role with the read-only import policy (ses:List* and ses:Get* calls for identities, templates, the suppression list and configuration sets). Used for this request only; never stored.
'
aws_secret_access_key:
type: string
writeOnly: true
description: Amazon SES only (required). The secret access key. Used for this request only; never stored.
aws_session_token:
type: string
writeOnly: true
description: Amazon SES only. The session token, for temporary credentials (SSO or an assumed role).
aws_region:
type: string
example: us-east-1
description: 'Amazon SES only (required). The region to import, such as `us-east-1`. SES keeps identities, templates and suppressions per region: run one import per region.
'
resources:
type: array
items:
type: string
enum:
- domains
- webhooks
- templates
- suppressions
description: What to import. Defaults to all four. Ignored when `cursor` is set.
dry_run:
type: boolean
default: false
description: 'Preview without writing anything. Suppressions are counted from the first page of each list only (`suppressions.estimated: true`). Ignored when `cursor` is set.
'
cursor:
type: string
description: The `next_cursor` from the previous response, to continue the same import.
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.
'