Groundmail

Migrate

Migrate from SendGrid to Groundmail in Python

Move a Python app from SendGrid to Groundmail: before and after code, env vars, DNS records, webhook changes and prices at 50k, 100k and 1M emails.

By GroundmailCompetitor prices last checked

Short answer

To move a Python app from SendGrid, install pip install groundmail, replace the send call with groundmail.emails.send(), and swap SENDGRID_API_KEY for GROUNDMAIL_API_KEY. Then verify your sending domain in Groundmail, leaving SendGrid's DNS records in place until you cut over.

SendGrid's webhook events map onto Groundmail's email.* events, signed with HMAC-SHA256 in a Groundmail-Signature header. The free email migration scanner finds every place your repo sends through SendGrid and writes the pull request for your coding agent.

On this page

Before and after: the Python send call

The same password reset email, sent with SendGrid's Python code and then with Groundmail's. Groundmail answers with { id: "msg_…", status: "sent" }; keep the id if you match webhook events to sends, or put your own id in tags.

Swap the package

pip uninstall sendgrid
pip install groundmail

Before: SendGrid (Python)

import os
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail

message = Mail(
    from_email="no-reply@acme.com",
    to_emails="ada@example.com",
    subject="Reset your password",
    html_content="<p>Your reset link: https://acme.com/reset/abc123</p>",
)
sg = SendGridAPIClient(os.environ["SENDGRID_API_KEY"])
response = sg.send(message)

After: Groundmail (Python)

import os
from groundmail import Groundmail

groundmail = Groundmail(os.environ["GROUNDMAIL_API_KEY"])

email = groundmail.emails.send(
    from_="no-reply@acme.com",
    to="ada@example.com",
    subject="Reset your password",
    html="<p>Your reset link: https://acme.com/reset/abc123</p>",
)

print(email["id"])  # msg_…

How the fields map

Beyond the basics above, these are the SendGrid fields you're most likely to be passing, and their Groundmail names.

SendGridGroundmail
personalizations[].to[] (or to in the helper)to: a string or an array
from: { email, name }from: "you@example.com" or { email, name }
content[] with type and value (or html / text)html and text
templateId + dynamicTemplateDatatemplate: { id: "<alias>", variables: { … } }
replyToreply_to
categories, customArgstags: up to 10 { name, value } pairs, returned on every webhook
attachments[] (content, filename, type)attachments[]: content and filename as they are, type becomes contentType

Using SendGrid templates? Send them by alias with template: { id: "<alias>", variables: { … } }. The importer converts your SendGrid templates and publishes them under an alias. More field detail: SendGrid migration docs and Python docs.

Environment variables

TodayOn Groundmail
SENDGRID_API_KEYGROUNDMAIL_API_KEY (a gm_… key)
Your webhook secret, e.g. SENDGRID_WEBHOOK_PUBLIC_KEYGROUNDMAIL_WEBHOOK_SECRET: the signing secret shown once when you create the webhook

Create the key under API keys in the dashboard: the Send only preset is enough for the app. Leave the SendGrid variables set until you cut over, so rolling back is a config change.

DNS: add Groundmail's records, keep SendGrid's until cutover

Add the domain you send from in the dashboard, or with POST /domains. Groundmail gives you these records; on Cloudflare, GoDaddy and Vercel one click writes them for you.

RecordTypeHostValue
OwnershipTXT_groundmail.example.comToken from the dashboard
DKIMTXT<selector>._domainkey.example.comPublic key from the dashboard
Return pathCNAMErp.example.comrp.groundmail.dev (DNS only, not proxied)
SPFTXTexample.comAdd include:spf.groundmail.dev to your existing SPF record
DMARCTXT_dmarc.example.comKeep yours; the dashboard suggests one if you have none

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

SendGrid records to remove after cutover

  • The s1._domainkey and s2._domainkey DKIM CNAMEs
  • The em… return-path CNAME, for example em1234.example.com
  • Take include:sendgrid.net out of your SPF record.

Wait a few days after the last email goes out through the old provider, in case you need to roll back. Background on each record: SPF, DKIM and DMARC for transactional email.

Webhooks: what changes

SendGridGroundmail
DeliveryA JSON array of events, batched, per POSTOne JSON object per POST: type, created_at and data
Checking it came from themOptional Signed Event Webhook: ECDSA signature in X-Twilio-Email-Event-Webhook-Signature, checked with a public keyGroundmail-Signature: t=…,v1=…: HMAC-SHA256 of t.rawBody with the endpoint's secret

