groundmail

Migrate

Migrate from Amazon SES to Groundmail

Move transactional email from Amazon SES to Groundmail: DNS, SendEmail to POST /emails field mapping, SMTP settings, event types to webhooks, suppression export, canary and rollback.

By GroundmailUpdated

Short answer

To move from Amazon SES to Groundmail: create an account and a gm_ API key, verify your domain alongside your existing SES records, replace SendEmail calls with POST https://api.groundmail.dev/emails (or point SMTP at smtp.groundmail.dev:587), subscribe a webhook in place of your configuration set's event destinations, and import your SES suppression list.

Then send a small share of traffic through Groundmail, compare delivery, and shift the rest. Your SES identities can stay in place until you're done, so rolling back is a config change.

On this page

What changes and what stays

Your email jobs stay the same: the password resets, sign-in codes, receipts and alerts your app already sends. What changes is the send call, the credentials, where delivery events arrive and who runs the infrastructure. Groundmail sends from our own mail servers and our own IP space, and we handle warmup, reputation, suppressions and webhooks.

Amazon SESGroundmail
IAM credentialsgm_... API key (Bearer)
Regional endpointhttps://api.groundmail.dev
SendEmail (SES v2)POST /emails
Verified identityVerified domain
Configuration set + event destinationsWebhooks and dashboard activity
Account-level suppression listSuppression list, filled automatically from bounces and complaints
SMTP: email-smtp.<region>.amazonaws.com with IAM-derived credentialssmtp.groundmail.dev:587 with a gmsmtp_... credential

SES details from the SES v2 API reference and SES developer guide.

Last checked on each vendor's public page.

1. Create an account, a key and verify your domain

  1. 1

    Create an account and an API key

    Sign up and create a gm_ API key in the dashboard. Use a sending key in your app. Keep a separate full-access key for the suppression import in step 5, because managing suppressions needs full scope.

  2. 2

    Add your domain

    Add the domain you send from today, in the dashboard or with POST /domains and {"name": "example.com"}. While DNS propagates, you can self-test from noreply@mail.groundmail.dev to your own account email.

  3. 3

    Publish the DNS records

    Groundmail gives you an ownership TXT at _groundmail.example.com, a DKIM TXT at <selector>._domainkey.example.com, a return-path CNAME rp.example.com pointing to rp.groundmail.dev (DNS only, not proxied), an SPF include and a DMARC record. Cloudflare, GoDaddy and Vercel users can apply them in one click. See domains.

Keeping SES records during the move

  • SES DKIM CNAMEs stay. Groundmail's DKIM key lives under its own selector, so both can be published at once. Remove the SES ones only after cutover.
  • Your SES custom MAIL FROM subdomain stays. It keeps its own MX and SPF records. Groundmail's return path is rp.example.com; as long as your MAIL FROM subdomain isn't also rp, the two don't overlap.
  • One SPF record on the root. A domain can publish only one SPF record. If the root already has one, add include:spf.groundmail.dev to it instead of creating a second, e.g. v=spf1 include:spf.groundmail.dev include:<existing> ~all.
  • Keep your DMARC record. Groundmail signs with DKIM on your domain, so your existing DMARC policy continues to apply. The SPF, DKIM and DMARC guide covers alignment.

Ownership, SPF, DKIM and the return path all need to verify before production sends. On the Free plan, a card on file is also required to send; it isn't charged.

2. Replace SendEmail with POST /emails

SES v2 SendEmail takes a nested body. Groundmail's is flat: most fields map one to one, and a few have no direct equivalent.

SES v2 SendEmailGroundmail POST /emailsNotes
FromEmailAddressfromString, or { email, name }
Destination.ToAddressestoString or array
Destination.CcAddresses / BccAddressescc / bcc
Content.Simple.Subject.Datasubject
Content.Simple.Body.Html.Datahtml
Content.Simple.Body.Text.DatatextSend both html and text if you did before
ReplyToAddressesreply_toString or array
EmailTagstagsArray of { name, value }, max 10
ConfigurationSetNameNo equivalent fieldEvents go to the webhooks on your account (step 4)
Content.Templatetemplate: { id, variables }Recreate the template in Groundmail first; id is its UUID or alias
Content.RawNo raw MIME on the HTTP APISend through the SMTP relay; for attachments alone, use attachments

SES field names from the SendEmail API reference.

Last checked on each vendor's public page.

Groundmail also accepts headers (an object), attachments (up to 10, each { name, content_type, data } with base64 data), scheduled_at (ISO 8601) and an Idempotency-Key header, which makes retries from job queues safe. A successful send returns { "id": "msg_...", "status": "sent" }.

Before: SES v2 SDK for JavaScript

Node (@aws-sdk/client-sesv2)

import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";

const ses = new SESv2Client({ region: "us-east-1" });

