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.
| Step | What you do | Time |
|---|---|---|
| 1. Account and API key | Sign up, create a gm_ key, send a sandbox test | 2 minutes |
| 2. Domain and DNS | Add your domain, publish the records, verify | 2 minutes with one-click apply; otherwise your DNS host's propagation time |
| 3. Swap the send call | Replace the endpoint or SDK and map fields | About 5 minutes for one send helper |
| 4. SMTP (if you use it) | Change host, username and password | 1 minute |
| 5. Webhooks | Create an endpoint, update your handler | 15 to 30 minutes |
| 6. Suppressions | Export from SendGrid, import to Groundmail | Minutes; depends on list size |
| 7. Canary and cutover | Ramp traffic behind a flag, then remove SendGrid | A 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.
| Record | Type | Host | Value |
|---|---|---|---|
| Ownership (required) | TXT | _groundmail.example.com | Token from the dashboard |
| DKIM (required) | TXT | <selector>._domainkey.example.com | Public key from the dashboard |
| Return path (required) | CNAME | rp.example.com | rp.groundmail.dev (DNS only, not proxied) |
| SPF (required) | TXT | example.com | Add include:spf.groundmail.dev to your existing SPF record |
| DMARC (recommended) | TXT | _dmarc.example.com | Starter 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 v3 | Groundmail | Notes |
|---|---|---|
POST https://api.sendgrid.com/v3/mail/send | POST https://api.groundmail.dev/emails | Response is { "id": "msg_...", "status": "sent" } |
Authorization: Bearer SG... | Authorization: Bearer gm_... | |
personalizations[].to[] ({email, name}) | to | A string or an array of addresses |
personalizations[].cc[], bcc[] | cc, bcc | String or array |
from {email, name} | from | A string, or { "email", "name" } as before |
subject | subject | Optional when sending a template |
content[] with text/plain | text | |
content[] with text/html | html | |
reply_to | reply_to | String 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_data | template: { id, variables } | id is the template's id or alias, after you recreate it |
send_at (Unix seconds, up to 72 hours ahead) | scheduled_at | ISO 8601, e.g. 2026-11-01T09:00:00Z |
categories[], custom_args | tags | Array of {name, value} or an object; max 10 |
headers | headers | Same shape |
asm.group_id | category + unsubscribe | For opt-out-able product mail, category: "product" with managed unsubscribe |
Several personalizations in one request | POST /emails/batch | Up 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.
| Setting | SendGrid | Groundmail |
|---|---|---|
| Host | smtp.sendgrid.net | smtp.groundmail.dev |
| Port | 587 (recommended) | 587 (STARTTLS) |
| Username | apikey (literal) | groundmail |
| Password | Your SG. API key | An 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.
| Header | Does |
|---|---|
X-Groundmail-Tag | Adds a tag as name=value; repeat the header for more than one |
X-Groundmail-Idempotency-Key | Idempotency, same 24-hour rules as the API |
X-Groundmail-Scheduled-At | Schedules 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 event | Groundmail event |
|---|---|
processed | email.sent |
delivered | email.delivered |
deferred | email.delivery_delayed |
bounce | email.bounced |
dropped | email.failed or email.suppressed |
open | email.opened |
click | email.clicked |
spamreport | email.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
doneStep 7: Canary, cutover and rollback
- 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
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
Watch the numbers for a day or two
Track
email.deliveredagainst sends, and watchemail.bouncedandemail.complained. Compare them with the same mail on SendGrid. They should look alike. - 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
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
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
categorywith managed unsubscribe instead ofasm.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.