groundmail-dev Migrations API

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.

Operations 4

GET /migrations List imports (Beta) #
POST /migrations Import from SendGrid, Postmark, Mailgun, Resend or Amazon SES (Beta) #
GET /migrations/{id} Retrieve an import report (Beta) #
GET /migrations/{id}/report Download an import report (Beta) #

Work with this as data

Every API here is available over the APIs.io API and to AI agents over MCP.

MCP server

One button, every client — Claude, Cursor, VS Code and the rest.

https://apis.io/mcp

Tools for apis

7 MCP tools reach this
  • find_apisBrowse and filter every API in the catalog.
  • get_api_artifactsOne API's artifacts, grouped by type.
  • get_openapiThe primary OpenAPI for this API.
  • find_similar_apisAPIs that look like this one.
  • apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.
  • resolveTurn a domain, URL or GitHub org into the provider it belongs to.
  • find_cohortsEvery scored population of providers in the catalog.
All 92 tools →

Call it yourself

curl for this page
This API
curl "https://apis.io/api/v1/apis/groundmail-dev:groundmail-dev-migrations-api"
All apis
curl "https://apis.io/api/v1/apis?limit=25"

Discovery needs no key. Ratings and market analysis are Pro.

Get an API key

Free tier, no form to fill in. Signing in shares your email address with us — we store it to create your key and to recognise you if you sign in with another provider. See our Privacy Policy and Terms.

A second provider on the same verified email joins the account you already have.

OpenAPI Specification

groundmail-dev-migrations-api-openapi.yml Raw ↑
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.

        '