await ses.send(
  new SendEmailCommand({
    FromEmailAddress: "no-reply@acme.com",
    Destination: { ToAddresses: ["ada@example.com"] },
    ReplyToAddresses: ["support@acme.com"],
    Content: {
      Simple: {
        Subject: { Data: "Reset your password" },
        Body: {
          Html: { Data: "<p>Your reset link: https://acme.com/reset/abc123</p>" },
          Text: { Data: "Your reset link: https://acme.com/reset/abc123" },
        },
      },
    },
    EmailTags: [{ Name: "type", Value: "password_reset" }],
    ConfigurationSetName: "transactional",
  })
);

After: Groundmail

Node (groundmail)

// npm install groundmail
import { Groundmail } from "groundmail";

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

const email = await gm.emails.send({
  from: "no-reply@acme.com",
  to: "ada@example.com",
  replyTo: "support@acme.com",
  subject: "Reset your password",
  html: "<p>Your reset link: https://acme.com/reset/abc123</p>",
  text: "Your reset link: https://acme.com/reset/abc123",
  tags: [{ name: "type", value: "password_reset" }],
  idempotencyKey: "password-reset:abc123",
});

console.log(email.id); // msg_...

curl

curl -X POST https://api.groundmail.dev/emails \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: password-reset:abc123" \
  -d '{
    "from": "no-reply@acme.com",
    "to": "ada@example.com",
    "reply_to": "support@acme.com",
    "subject": "Reset your password",
    "html": "<p>Your reset link: https://acme.com/reset/abc123</p>",
    "text": "Your reset link: https://acme.com/reset/abc123",
    "tags": [{ "name": "type", "value": "password_reset" }]
  }'

Go

// go get 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",
	"reply_to": "support@acme.com",
	"subject":  "Reset your password",
	"html":     "<p>Your reset link: https://acme.com/reset/abc123</p>",
	"text":     "Your reset link: https://acme.com/reset/abc123",
})
if err != nil {
	log.Fatal(err)
}
fmt.Println(result["id"], result["status"])

3. Or swap SMTP settings

If your app, framework or auth provider sends through the SES SMTP interface, change four settings. The relay uses the same verified domains, plan limits, suppressions and webhooks as the API.

SettingAmazon SESGroundmail
Hostemail-smtp.<region>.amazonaws.comsmtp.groundmail.dev
Port and TLSSTARTTLS on 25, 587 or 2587; TLS wrapper on 465 or 2465587 with STARTTLS
UsernameSMTP username created using IAMgroundmail
PasswordSMTP password created using IAM (not your secret access key)An SMTP credential from the dashboard (gmsmtp_...)

SES values from connecting to the SES SMTP endpoint and SES SMTP credentials.

Last checked on each vendor's public page.

The SMTP password is shown once when you create it, so store it straight away. Over SMTP, the headers X-Groundmail-Tag, X-Groundmail-Idempotency-Key and X-Groundmail-Scheduled-At map onto tags, idempotency and scheduling. Details in the SMTP docs.

4. Replace event destinations with webhooks

On SES, events flow from a configuration set to event destinations such as SNS, EventBridge, Amazon Data Firehose or CloudWatch, and your code consumes them from there. On Groundmail, register an HTTPS endpoint in the dashboard (or POST /webhooks) and choose the events. There's no configuration set to pass on each send: the webhooks on your account receive events for every message.

SES event typeGroundmail webhook event
Sendemail.sent
Deliveryemail.delivered
Bounceemail.bounced (recipient suppressed automatically)
Complaintemail.complained
Rejectemail.failed
Openemail.opened (open tracking is a per-domain setting)
Clickemail.clicked (click tracking is a per-domain setting)
DeliveryDelayemail.delivery_delayed
RenderingFailureemail.failed
(none)email.suppressed: a send was blocked because a recipient is suppressed

Last checked on each vendor's public page.

Each delivery is one JSON object with type, created_at and data, signed in the Groundmail-Signature header (t=...,v1=..., HMAC-SHA256). Verify against the raw body before parsing:

Node (Express)

import express from "express";
import { verifyWebhookSignature } from "groundmail";

const app = express();

app.post("/webhooks/groundmail", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  const ok = verifyWebhookSignature(
    process.env.GROUNDMAIL_WEBHOOK_SECRET,
    rawBody,
    req.header("groundmail-signature")
  );
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(rawBody);
  if (event.type === "email.bounced" || event.type === "email.complained") {
    // same handling your SNS or EventBridge consumer did for Bounce / Complaint
  }
  res.sendStatus(200);
});

Use Send test event in the dashboard, or POST /emails/test from CI, to exercise the handler before real traffic arrives. Full payload reference in webhooks.

5. Import your SES suppression list

Don't start on Groundmail by re-sending to addresses SES already knows bounce or complain. Export the account-level suppression list with ListSuppressedDestinations (aws sesv2 list-suppressed-destinations), then add each address with POST /suppressions. The endpoint takes one address per call, so loop over the export. Use a full-access key.

