groundmail

Migrate

Migrate from SendGrid in 10 minutes

Move transactional email from SendGrid to Groundmail: API key, DNS, a full v3 field mapping, SMTP, webhooks, suppression import and a canary cutover with rollback.

By GroundmailUpdated

Short answer

To move from SendGrid, create a Groundmail API key, verify your sending domain, and replace POST /v3/mail/send with POST https://api.groundmail.dev/emails: personalizations[].to becomes to, content[] becomes html and text, and template_id becomes template: { id, variables }. For a typical app the code change takes about ten minutes; DNS verification takes as long as your DNS host does, and is one click on Cloudflare, GoDaddy and Vercel.

Then move webhooks, import your SendGrid suppression lists, and shift traffic gradually behind a flag, keeping SendGrid as a fallback until bounce and complaint rates look normal.

On this page

The checklist, with honest timings

"Ten minutes" is the code change: account, key, domain records submitted, and the send call swapped. The rest of the migration is about doing it safely, and it runs on your schedule rather than ours.

StepWhat you doTime
1. Account and API keySign up, create a gm_ key, send a sandbox test2 minutes
2. Domain and DNSAdd your domain, publish the records, verify2 minutes with one-click apply; otherwise your DNS host's propagation time
3. Swap the send callReplace the endpoint or SDK and map fieldsAbout 5 minutes for one send helper
4. SMTP (if you use it)Change host, username and password1 minute
5. WebhooksCreate an endpoint, update your handler15 to 30 minutes
6. SuppressionsExport from SendGrid, import to GroundmailMinutes; depends on list size
7. Canary and cutoverRamp traffic behind a flag, then remove SendGridA few days of watching

Step 1: Create an account and API key

Sign up at app.groundmail.dev/signup and create an API key. Groundmail keys start with gm_ and go in a standard Authorization: Bearer header, the same place your SG. key went. Use a sending-scoped key in your app, and a full-scope key for the setup tasks below (webhooks, suppressions, SMTP credentials).

Before your domain is verified you can self-test from the sandbox sender noreply@mail.groundmail.dev to your own account email. To send to anyone else you need a verified domain and a credit card on file. On the Free plan (3,000 emails a month, 100 a day) the card isn't charged.

Sandbox self-test

curl -X POST https://api.groundmail.dev/emails \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "noreply@mail.groundmail.dev",
    "to": "you@yourcompany.com",
    "subject": "Sandbox check",
    "text": "It works."
  }'

Step 2: Add your domain and DNS records

Add the domain you send from in the dashboard, or with POST /domains and {"name": "example.com"}. Groundmail returns the records to publish. If your DNS is on Cloudflare, GoDaddy or Vercel, one-click apply writes them for you; otherwise copy them into your DNS host.

RecordTypeHostValue
Ownership (required)TXT_groundmail.example.comToken from the dashboard
DKIM (required)TXT<selector>._domainkey.example.comPublic key from the dashboard
Return path (required)CNAMErp.example.comrp.groundmail.dev (DNS only, not proxied)
SPF (required)TXTexample.comAdd include:spf.groundmail.dev to your existing SPF record
DMARC (recommended)TXT_dmarc.example.comStarter policy from the dashboard

Ownership, SPF, DKIM and the return path must resolve before the domain verifies.

Merged SPF (example)

example.com.  TXT  "v=spf1 include:_spf.google.com include:spf.groundmail.dev ~all"

Once the records are visible, click Verify (or call the verify endpoint). Verification turns on DKIM signing with your domain, so DMARC can pass. For the background on each record, see SPF, DKIM and DMARC for transactional email and the domains docs.

Step 3: Swap the send call

SendGrid's v3 Mail Send nests recipients in personalizations[] and bodies in content[]. Groundmail's POST /emails takes one flat message. Here's every field you're likely to be using.

