Import from SendGrid, Postmark or Mailgun

Bring your setup over from SendGrid, Postmark or Mailgun in one step: paste an API key from your current provider and Groundmail imports what it finds. Nothing changes in your old account, so you can keep sending from it until you cut over.

What How it comes across
Suppressions Hard bounces, spam complaints and unsubscribes are added to your Groundmail suppression list, so you never mail an address your old provider already stopped. Opt-outs that only covered some of your mail (unsubscribe groups, broadcast streams, tags) become product email opt-outs. Bounces caused by a block on your old provider's sending servers, and temporary failures, are left off: those addresses are fine. Existing entries are kept.
Templates Converted to Groundmail templates with their variables. Templates that convert cleanly are published under an alias; anything that does not convert (for example {{#if}} or {{#each}} blocks), and any template with an unsubscribe link, is imported as a draft with a note on what to check.
Sending domains Added to your workspace, with the exact DNS changes compared with what the domain publishes today: which records to add, how to edit your existing SPF record, and which records to keep. On Cloudflare, Vercel and GoDaddy applying them is one click from the domain's page.
Webhooks Recreated with the events mapped to Groundmail event types. They start disabled, because Groundmail sends a different payload and signature; enable them once your handler is updated.

At the end you get a summary of what was imported and what needs attention, the change to make in your send code (Node.js, Python and cURL), and a prompt you can give your coding agent to make that change. The report is kept, so you can download it later as CSV or JSON.

Your provider key is used for the import only. It is never stored, logged or returned.

Where to run it

  • Dashboard: Import in the sidebar.
  • CLI: groundmail migrate sendgrid (or postmark, mailgun). See CLI.
  • MCP: the import_from_provider tool, and get_import_report for the stored report. See MCP server.
  • API: POST /migrations, below.

Switching, step by step

The Import page keeps a checklist for the whole switch, per workspace:

  1. Import from your current provider (preview first if you like).
  2. Verify your sending domains. Publish the DNS changes shown for each domain. Your old provider's DKIM and return-path records use different names from Groundmail's, so leave them in place while you switch: mail from both keeps passing DKIM until your app sends only through Groundmail.
  3. Send a test email, for example one of the imported templates to yourself.
  4. Point your app at Groundmail: swap the API key (or the SMTP host, username and password) and update the send calls with the code change from the import. You tick this step when it is deployed.
  5. Cutover check. The last 24 hours of sending (delivered, bounced, complaints) with a verdict once there is enough mail. Mark it switched when everything goes through Groundmail; keep the old account and DNS records for a few days in case you need to roll back, then remove them and the old SPF include.

Steps 1 to 3 tick themselves. Sending a lot of mail? We'll do it for you on the Import page asks our team to run the switch with you.

Which key to use

Provider Key Notes
SendGrid API key (SG.…) Read access to templates, suppressions, sender authentication and the event webhook is enough. A full-access key works too. EU-pinned accounts: region: "eu".
Postmark Server API token Imports templates, suppressions and webhooks for that server. Add the account API token (account_api_key) to import sending domains. Run once per server.
Mailgun Private API key Reads every sending domain on the account. EU accounts: region: "eu".

Create an import

POST /migrations with a key that has the write scope for each resource you import (domains:write, webhooks:write, templates:write, suppressions:write), or a full-access key.

curl -X POST https://api.groundmail.dev/migrations \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "sendgrid",
    "api_key": "'"$SENDGRID_API_KEY"'",
    "resources": ["suppressions", "templates", "domains", "webhooks"],
    "dry_run": true
  }'
Field Type Description
provider string sendgrid, postmark or mailgun
api_key string Your provider key (Postmark: server API token)
account_api_key string Postmark only: account API token, for sending domains
region string us (default) or eu, for SendGrid and Mailgun
resources string[] Any of domains, webhooks, templates, suppressions. Default: all
dry_run boolean Preview without writing anything. Suppressions are counted from the first page of each list
cursor string next_cursor from the previous response, to continue

Long imports continue with a cursor

One request works for about 40 seconds. Large suppression lists take several requests: while has_more is true, send the same body again with cursor set to next_cursor (wait retry_after_seconds first when it is set, which means your provider rate-limited the import). Each response lists the items it handled and the running totals. The SDK, CLI and MCP tool follow the cursor for you:

import { Groundmail } from 'groundmail'

const groundmail = new Groundmail(process.env.GROUNDMAIL_API_KEY!)
const report = await groundmail.migrations.run(
  { provider: 'postmark', api_key: process.env.POSTMARK_SERVER_TOKEN!, account_api_key: process.env.POSTMARK_ACCOUNT_TOKEN },
  { onProgress: (m) => console.log(m.suppressions?.totals.found ?? 0, 'suppressions read') },
)
console.log(report.attention)

Response

{
  "migration": {
    "id": "6c1f…",
    "provider": "sendgrid",
    "dry_run": false,
    "status": "partial",
    "has_more": false,
    "next_cursor": null,
    "domains": {
      "totals": { "found": 2, "imported": 1, "skipped": 0, "failed": 1 },
      "items": [
        {
          "name": "acme.com",
          "status": "imported",
          "domain_id": "…",
          "records": [/* DNS records */],
          "dns_plan": {
            "checked": true,
            "changes": [
              { "action": "add", "type": "TXT", "host": "_groundmail.acme.com", "value": "groundmail-verify=…", "purpose": "Domain ownership", "current": null, "note": null },
              { "action": "update", "type": "TXT", "host": "acme.com", "value": "v=spf1 include:sendgrid.net include:spf.groundmail.dev ~all", "purpose": "SPF", "current": "v=spf1 include:sendgrid.net ~all", "note": "Edit your existing SPF record instead of adding a second one (two SPF records fail SPF). Keep include:sendgrid.net until the cutover, then remove it." },
              { "action": "keep", "type": "TXT", "host": "_dmarc.acme.com", "value": "v=DMARC1; p=quarantine", "purpose": "DMARC", "current": "v=DMARC1; p=quarantine", "note": "Keep your DMARC record; a domain has one. …" }
            ],
            "keep_old_records": "Keep your SendGrid records (the s1._domainkey and s2._domainkey CNAMEs and the em… CNAME) while you switch: …"
          },
          "dashboard_url": "/domains/…"
        },
        { "name": "mail.acme.io", "status": "failed", "message": "Domain limit reached (1 on the Free plan). Upgrade at /settings/billing." }
      ]
    },
    "webhooks": {
      "totals": { "found": 1, "imported": 1, "skipped": 0, "failed": 0 },
      "items": [
        {
          "url": "https://acme.com/hooks/sendgrid",
          "status": "imported",
          "enabled": false,
          "secret": "whsec_…",
          "events": ["email.delivered", "email.bounced", "email.failed", "email.suppressed", "email.complained"],
          "unmapped_events": ["group_resubscribe"]
        }
      ]
    },
    "templates": {
      "totals": { "found": 3, "imported": 2, "needs_attention": 1, "skipped": 0, "failed": 0 },
      "items": [
        { "name": "Welcome", "alias": "welcome", "status": "imported", "published": true, "variables": ["first_name", "team_name"], "notes": ["`team.name` became `{{team_name}}`: send it as a flat variable."] },
        { "name": "Order receipt", "alias": "order-receipt", "status": "needs_attention", "published": false, "notes": ["Template logic has no Groundmail equivalent yet: `{{#each items}}`, …"] }
      ]
    },
    "suppressions": {
      "totals": { "found": 507, "imported": 505, "already_suppressed": 2, "skipped": 2, "failed": 0, "bounce": 502, "complaint": 1, "unsubscribe": 2, "manual": 0, "product_opt_out": 2 },
      "skipped_reasons": {
        "Not a valid email address": 1,
        "Bounces caused by a block on SendGrid's sending servers, not by the address (kept off your list so mail still reaches them)": 1
      },
      "estimated": false
    },
    "errors": [],
    "attention": ["Publish the DNS records for 1 domain and verify it. …"],
    "template_map": [{ "source_id": "d-1111…", "alias": "welcome" }],
    "code_changes": [{ "stack": "node", "label": "Node.js", "diff": "-await sgMail.send({…})\n+await groundmail.emails.send({…})" }],
    "agent_prompt": "Switch this app's transactional email from SendGrid to Groundmail. …"
  }
}

Item status is imported, would_import (dry run), needs_attention (imported as a draft), skipped (already in your workspace) or failed (with a message). The import status is completed, partial (some items or resources failed; each is listed), failed (nothing could be read) or in_progress.

A domain's dns_plan compares each record with what the domain publishes today. action is add (a new record), update (edit the record at that name to value; for SPF, add our include to your existing record rather than publishing a second SPF record), keep (keep yours, for example your DMARC record) or done (already published). keep_old_records says which of your old provider's records to leave in place until the cutover. dns_plan is null in a preview; when DNS could not be read, checked is false and every record is listed as add.

Reports

Every import (not previews) is kept with its full report, so you can come back to it. Webhook signing secrets are not kept: they are shown once, in the response that created the webhook.

Request Returns
GET /migrations Your recent imports, newest first, with totals (?limit=, up to 100)
GET /migrations/{id} The report of one import, every request merged
GET /migrations/{id}/report The report as a CSV download (?format=json for JSON): one row per domain, webhook and template, the suppression counts and skip reasons, anything that could not be read, and the next steps

These need account:read or any of the import scopes. In the SDK: migrations.list(), migrations.get(id) and migrations.reportCsv(id). In the CLI: groundmail migrate list and groundmail migrate report <id> [--csv].

What maps to what

Suppressions

Provider Suppressed (no mail) Product email opt-out Not imported, and why
SendGrid Bounces, spam reports, invalid emails, global unsubscribes Unsubscribe-group (ASM) suppressions Blocks (SendGrid keeps sending to them, so they are not permanent).
Postmark Hard bounces, spam complaints, unsubscribes and manual suppressions on transactional streams; hard bounces and complaints on broadcast streams Broadcast-stream unsubscribes Nothing: Postmark only suppresses permanent failures.
Mailgun Bounces, complaints, and unsubscribes from all mail (*) Tag-specific unsubscribes Nothing.

A product email opt-out stops product email to that person and nothing else: receipts and password resets still reach them, as they did at your old provider.

Bounces are checked before they are imported. When your provider kept the receiving server's reply (SendGrid and Mailgun do), a bounce that was really a block on that provider's sending servers (a blocklist listing or a reputation refusal) is left off your list, because the address is fine and Groundmail sends from different servers. A temporary failure (a 4xx reply) is left off too. Both are counted under skipped_reasons. In Groundmail terms, these are block bounces, which never suppress an address.

When an address appears in two lists, a bounce or complaint wins over an unsubscribe. Re-running an import skips addresses that are already suppressed.

Template variables. Groundmail templates use flat {{name}} variables, HTML-escaped in HTML bodies.

In your template Becomes
{{ name }}, {{{ name }}}, {{~ name ~}} {{name}} (always escaped)
{{ user.first_name }} {{user_first_name}}: send the value flattened
SendGrid {{insert name "default=…"}} {{name}} (the default is not kept)
SendGrid legacy <%body%>, <%subject%> {{body}}, {{subject}}
Postmark layouts Merged into each template that uses them
Mailgun %recipient.first_name% {{first_name}}: pass it in the template variables instead of recipient-variables
Unsubscribe links: SendGrid {{{unsubscribe}}}, {{{unsubscribe_preferences}}}, <%asm_group_unsubscribe_raw_url%>; Postmark {{{ pm:unsubscribe }}}; Mailgun %unsubscribe_url%, %tag_unsubscribe_url% {{unsubscribe_url}}, Groundmail's managed unsubscribe link. The template is imported as a draft in the Product category with unsubscribe on, so people who unsubscribe stop getting product email and still get receipts. Check the category, then publish.
{{#if}}, {{#each}}, {{else}}, helpers Left as written; the template is imported as a draft. Render that part in your code and pass it as a variable.

A template without a subject (common on Mailgun) gets {{subject}} as its subject so you can set it per send or edit it before publishing.

Webhook events

SendGrid Postmark Mailgun Groundmail
processed accepted email.sent
delivered Delivery delivered email.delivered
deferred temporary_fail email.delivery_delayed
bounce Bounce permanent_fail email.bounced and email.failed
dropped email.suppressed
spam_report SpamComplaint complained email.complained
open Open opened email.opened
click Click clicked email.clicked
unsubscribe, group_unsubscribe SubscriptionChange unsubscribed contact.unsubscribed

A bounce maps to two Groundmail events: email.failed when the receiving server refuses the address during delivery (bounce_type: "hard"), and email.bounced for a bounce report after delivery or a block refusal. Events with no equivalent are listed under unmapped_events. Mailgun webhooks are per domain and per event; the importer creates one Groundmail webhook per URL.

Safe to re-run

Domains already in your workspace, webhooks with the same URL, templates with the same alias and suppressions that already exist are skipped, so running the import again picks up what changed and never creates duplicates.

Limits

  • 10 new imports per workspace per hour, and 300 continuation requests per hour.
  • Up to 500,000 suppressions per run; run it again with only suppressions selected to continue past that.
  • Domain imports count toward your plan's domain limit; a domain over the limit is reported as failed and the rest of the import continues.

Errors

Status Code When
400 validation_error Bad request, or your provider rejected the key (details.reason: provider_auth_failed)
403 insufficient_scope Your Groundmail key lacks the write scope of a selected resource
429 rate_limited Too many imports; see Retry-After
502 upstream_error The provider could not be reached (details.reason: provider_unavailable)

A resource your provider key cannot read (for example a SendGrid key without template access) does not fail the import: it is listed under errors and the other resources still import.