bash (aws CLI + jq + curl)

#!/usr/bin/env bash
# Export the SES account-level suppression list for one region and import it into Groundmail.
set -euo pipefail
REGION=us-east-1
: > ses-suppressions.tsv

token=""
while :; do
  if [ -n "$token" ]; then
    page=$(aws sesv2 list-suppressed-destinations --region "$REGION" --next-token "$token" --output json)
  else
    page=$(aws sesv2 list-suppressed-destinations --region "$REGION" --output json)
  fi
  echo "$page" | jq -r '.SuppressedDestinationSummaries[] | [.EmailAddress, .Reason] | @tsv' >> ses-suppressions.tsv
  token=$(echo "$page" | jq -r '.NextToken // empty')
  [ -z "$token" ] && break
done

while IFS=$'\t' read -r email reason; do
  jq -n --arg email "$email" --arg reason "ses $reason" '{email: $email, reason: $reason}' |
    curl -sS -X POST https://api.groundmail.dev/suppressions \
      -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
      -H "Content-Type: application/json" \
      --data-binary @- > /dev/null
done < ses-suppressions.tsv

echo "Imported $(wc -l < ses-suppressions.tsv) addresses"

Run it once per region you sent from. Imported entries are stored with source: api, and the call upserts, so re-running is safe. SES keeps adding suppressions while it still carries traffic, so re-run the export after cutover; --start-date limits it to recent additions. From then on, Groundmail adds hard bounces and complaints itself.

6. Canary, cutover and rollback

  1. 1

    Put the provider behind a flag

    Wrap SES and Groundmail behind one send function and choose per message with a config value or feature flag. That flag is your rollback.

  2. 2

    Send internal mail first

    Route messages to your own team through Groundmail. Check headers in Gmail and Outlook for SPF, DKIM and DMARC passes, and confirm the webhook handler sees email.delivered.

  3. 3

    Canary a small share

    Move a few percent of real traffic, starting with your most-engaged mail such as sign-in codes and password resets. Compare delivery, bounce and complaint rates against SES for the same message types.

  4. 4

    Ramp up

    Increase the share over days while bounces and deferrals stay flat. If you are moving to dedicated IPs or a dedicated server, we warm them with you before traffic moves over.

  5. 5

    Cut over and keep SES ready

    Send 100% through Groundmail, re-run the suppression export, and keep SES identities, DKIM and MAIL FROM records in place for a couple of weeks. If something goes wrong, flip the flag back.

  6. 6

    Clean up

    Once you're confident, remove the SES DKIM CNAMEs, the MAIL FROM records and any SES-only SPF include, retire configuration sets, and revoke IAM SMTP credentials.

What doesn't carry over

  • Sending history and metrics. SES reputation data, CloudWatch metrics and Virtual Deliverability Manager dashboards stay in AWS. Export anything you need for reporting.
  • Configuration sets. There's no per-send configuration set; webhooks and dashboard activity cover events for every message. Use tags to label message types.
  • Templates. SES templates don't import automatically. Recreate and publish them in Groundmail.
  • Raw MIME over HTTP. POST /emails takes structured fields. Code that builds full MIME messages should send through the SMTP relay or switch to html, text and attachments.
  • IAM policies and dedicated IP assignments. Access is controlled with Groundmail API keys and SMTP credentials. SES dedicated IP assignments and IP pools don't carry over; if you need isolation, dedicated IPs or a dedicated server are set up with our team.
  • Marketing email. Groundmail carries transactional mail only. Newsletters and campaigns need to stay on a separate provider.

Frequently asked questions

Do I need to remove my SES DNS records before verifying with Groundmail?

No. SES DKIM CNAMEs and your custom MAIL FROM subdomain can stay published while you verify and test Groundmail. The only shared record is SPF on the root, where you add include:spf.groundmail.dev to your existing record instead of creating a second one.

Is there a Groundmail equivalent of ConfigurationSetName?

No per-send field. Webhooks you register on your account receive events for every message, and the dashboard shows activity for all sends. If you used configuration sets to separate message types, use tags instead.

Can I send raw MIME like SendRawEmail or Content.Raw?

Not through the HTTP API, which takes structured fields. Send those messages through the SMTP relay at smtp.groundmail.dev on port 587, or move to html, text and attachments on POST /emails.

How do I move my SES suppression list?

Export it with aws sesv2 list-suppressed-destinations (following NextToken until it's empty), then POST each address to /suppressions with a full-access API key. There's no bulk endpoint, so loop over the export. Re-run it after cutover to catch anything SES added during the move.

Which SDKs can I use?

Official SDKs are available for Node (npm install groundmail) and Go. From any other language, call POST /emails over HTTPS or use the SMTP relay.

How do I roll back if delivery drops?

Keep SES identities, DKIM and MAIL FROM records and credentials in place until you're confident, and route sends through a flag. Rolling back is flipping that flag; nothing on the SES side needs to be rebuilt.

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.