SendGrid v3GroundmailNotes
POST https://api.sendgrid.com/v3/mail/sendPOST https://api.groundmail.dev/emailsResponse is { "id": "msg_...", "status": "sent" }
Authorization: Bearer SG...Authorization: Bearer gm_...
personalizations[].to[] ({email, name})toA string or an array of addresses
personalizations[].cc[], bcc[]cc, bccString or array
from {email, name}fromA string, or { "email", "name" } as before
subjectsubjectOptional when sending a template
content[] with text/plaintext
content[] with text/htmlhtml
reply_toreply_toString or array
attachments[] {content, filename, type, content_id}attachments[] {data, name, content_type, content_id}content and filename are accepted as aliases and content_id is unchanged; rename type to content_type. Max 10, base64
template_id + personalizations[].dynamic_template_datatemplate: { id, variables }id is the template's id or alias, after you recreate it
send_at (Unix seconds, up to 72 hours ahead)scheduled_atISO 8601, e.g. 2026-11-01T09:00:00Z
categories[], custom_argstagsArray of {name, value} or an object; max 10
headersheadersSame shape
asm.group_idcategory + unsubscribeFor opt-out-able product mail, category: "product" with managed unsubscribe
Several personalizations in one requestPOST /emails/batchUp to 100 messages per call

SendGrid fields from the v3 Mail Send reference. Attachments aren't supported on batch or scheduled sends.

Last checked on each vendor's public page.

Before and after: curl

SendGrid

curl -X POST https://api.sendgrid.com/v3/mail/send \
  -H "Authorization: Bearer $SENDGRID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "personalizations": [{ "to": [{ "email": "ada@example.com", "name": "Ada" }] }],
    "from": { "email": "no-reply@acme.com", "name": "Acme" },
    "reply_to": { "email": "support@acme.com" },
    "subject": "Your receipt",
    "content": [
      { "type": "text/plain", "value": "Thanks for your order." },
      { "type": "text/html", "value": "<p>Thanks for your order.</p>" }
    ],
    "categories": ["receipt"],
    "custom_args": { "order_id": "1234" }
  }'

Groundmail

curl -X POST https://api.groundmail.dev/emails \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: receipt-1234" \
  -d '{
    "from": { "email": "no-reply@acme.com", "name": "Acme" },
    "to": "ada@example.com",
    "reply_to": "support@acme.com",
    "subject": "Your receipt",
    "text": "Thanks for your order.",
    "html": "<p>Thanks for your order.</p>",
    "tags": { "category": "receipt", "order_id": "1234" }
  }'

The Idempotency-Key header is optional. A retry with the same key and the same body returns the original response instead of sending twice (keys last 24 hours), which is worth adding to anything that retries on timeouts.

Before and after: Node

SendGrid (@sendgrid/mail)

import sgMail from "@sendgrid/mail";

sgMail.setApiKey(process.env.SENDGRID_API_KEY!);

await sgMail.send({
  to: "ada@example.com",
  from: "no-reply@acme.com",
  subject: "Your receipt",
  text: "Thanks for your order.",
  html: "<p>Thanks for your order.</p>",
});

Groundmail (npm install groundmail)

import { Groundmail } from "groundmail";

const gm = new Groundmail(process.env.GROUNDMAIL_API_KEY!);

const { id } = await gm.emails.send({
  from: "no-reply@acme.com",
  to: "ada@example.com",
  subject: "Your receipt",
  text: "Thanks for your order.",
  html: "<p>Thanks for your order.</p>",
});
// Store id (msg_...) to match webhook events to this send.

Templates and multiple personalizations

Templates don't transfer between providers, so recreate each one you use in the Groundmail dashboard (or via the templates API), publish it, and give it an alias such as password-reset. Groundmail templates use {{variable}} placeholders, and values in the HTML body are escaped. If a SendGrid template does more than plain substitution, move that logic into your app and pass the result as a variable, or render the HTML yourself and send html.

SendGrid dynamic template

{
  "from": { "email": "no-reply@acme.com" },
  "template_id": "YOUR_SENDGRID_TEMPLATE_ID",
  "personalizations": [{
    "to": [{ "email": "ada@example.com" }],
    "dynamic_template_data": { "name": "Ada", "reset_url": "https://acme.com/reset/abc" }
  }]
}

Groundmail template

{
  "from": "no-reply@acme.com",
  "to": "ada@example.com",
  "template": {
    "id": "password-reset",
    "variables": { "name": "Ada", "reset_url": "https://acme.com/reset/abc" }
  }
}

If you put several recipients with their own data in one SendGrid request, send them as a batch instead. Each entry is a full message, results come back per index, and one bad address doesn't fail the rest.

Node: one personalization per batch entry

const { data } = await gm.emails.batch(
  orders.slice(0, 100).map((order) => ({
    from: "no-reply@acme.com",
    to: order.email,
    template: { id: "order-shipped", variables: { name: order.name, tracking: order.tracking } },
  })),
);

