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 SES | Groundmail |
|---|---|
| IAM credentials | gm_... API key (Bearer) |
| Regional endpoint | https://api.groundmail.dev |
SendEmail (SES v2) | POST /emails |
| Verified identity | Verified domain |
| Configuration set + event destinations | Webhooks and dashboard activity |
| Account-level suppression list | Suppression list, filled automatically from bounces and complaints |
SMTP: email-smtp.<region>.amazonaws.com with IAM-derived credentials | smtp.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
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
Add your domain
Add the domain you send from today, in the dashboard or with
POST /domainsand{"name": "example.com"}. While DNS propagates, you can self-test fromnoreply@mail.groundmail.devto your own account email. - 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 CNAMErp.example.compointing torp.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 alsorp, 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.devto 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 SendEmail | Groundmail POST /emails | Notes |
|---|---|---|
FromEmailAddress | from | String, or { email, name } |
Destination.ToAddresses | to | String or array |
Destination.CcAddresses / BccAddresses | cc / bcc | |
Content.Simple.Subject.Data | subject | |
Content.Simple.Body.Html.Data | html | |
Content.Simple.Body.Text.Data | text | Send both html and text if you did before |
ReplyToAddresses | reply_to | String or array |
EmailTags | tags | Array of { name, value }, max 10 |
ConfigurationSetName | No equivalent field | Events go to the webhooks on your account (step 4) |
Content.Template | template: { id, variables } | Recreate the template in Groundmail first; id is its UUID or alias |
Content.Raw | No raw MIME on the HTTP API | Send 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.
| Setting | Amazon SES | Groundmail |
|---|---|---|
| Host | email-smtp.<region>.amazonaws.com | smtp.groundmail.dev |
| Port and TLS | STARTTLS on 25, 587 or 2587; TLS wrapper on 465 or 2465 | 587 with STARTTLS |
| Username | SMTP username created using IAM | groundmail |
| Password | SMTP 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 type | Groundmail webhook event |
|---|---|
| Send | email.sent |
| Delivery | email.delivered |
| Bounce | email.bounced (recipient suppressed automatically) |
| Complaint | email.complained |
| Reject | email.failed |
| Open | email.opened (open tracking is a per-domain setting) |
| Click | email.clicked (click tracking is a per-domain setting) |
| DeliveryDelay | email.delivery_delayed |
| RenderingFailure | email.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
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
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
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
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
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
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
tagsto label message types. - Templates. SES templates don't import automatically. Recreate and publish them in Groundmail.
- Raw MIME over HTTP.
POST /emailstakes structured fields. Code that builds full MIME messages should send through the SMTP relay or switch tohtml,textandattachments. - 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.