---
title: "Import from SendGrid, Postmark or Mailgun (Beta)"
method: POST
path: "/migrations"
tags: ["Migrations"]
---

# Import from SendGrid, Postmark or Mailgun (Beta)

`POST /migrations`

## Request

- Base URL: `https://api.groundmail.dev`
- URL: `https://api.groundmail.dev/migrations`
- Auth: HTTP bearer

Reads your account at SendGrid, Postmark or Mailgun with the API 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), 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.

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.

## Request body

- CreateMigrationRequest
  - `provider` 'sendgrid' | 'postmark' | 'mailgun', required
  - `api_key` string, required: SendGrid API key, Postmark server API token, or Mailgun private API key. Read access is enough. Used for this request only; never stored.
  - `account_api_key` string: Postmark only. The account API token, needed to import sending domains.
  - `region` 'us' | 'eu': SendGrid and Mailgun only. Use `eu` for EU-region accounts.
  - `resources` string[]: What to import. Defaults to all four. Ignored when `cursor` is set.
  - `dry_run` boolean: Preview without writing anything. Suppressions are counted from the first page of each list only (`suppressions.estimated: true`). Ignored when `cursor` is set.
  - `cursor` string: The `next_cursor` from the previous response, to continue the same import.

## Response `200`

What this request imported, running totals, and (when finished) the code change

- MigrationResponse
  - `migration` Migration, required
    - `id` string, uuid, required: Shared by every request of one import
    - `provider` 'sendgrid' | 'postmark' | 'mailgun', required
    - `dry_run` boolean, required
    - `status` 'in_progress' | 'completed' | 'partial' | 'failed', required: `partial` when some items or resources could not be imported (each is listed); `failed` when nothing could be read.
    - `started_at` string, date-time, required
    - `resources` string[], required
    - `has_more` boolean, required
    - `next_cursor` string, nullable, required
    - `retry_after_seconds` integer, nullable: Wait this long before continuing (the provider rate-limited the import).
    - `domains` object
      - `totals` MigrationTotals: 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.
      - `items` object[]: Domains handled by this request
        - `name` string
        - `status` 'imported' | 'would_import' | 'needs_attention' | 'skipped' | 'failed'
        - `domain_id` string, nullable
        - `verified_at_source` boolean, nullable
        - `records` object[]: DNS records to publish (also available from GET /domains/{id})
          - `type` string
          - `host` string
          - `value` string
          - `purpose` string
        - `dns_plan` MigrationDnsPlan
          - `checked` boolean, required: False when DNS could not be read; every record is then listed as `add`.
          - `changes` MigrationDnsChange[], required
            - `action` 'add' | 'update' | 'keep' | 'done', required: `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` string, required
            - `host` string, required
            - `value` string, required: The value to publish (for `keep`, what is there now)
            - `purpose` string, required
            - `current` string, nullable, required: What this name holds today, when it holds something relevant
            - `note` string, nullable, required
          - `keep_old_records` string, required: Which of the old provider's records to keep while you switch, and when to remove them
        - `dashboard_url` string, nullable
        - `message` string, nullable
    - `webhooks` object
      - `totals` MigrationTotals: 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.
      - `items` object[]
        - `url` string
        - `status` 'imported' | 'would_import' | 'needs_attention' | 'skipped' | 'failed'
        - `webhook_id` string, nullable
        - `secret` string, nullable: Signing secret of a webhook this request created. Shown once.
        - `enabled` boolean: Imported webhooks start disabled until your handler accepts Groundmail events.
        - `source_events` string[]
        - `events` string[]
        - `unmapped_events` string[]
        - `message` string, nullable
    - `templates` object
      - `totals` MigrationTotals: 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.
      - `items` object[]
        - `source_id` string
        - `name` string
        - `status` 'imported' | 'would_import' | 'needs_attention' | 'skipped' | 'failed'
        - `template_id` string, nullable
        - `alias` string, nullable
        - `published` boolean
        - `variables` string[]
        - `notes` string[]: What changed in conversion, and anything that did not convert
        - `message` string, nullable
    - `suppressions` object
      - `totals` MigrationTotals: 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.
      - `skipped_reasons` object
      - `estimated` boolean
    - `errors` object[], required: Resources that could not be read at all (for example, a key without that permission)
      - `resource` string
      - `code` string
      - `message` string
    - `notes` string[], required
    - `attention` string[], required: What you still need to do (publish DNS, enable webhooks, review drafts)
    - `template_map` object[], required: Provider template id → Groundmail alias, for updating your send calls
      - `source_id` string
      - `alias` string
    - `code_changes` object[], nullable: When the import has finished, the send call before and after, per stack
      - `stack` 'node' | 'python' | 'curl'
      - `label` string
      - `diff` string
    - `agent_prompt` string, nullable: When the import has finished, a prompt for your coding agent to make the code change

## Other responses

- `400`: Invalid request, or the provider rejected the key (`validation_error` with `details.reason: provider_auth_failed`).
- `401`: Missing or invalid API key
- `403`: The key lacks the write scope of a selected resource (`insufficient_scope`)
- `429`: Too many imports (`rate_limited`, `Retry-After`)
- `502`: The provider could not be reached (`upstream_error`, `details.reason: provider_unavailable`)

---

[API](https://skmtc.dev/groundmail/apis/groundmail-api.md) · [All operations](https://skmtc.dev/groundmail/apis/groundmail-api/llms.txt) · [OpenAPI document](https://skmtc.dev/groundmail/apis/groundmail-api/revisions/479821c9fecf?raw)