for (const result of data) {
  if (result.error) console.error(result.index, result.error.message);
}

Go

Go (go get github.com/Groundmail/groundmail-sdks/go)

import groundmail "github.com/Groundmail/groundmail-sdks/go"

client, err := groundmail.New(os.Getenv("GROUNDMAIL_API_KEY"))
if err != nil {
	log.Fatal(err)
}

result, err := client.Emails.Send(map[string]any{
	"from":    "no-reply@acme.com",
	"to":      "ada@example.com",
	"subject": "Your receipt",
	"html":    "<p>Thanks for your order.</p>",
})
if err != nil {
	log.Fatal(err)
}
log.Println(result["id"], result["status"])

Step 4: Switch SMTP settings

If an auth provider, CMS or framework sends through SendGrid's SMTP relay, create an SMTP credential in the dashboard (or with POST /smtp-credentials and a full-scope key) and change four settings. The password is shown once, so copy it when you create it.

SettingSendGridGroundmail
Hostsmtp.sendgrid.netsmtp.groundmail.dev
Port587 (recommended)587 (STARTTLS)
Usernameapikey (literal)groundmail
PasswordYour SG. API keyAn SMTP credential (gmsmtp_...)

SendGrid settings from its SMTP integration docs.

Last checked on each vendor's public page.

SMTP sends use the same verified domains, limits, suppressions and webhooks as the API. To use API features over SMTP, set these headers; other non-reserved headers are passed through as message headers. If your SMTP setup sends SendGrid-specific headers, replace them with these.

HeaderDoes
X-Groundmail-TagAdds a tag as name=value; repeat the header for more than one
X-Groundmail-Idempotency-KeyIdempotency, same 24-hour rules as the API
X-Groundmail-Scheduled-AtSchedules the send (ISO 8601)

Step 5: Move webhooks

Create an endpoint in the dashboard under Webhooks, or with POST /webhooks and { "url": "https://yourapp.com/webhooks/groundmail" }. The response includes a signing secret (whsec_...) once; store it.

Two things change in your handler. SendGrid's Event Webhook posts JSON arrays of events and signs them with ECDSA; Groundmail posts one JSON object per event, signed with HMAC-SHA256 in a Groundmail-Signature header. And the event names change:

SendGrid eventGroundmail event
processedemail.sent
deliveredemail.delivered
deferredemail.delivery_delayed
bounceemail.bounced
droppedemail.failed or email.suppressed
openemail.opened
clickemail.clicked
spamreportemail.complained

Open and click events need open and click tracking turned on for the domain. SendGrid webhook details from its Event Webhook security docs.

Last checked on each vendor's public page.

Node handler (Next.js route, Fetch API)

import { verifyWebhookSignature } from "groundmail";

export async function POST(request: Request) {
  const rawBody = await request.text(); // verify before parsing
  const ok = verifyWebhookSignature(
    process.env.GROUNDMAIL_WEBHOOK_SECRET!,
    rawBody,
    request.headers.get("groundmail-signature"),
  );
  if (!ok) return new Response("invalid signature", { status: 401 });

  const event = JSON.parse(rawBody); // one event, not an array
  switch (event.type) {
    case "email.bounced":
    case "email.complained":
      await markUndeliverable(event.data.to, event.data.email_id);
      break;
    case "email.delivered":
      await markDelivered(event.data.email_id);
      break;
  }
  return new Response("ok");
}

event.data.email_id is the msg_... id returned by the send, so if you stored SendGrid's message ids to match events to sends, store Groundmail's instead. Groundmail adds bounced and complained addresses to your suppression list automatically, so you don't need a handler just to stop sending to them.

Test the handler before cutover with Send test event on the dashboard's webhooks page, or from CI with POST /emails/test and { "event": "email.bounced", "deliver": true }. Full payloads are in the webhooks docs.

Step 6: Import your suppressions

Your SendGrid suppression lists hold addresses that bounced, complained or are invalid. Bring them over so your first week on Groundmail doesn't re-send to them. SendGrid exposes each list through its v3 API: GET /v3/suppression/bounces, /blocks, /spam_reports, /invalid_emails and /unsubscribes. Each returns a JSON array of objects with an email field.

Groundmail adds suppressions one address per call with POST /suppressions (full-scope key), so the script loops:

bash (curl + jq)

#!/usr/bin/env bash
set -euo pipefail