If your endpoint doesn't return a 2xx, Groundmail retries the delivery 4 more times: after 5 seconds, 30 seconds, 2 minutes and 2 minutes. Subscribe an endpoint to these events to replace the ones you handle today:

SendGrid eventGroundmail event
processedemail.sent
deliveredemail.delivered
deferredemail.delivery_delayed
bounceemail.bounced and email.failed (data.bounce_type is hard or block)
droppedemail.suppressed
spamreportemail.complained
openemail.opened
clickemail.clicked
unsubscribe, group_unsubscribecontact.unsubscribed

Check the signature against the raw request body, before you parse it. In Python:

Verify a Groundmail webhook (Python)

import hashlib
import hmac
import time


def verify_groundmail_signature(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or not v1 or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)


# Flask: verify_groundmail_signature(request.get_data(), request.headers["Groundmail-Signature"], secret)

Use Send test event on the webhooks page, or POST /emails/test from CI, to try your handler before real mail flows. Payloads and every event: webhooks.

SendGrid and Groundmail at 50k, 100k and 1M emails

Monthly volumeSendGridGroundmail
Free option60-day trial, 100 emails/day3,000 emails/month (100/day), no time limit
50,000 emails/month$19.95 (Essentials 50k)$20 (Pro)
100,000 emails/month$34.95 (Essentials 100k)$35 (Pro)
1,000,000 emails/month$799 (Pro 1.5M)$650 (Scale)

USD per month, monthly billing, cheapest published plan that covers each volume. SendGrid prices as of October 2026 from twilio.com/en-us/products/email-api/pricing; Groundmail prices from our pricing page.

Last checked on each vendor's public page.

Up to 100,000 emails a month the two list prices are within five cents, so price isn't the reason to move. At 1 million Groundmail is $650 against $799, the free plan doesn't end after 60 days, and overage rates are published.

Plans, overage and what each includes: SendGrid pricing. Everything else side by side: Groundmail vs SendGrid.

Cut over without losing mail

  1. 1

    Put the provider behind a flag

    Keep the SendGrid code path and add Groundmail next to it, chosen by an env var such as EMAIL_PROVIDER, so rolling back is a config change.

  2. 2

    Send to yourself first

    Trigger a password reset and a receipt to your own addresses, and check the headers show DKIM passing for your domain.

  3. 3

    Move a small share

    Send 5 to 10% of real mail through Groundmail for a day, and watch bounces and complaints on the dashboard next to what you see today.

  4. 4

    Ramp to 100%

    Then switch everything over, and keep SendGrid configured for a week in case you need to switch back.

  5. 5

    Clean up

    Remove the SendGrid package, its env vars and its DNS records, and cancel the plan.

Let the migration tool find every send

Then bring your SendGrid setup across with the importer: paste a SendGrid API key under Import and Groundmail copies your suppressions, templates, sending domains (with the DNS changes) and webhooks (created switched off until your handler is ready). Nothing changes in your SendGrid account, and the key is used for the import only. From a terminal:

Import from SendGrid with the CLI

npm install -g groundmail-cli
groundmail login
export SENDGRID_API_KEY=…
groundmail migrate sendgrid --dry-run   # preview, nothing written
groundmail migrate sendgrid             # import

Frequently asked questions

How long does it take to move a Python app from SendGrid?

The code change is usually under an hour: one package, the send calls and one env var. Domain verification takes as long as your DNS host takes to publish the records, and is one click on Cloudflare, GoDaddy and Vercel. Plan a few days of partial sending before you turn SendGrid off.

Can I keep sending over SMTP instead?

Yes. If your app sends through smtp.sendgrid.net today, point it at smtp.groundmail.dev on port 587 with STARTTLS (or 2525 if your host blocks 587), username groundmail, and an SMTP credential (gmsmtp_…) from the dashboard as the password. Same verified domains, limits and suppressions as the API. See SMTP.

What happens to my SendGrid suppression list?

The importer copies hard bounces, spam complaints and unsubscribes onto your Groundmail suppression list. Bounces that were blocks on SendGrid's servers are left off, because those addresses are fine.

Why do teams move from SendGrid to Groundmail?

A free plan instead of a 60-day trial, published overage rates, lower prices from 500,000 emails a month, and support from the people who run the mail servers.

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.

Transactional email, plus everything around it.

Sign-in codes, receipts and alerts kept separate from marketing mail, with inbound, templates and automations included. Start free with 3,000 emails a month, and move to a dedicated IP or server without changing your code.