Migration guide
Migrate from Mailgun to Mail.Land
If you send through SMTP, the change is a host and a password. If you use the messages API, this guide maps each common Mailgun field to Mail.Land and lists what needs a code change.
# Before (Mailgun)
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587 # STARTTLS
SMTP_USER=<SMTP login of your sending domain>
SMTP_PASS=<SMTP password of that login>
# After (Mail.Land)
SMTP_HOST=smtp.mail.land
SMTP_PORT=587 # STARTTLS
SMTP_USER=apikey # any value; Mail.Land ignores it
SMTP_PASS=ml_pet_... # a Mail.Land key with SMTP enabledMailgun concepts in Mail.Land
| Mailgun | Mail.Land |
|---|---|
| Sending domain | Verified domain (SPF, DKIM, DMARC and return path) |
| API key and domain sending keys | API keys (credentials), each limited to the API or SMTP, to domains and to IP addresses |
| SMTP credentials per domain | The same API key, used as the SMTP password |
| Templates (Handlebars) | Templates with {{name}} placeholders |
| Bounces, complaints and unsubscribes lists | Suppressions with a type and a scope |
| Webhooks | Webhooks signed with HMAC-SHA256, a secret per webhook |
| Routes | Inbound routes |
Create the right API keys
A Mail.Land key's prefix tells you its mode and its stream. Create one key per application and stream, and keep the production keys out of your test systems.
| Prefix | What it is for |
|---|---|
ml_pet_ | Production, transactional: receipts, password resets, alerts. Up to 5 recipients per request. |
ml_pem_ | Production, marketing: newsletters and bulk sends with send-batch, up to 10,000 recipients per call. |
ml_set_ | Sandbox, transactional: with sandboxBehavior LogOnly (the default), requests are accepted and logged but never delivered. Use it for your integration tests. |
Each key can be limited to the API, to SMTP or both, to selected domains and to known IP addresses. A key is shown once, and Mail.Land stores only a hash of it.
Switch SMTP: change the host and the password
Mailgun uses an SMTP login per sending domain. Mail.Land uses your API key as the password and ignores the username.
| Setting | Mailgun | Mail.Land |
|---|---|---|
| Host | smtp.mailgun.org | smtp.mail.land |
| Port | 25, 587 or 2525 with STARTTLS, or 465 with TLS | 587 with STARTTLS |
| Username | The SMTP login of your sending domain | Any value, for example apikey |
| Password | The password of that SMTP login | A Mail.Land API key with SMTP enabled (ml_pet_ for transactional mail) |
Mail.Land accepts sign-in only after STARTTLS, and port 465 is not offered. If your application uses 465, switch it to 587 with STARTTLS. Messages can be up to 15 MiB.
Switch the API call
Mailgun takes form fields with HTTP Basic auth. Mail.Land takes a JSON body with the X-API-Key header. Here is the same message in both APIs, and a batch send.
curl -s --user "api:$MAILGUN_API_KEY" \
https://api.mailgun.net/v3/yourdomain.com/messages \
-F from="Your Shop <[email protected]>" \
-F to="Jane <[email protected]>" \
-F subject="Order 12345 has shipped" \
-F text="Your order is on its way." \
-F html="<p>Your order is on its way.</p>" \
-F h:Reply-To="[email protected]" \
-F o:tag="order-shipped"curl -X POST https://api.mail.land/v1/transactional/send \
-H "X-API-Key: $MAILLAND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": { "email": "[email protected]", "name": "Your Shop" },
"to": [{
"email": "[email protected]", "name": "Jane",
"variables": { "orderNumber": "12345" }
}],
"replyTo": "[email protected]",
"subject": "Order {{orderNumber}} has shipped",
"textContent": "Your order {{orderNumber}} is on its way.",
"htmlContent": "<p>Your order {{orderNumber}} is on its way.</p>",
"tags": ["order-shipped"]
}'
# MAILLAND_API_KEY=ml_pet_... (production, transactional)Field mapping for the send call
From POST /v3/<domain>/messages to POST /v1/transactional/send.
| Mailgun | Mail.Land | Note |
|---|---|---|
--user api:<key> (Basic auth) | X-API-Key: <key> | A header instead of Basic auth, and a JSON body instead of form fields. |
from | from.email, from.name | Split the display name from the address. The address must be on a verified domain. |
to | to[].email, to[].name | Up to 5 recipients per request with a transactional key; each receives a separate copy. |
cc, bcc | None | Not supported. Send a separate message to each recipient. |
subject | subject | Required unless you use a template. |
text | textContent | |
html | htmlContent | |
amp-html | None | There is no AMP part. |
template, t:version | templateId | Recreate the template in Mail.Land first. Templates have no versions. |
t:variables | variables | Values shared by every recipient, as a JSON object instead of a JSON string. |
v:<name> (metadata) | tags | Mail.Land has no key and value metadata on events. Carry an identifier in tags or in a custom header. |
recipient-variables | to[].variables | Per-recipient values. For more than 5 recipients, use send-batch with a marketing key and recipients[].variables. |
o:tag | tags | An array of strings. |
h:<Header-Name> | customHeaders | An object of header names and values. |
h:Reply-To | replyTo | A plain address string. |
attachment, inline | attachments[].fileName, contentType, content, contentId, inline | Base64 content in JSON instead of multipart files. Up to 10 attachments and 10 MiB in total. |
o:deliverytime | scheduledAt | A UTC date and time in ISO 8601, up to 7 days ahead. |
o:tracking-opens, o:tracking-clicks | tracking.opens, tracking.clicks | Booleans. A domain's own open-tracking choice takes precedence. |
No equivalent | X-Idempotency-Key header | Within the retention window, a retry with the same key returns the original response instead of sending again. |
Mail.Land answers 202 Accepted with a jobId, a message ID for each accepted recipient, and the lists of accepted and rejected recipients.
Add your domain and DNS records
Add the domain in Mail.Land and publish the records the dashboard shows you. SPF, DKIM and DMARC must verify before you can send. You can do this while Mailgun is still sending.
| Record | Name | Value | Note |
|---|---|---|---|
| SPF (TXT) | yourdomain.com | include:spf.mlnd.net | Add the include to your existing SPF record. A domain must have only one SPF record. |
| DKIM (TXT) | mlnd._domainkey.yourdomain.com | v=DKIM1; k=rsa; p=... | The selector is mlnd, so the record sits beside Mailgun's DKIM record. |
| DMARC (TXT) | _dmarc.yourdomain.com | v=DMARC1; p=... | Required. Keep a single DMARC record; an existing one with a policy is enough. |
| Return path (CNAME) | bounce.yourdomain.com | bounce.mlnd.net | Set it to DNS only, not proxied. Check that this name is not already in use. |
Keep Mailgun's DNS records until the move is finished, and remove them only after its last message has gone.
Import your suppression lists first
Mailgun keeps bounces, complaints and unsubscribes for each sending domain, so export the lists of every domain you send from, for example with GET /v3/<domain>/bounces. Then import each list into Mail.Land with the matching type and scope, in the dashboard or with POST /v1/transactional/suppressions/import.
| Mailgun list | Mail.Land type | Scope |
|---|---|---|
| Bounces | HardBounce | Global |
| Complaints | Complaint | Global |
| Unsubscribes (all mail) | Unsubscribe | Global |
| Unsubscribes for one tag or mailing list | Unsubscribe | Marketing; or List, which applies only to campaigns sent to the matching Mail.Land mailing list |
Import before your first send through Mail.Land, so that people who opted out stay suppressed from the start. Addresses that already exist are skipped, not duplicated.
Point your webhooks at Mail.Land
Add your endpoint in Mail.Land, choose the events and verify the X-MLND-Signature header. Mailgun signs a timestamp and a token with one account-wide key; Mail.Land signs the timestamp and the raw body with a secret per webhook, so update your verification code too.
| Mailgun event | Mail.Land event | Note |
|---|---|---|
accepted | None today | message.sent is not available yet; the 202 response confirms acceptance. |
delivered | message.delivered | |
temporary_fail | message.deferred | |
permanent_fail | message.bounced | A refusal by the receiving server. message.failed reports a stop on Mail.Land's side. |
complained | message.complained | |
unsubscribed | message.unsubscribed | Sent for the recipient's own unsubscribe, not for changes you make through the API. |
opened, clicked | Not available yet | Open and click events are catalogued but not sent today. |
Deduplicate on event_id, because a retried delivery carries the same event ID. A status can be corrected later, so keep processing events after a final one and compare status_version.
Recreate your templates
Create each template in the Mail.Land dashboard or with the templates API, then send it with templateId and variables. Every placeholder needs a value, or the send is refused with 422 and the missing names.
| Mailgun | Mail.Land |
|---|---|
{{firstName}} | {{firstName}} |
%recipient.firstName% | {{firstName}} |
{{#if}}, {{#unless}}, {{#each}}, {{#with}}, {{#equal}} | None |
Template versions (t:version) | None |
Mail.Land placeholders are plain: no conditions, loops, filters or partials. Move that logic into your application, or keep one template per variant.
Cut over step by step
- Add and verify your sending domain in Mail.Land, adding include:spf.mlnd.net to your existing SPF record.
- Export the suppression lists of each Mailgun domain and import them.
- Recreate your templates and webhooks.
- Create a sandbox key with sandboxBehavior LogOnly, and run your integration tests against Mail.Land without delivering mail.
- Send real messages to addresses you control and check them in the email log.
- Move one application or a small share of traffic, then watch delivery, bounces and complaints.
- While both providers run, keep opt-outs in step both ways: copy new unsubscribes, complaints and bounces from Mailgun to Mail.Land and from Mail.Land to Mailgun, and do a final sync before you move the remaining traffic.
- Check your domain's warm-up: on Free and Pro, a new domain has a daily limit that rises in stages, and sends above it are refused. Plan the move within those limits, or ask us about a custom schedule. Max skips warm-up.
- Then move the rest, and keep Mailgun active until its last scheduled and retried messages have gone.
Sources
Mailgun's public pages used for this guide.
- [1]Mailgun API: send a messagehttps://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messageschecked November 4, 2026
- [2]Mailgun API: authenticationhttps://documentation.mailgun.com/docs/mailgun/api-reference/authenticationchecked November 4, 2026
- [3]Mailgun: send with SMTPhttps://documentation.mailgun.com/docs/mailgun/user-manual/sending-messages/send-smtpchecked November 4, 2026
- [4]Mailgun: send with templateshttps://documentation.mailgun.com/docs/mailgun/user-manual/sending-messages/send-templateschecked November 4, 2026
- [5]Mailgun: webhookshttps://documentation.mailgun.com/docs/mailgun/user-manual/webhooks/webhookschecked November 4, 2026
- [6]Mailgun: securing webhookshttps://documentation.mailgun.com/docs/mailgun/user-manual/webhooks/securing-webhookschecked November 4, 2026
- [7]Mailgun API: bounceshttps://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/bounces/get-v3--domainid--bounceschecked November 4, 2026
Frequently asked questions
Can I keep sending through Mailgun while I test?
Yes. Verifying a domain in Mail.Land does not stop Mailgun from sending for it, so you can run both and move traffic gradually.
Mailgun lets me choose a US or EU region. Where does Mail.Land run?
Mail.Land has no regions to choose. The primary copy of your data is in Germany; the providers outside the EU are listed on our GDPR page.
How do I send to many recipients at once?
Use POST /v1/transactional/send-batch with a marketing key (ml_pem_). It takes up to 10,000 recipients, each with their own variables. A transactional key takes up to 5 recipients per request.
Is there an official Mail.Land SDK?
Not yet. The documentation has copy-paste examples for the REST API in cURL, Node.js, Python, PHP, C#, Java and Go, and any SMTP library works with the relay.
Ready to move from Mailgun?
Create a free account and send your first test today. If you want help, tell us about your setup.