for list in bounces spam_reports invalid_emails; do
  curl -s "https://api.sendgrid.com/v3/suppression/$list" \
    -H "Authorization: Bearer $SENDGRID_API_KEY" |
  jq -r '.[].email' |
  while read -r email; do
    curl -s -o /dev/null -w "%{http_code} $email\n" \
      -X POST https://api.groundmail.dev/suppressions \
      -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
      -H "Content-Type: application/json" \
      -d "$(jq -n --arg e "$email" --arg r "imported from SendGrid $list" '{email: $e, reason: $r}')"
  done
done

Step 7: Canary, cutover and rollback

  1. 1

    Put the provider behind a flag

    Keep both send paths in code and choose one per message with a feature flag or a percentage, so rollback is a config change rather than a deploy.

  2. 2

    Send a small share through Groundmail

    Start with a few percent of real traffic, ideally your most-engaged mail such as sign-in codes and password resets.

  3. 3

    Watch the numbers for a day or two

    Track email.delivered against sends, and watch email.bounced and email.complained. Compare them with the same mail on SendGrid. They should look alike.

  4. 4

    Ramp to 100%

    Raise the share in steps (for example 25%, 50%, 100%) while the rates stay flat. If anything moves, set the flag back and look before continuing.

  5. 5

    Keep SendGrid as a fallback for a week

    Leave the SendGrid key and DNS records in place for about a week after 100%, so you can roll back instantly.

  6. 6

    Remove SendGrid

    Revoke the SendGrid API key and SMTP credentials, remove the code path, and delete the DNS records SendGrid had you add, including its entry in your SPF record if you had one.

What doesn't carry over

  • Marketing email. Groundmail is for transactional email only; marketing, newsletters and cold email aren't allowed on our IPs (Acceptable Use Policy). If you use SendGrid Marketing Campaigns, keep campaigns on a marketing tool and move transactional sending here.
  • Templates. They don't export between providers. Recreate the ones you use with {{variable}} placeholders, and move any template logic into your app.
  • Account settings. Tracking options, API key permissions and unsubscribe groups are configured again on Groundmail: open and click tracking per domain, sending vs full-scope keys, and category with managed unsubscribe instead of asm.group_id.
  • Send history. Past activity stays in SendGrid. Export anything you need before you close the account.

Frequently asked questions

Can I really migrate from SendGrid in 10 minutes?

The code change, yes, for a typical app with one send helper: new key, new endpoint, flattened payload. DNS verification depends on your DNS host; on Cloudflare, GoDaddy or Vercel one-click apply makes it quick. Webhooks, templates, suppressions and a gradual cutover take longer, and you should give the cutover a few days.

Do I need to change my DNS before switching?

You need to add Groundmail's records (ownership TXT, DKIM, the rp return-path CNAME and Groundmail's SPF include) and verify the domain. Keep SendGrid's records until you've fully cut over, and merge SPF into a single record rather than adding a second one.

Is there a Groundmail SDK for Python, PHP or Ruby?

Not yet. The official SDKs are Node (npm install groundmail) and Go. From other languages, call POST https://api.groundmail.dev/emails with any HTTP client, or use the SMTP relay at smtp.groundmail.dev on port 587.

How do SendGrid dynamic templates map to Groundmail?

Recreate each template in Groundmail with {{variable}} placeholders and publish it with an alias. Then replace template_id and dynamic_template_data with template: { id: "your-alias", variables: {...} }. The subject comes from the template unless you override it.

How do I send to many recipients like SendGrid personalizations?

Use POST /emails/batch with up to 100 messages per call, one per recipient. Each entry is a full message with its own to and template variables, and the response reports a result per index. Attachments aren't supported in batch sends.

Should I import SendGrid unsubscribes into Groundmail suppressions?

Usually only bounces, spam reports and invalid addresses. A Groundmail suppression blocks every send to an address, including password resets and sign-in codes, so importing global unsubscribes could stop people from receiving mail they need.

How do I roll back if something goes wrong?

Keep both providers in code behind a flag and ramp Groundmail traffic gradually. Leave the SendGrid key and DNS records in place for about a week after reaching 100%, so rolling back is a flag change.

Sources

Facts on this page about other companies' products, prices and policies come from these public pages. They change, so check them before you decide.

Send from infrastructure we own.

Groundmail runs its own mail servers on its own IP space. Start free on the shared transactional pool, then move to dedicated IPs or a dedicated server without changing your code.