Short answer
To move a Laravel app from Amazon SES, install composer require groundmail/laravel, set MAIL_MAILER=groundmail with a groundmail mailer in config/mail.php (your Mailables, notifications and queued mail stay as they are), and swap AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY for GROUNDMAIL_API_KEY. Then verify your sending domain in Groundmail, leaving Amazon SES's DNS records in place until you cut over.
Amazon SES'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 Amazon SES and writes the pull request for your coding agent.
On this page
Before and after: the Laravel mail config
Laravel sends through Amazon SES with a mail driver, so the move is a config change: the Groundmail package replaces the driver, and the code that builds and sends your email stays as it is.
Swap the package
composer remove aws/aws-sdk-php
composer require groundmail/laravelBefore: Amazon SES (Laravel)
# .env
MAIL_MAILER=ses
AWS_ACCESS_KEY_ID=…
AWS_SECRET_ACCESS_KEY=…
AWS_DEFAULT_REGION=us-east-1
MAIL_FROM_ADDRESS=no-reply@acme.com
// config/services.php
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],After: Groundmail (Laravel)
# .env
MAIL_MAILER=groundmail
GROUNDMAIL_API_KEY=gm_…
MAIL_FROM_ADDRESS=no-reply@acme.com
// config/mail.php, under 'mailers'
'groundmail' => [
'transport' => 'groundmail',
],
// Your Mailables, notifications and queued mail don't change:
Mail::to($user)->send(new ResetPasswordMail($user));If you also call Amazon SES's API directly
The Groundmail Laravel package maps from, to, cc, bcc, reply-to, subject, HTML, text and attachments for you. If some code also calls Amazon SES's API directly, for example to send a stored template, its fields map like this.
| Amazon SES | Groundmail |
|---|---|
FromEmailAddress | from |
Destination.ToAddresses / CcAddresses / BccAddresses | to, cc, bcc |
Content.Simple.Subject.Data | subject |
Content.Simple.Body.Html.Data / Body.Text.Data | html / text |
Content.Template (TemplateName, TemplateData JSON string) | template: { id: "<alias>", variables: { … } }, an object, not a string |
ReplyToAddresses | reply_to |
EmailTags | tags, returned on every webhook |
ConfigurationSetName | Not needed: webhooks get events for every message |
Content.Raw (MIME) | Structured fields, or the SMTP relay if you build MIME yourself |
Using Amazon SES templates? Send them by alias with template: { id: "<alias>", variables: { … } }. Recreate them in the dashboard or with POST /templates; see templates. More field detail: Amazon SES migration docs and Laravel docs.
Environment variables
| Today | On Groundmail |
|---|---|
MAIL_MAILER=ses | MAIL_MAILER=groundmail |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | GROUNDMAIL_API_KEY (a gm_… key). Remove after cutover, unless something else in the app uses AWS |
AWS_DEFAULT_REGION | Remove after cutover, unless something else in the app uses AWS |
| (new) | GROUNDMAIL_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 Amazon SES variables set until you cut over, so rolling back is a config change.
DNS: add Groundmail's records, keep Amazon SES'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.
| Record | Type | Host | Value |
|---|---|---|---|
| Ownership | TXT | _groundmail.example.com | Token from the dashboard |
| DKIM | TXT | <selector>._domainkey.example.com | Public key from the dashboard |
| Return path | CNAME | rp.example.com | rp.groundmail.dev (DNS only, not proxied) |
| SPF | TXT | example.com | Add include:spf.groundmail.dev to your existing SPF record |
| DMARC | TXT | _dmarc.example.com | Keep yours; the dashboard suggests one if you have none |
Ownership, DKIM, the return path and SPF must resolve before the domain verifies.
Amazon SES records to remove after cutover
- The three Easy DKIM CNAMEs (
<token>._domainkey) - Your custom MAIL FROM subdomain's MX and SPF records, if you set one up
- Take
include:amazonses.comout of your root SPF record if you added it there.
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
| Amazon SES | Groundmail | |
|---|---|---|
| Delivery | A configuration set publishes events to SNS, EventBridge, Firehose or CloudWatch; SNS wraps each one in a Notification with the event as a JSON string in Message | One JSON object per POST: type, created_at and data |
| Checking it came from them | SNS message signatures, checked against an AWS certificate, plus a subscription confirmation step | Groundmail-Signature: t=…,v1=…: HMAC-SHA256 of t.rawBody with the endpoint's secret |
| Setup | Configuration set, event destination and topic subscription, per region | An HTTPS URL and a list of events in the dashboard or POST /webhooks |
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:
| Amazon SES event | Groundmail event |
|---|---|
Send | email.sent |
Delivery | email.delivered |
DeliveryDelay | email.delivery_delayed |
Bounce | email.bounced and email.failed (hard bounces are suppressed automatically) |
Complaint | email.complained |
Reject, RenderingFailure | email.failed |
Open | email.opened |
Click | email.clicked |
Check the signature against the raw request body, before you parse it. In Laravel:
Verify a Groundmail webhook (Laravel controller)
use Illuminate\Http\Request;
class GroundmailWebhookController
{
public function __invoke(Request $request)
{
$ok = $this->verifyGroundmailSignature(
$request->getContent(),
(string) $request->header('Groundmail-Signature'),
config('services.groundmail.webhook_secret'), // env('GROUNDMAIL_WEBHOOK_SECRET')
);
abort_unless($ok, 401);
$event = $request->json()->all(); // ['type' => 'email.delivered', 'data' => [...]]
return response()->noContent();
}
private function verifyGroundmailSignature(string $rawBody, string $header, string $secret): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
$parts[$key] = $value;
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if (!ctype_digit($t) || $v1 === '' || abs(time() - (int) $t) > 300) {
return false;
}
return hash_equals(hash_hmac('sha256', $t . '.' . $rawBody, $secret), $v1);
}
}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.
Amazon SES and Groundmail at 50k, 100k and 1M emails
| Monthly volume | Amazon SES | Groundmail |
|---|---|---|
| Free option | No free monthly allowance; up to $200 in AWS Free Tier credits for new AWS customers | 3,000 emails/month (100/day), no time limit |
| 50,000 emails/month | $8 (Essentials) or $5 (à la carte) | $20 (Pro) |
| 100,000 emails/month | $16 (Essentials) or $10 (à la carte) | $35 (Pro) |
| 1,000,000 emails/month | $160 (Essentials) or $100 (à la carte) | $650 (Scale) |
USD per month, monthly billing, cheapest published plan that covers each volume. Amazon SES prices as of October 2026 from aws.amazon.com/ses/pricing; Groundmail prices from our pricing page.
Last checked on each vendor's public page.
SES is cheaper at every volume, and the gap widens as you grow. What you pay for with Groundmail is the finished product: no sandbox or production-access request, no IAM, a dashboard, signed webhooks, templates and support from the people who run the mail servers.
Plans, overage and what each includes: Amazon SES pricing. Everything else side by side: Groundmail vs Amazon SES.
Cut over without losing mail
- 1
Put the provider behind a flag
Keep the Amazon SES settings next to Groundmail's and choose between them with an env var, so rolling back is a config change and a restart.
- 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
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
Ramp to 100%
Then switch everything over, and keep Amazon SES configured for a week in case you need to switch back.
- 5
Clean up
Remove the Amazon SES package, its env vars and its DNS records, and cancel the plan.
Let the migration tool find every send
The one-step importer doesn't cover Amazon SES yet. Export the addresses Amazon SES has suppressed and add them with POST /suppressions before you cut over, so you never mail an address Amazon SES already stopped.
Frequently asked questions
How long does it take to move a Laravel app from Amazon SES?
The code change is usually under an hour: one package, one mailer setting 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 Amazon SES off.
Can I keep sending over SMTP instead?
Yes. If your app sends through email-smtp.<region>.amazonaws.com 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 Amazon SES suppression list?
Export it from Amazon SES and add the addresses with POST /suppressions before cutover. From then on Groundmail suppresses hard bounces and complaints for you.
Why do teams move from Amazon SES to Groundmail?
A finished product instead of a building block: no sandbox, no IAM or SNS wiring, a dashboard, signed webhooks, templates and support.
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.