What changes, what doesn't
Both are per-domain, developer-first sending APIs with webhooks for delivery
events, so the integration shape survives. The concrete change with the widest
blast radius: Mailgun's HTTP API is form-encoded (multipart/form-data or
application/x-www-form-urlencoded), while EuroMail's is JSON. If your client
builds a FormData object today, that code needs to change, not just the
field names. The honest comparison, including where Mailgun is the better
choice, is at euromail vs Mailgun.
Verify your domain on EuroMail first, run both in parallel, then cut over.
API mapping
| Mailgun | EuroMail | |
|---|---|---|
| Endpoint | POST https://api.mailgun.net/v3/{your-domain}/messages | POST https://api.euromail.dev/v1/emails, no domain in the path |
| Body format | Form-encoded (multipart/form-data) | JSON |
| Auth | HTTP Basic: username api, password = API key | X-EuroMail-Api-Key: em_live_... header |
| HTML body | html (form field) | html_body (JSON field) |
| Text body | text | text_body |
| Attachments | attachment (multipart file upload) | attachments: [{ filename, content, content_type }] (base64 in JSON) |
| Scheduling | o:deliverytime (RFC 2822 format, e.g. Fri, 14 Oct 2026 12:00:00 +0000) | send_at (RFC 3339, e.g. 2026-10-14T12:00:00Z) |
| Custom headers | h:X-My-Header (prefixed form field) | headers are not a general passthrough today. Use metadata for correlation data instead |
| Tagging | o:tag (up to 3 per message) | tags (array of strings, no fixed cap) |
| Variables | v:my-var (prefixed form field, echoed in webhooks) | metadata (single JSON object, echoed on webhooks) |
| Idempotency | None documented | idempotency_key body field. Same key always returns the original result |
| Success response | 200 with { "id", "message": "Queued. Thank you." } | 202 Accepted with { "id", "status": "queued", ... } |
The endpoint itself simplifies: no per-domain path segment, because your API
key is already scoped to your account and the from address determines the
sending domain (which must be
verified first).
Code before and after
curl:
# Before: Mailgun (form-encoded, HTTP Basic auth)
curl -X POST https://api.mailgun.net/v3/yourdomain.com/messages \
-u "api:your-mailgun-api-key" \
-F from="[email protected]" \
-F to="[email protected]" \
-F subject="Welcome aboard" \
-F html="<h1>Welcome!</h1>"
# After: EuroMail (JSON, header auth)
curl -X POST https://api.euromail.dev/v1/emails \
-H "X-EuroMail-Api-Key: em_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"from": "[email protected]",
"to": "[email protected]",
"subject": "Welcome aboard",
"html_body": "<h1>Welcome!</h1>"
}'
TypeScript:
// Before: Mailgun
import formData from "form-data";
import Mailgun from "mailgun.js";
const mailgun = new Mailgun(formData);
const mg = mailgun.client({ username: "api", key: process.env.MAILGUN_API_KEY! });
await mg.messages.create("yourdomain.com", {
from: "[email protected]",
to: "[email protected]",
subject: "Welcome aboard",
html: "<h1>Welcome!</h1>",
});
// After: EuroMail
import { EuroMail } from "@euromail/sdk";
const client = new EuroMail({ apiKey: process.env.EUROMAIL_API_KEY! });
await client.emails.send({
from: "[email protected]",
to: "[email protected]",
subject: "Welcome aboard",
html_body: "<h1>Welcome!</h1>",
});
The SDK swap removes the form-encoding concern entirely. Python, Rust, and Go SDKs all send JSON with the same field names.
SMTP cutover
| Setting | Mailgun | EuroMail |
|---|---|---|
| Host | smtp.mailgun.org (EU: smtp.eu.mailgun.org) | smtp.euromail.dev |
| Port | 587, 465, 25, or 2525 | 587 (STARTTLS) or 465 (implicit TLS) |
| Username | [email protected], per-domain, not your account login | apikey (literal string) |
| Password | your domain's SMTP password (dashboard → domain settings) | your EuroMail API key (em_live_...) |
The username convention changes the most: Mailgun issues a separate SMTP
password per sending domain, while EuroMail's SMTP relay authenticates with
your account-level API key under the literal username apikey. The same
convention SendGrid, Postmark, and SES SMTP presets use, so most "SMTP
provider" dropdowns work unchanged. Full walkthroughs in
Send Email via SMTP.
DNS: verify your domain before switching
- Add your domain in the EuroMail dashboard and publish its DKIM, SPF, and DMARC records. Domain Verification.
- Keep Mailgun's records in place during the dual-run. Different DKIM
selectors and stacked SPF
include:s let both providers verify at once. - Remove Mailgun's records after cutover, on your own schedule.
Webhook event mapping
| Mailgun event | EuroMail event |
|---|---|
delivered | delivered |
permanent_fail | bounced |
temporary_fail | deferred |
complained | complained |
opened | opened |
clicked | clicked |
unsubscribed | Handled via suppression lists, not a webhook event |
Signature verification changes: Mailgun signs with HMAC-SHA256 over a
concatenated timestamp + token, delivered as three separate payload
fields you combine yourself. EuroMail signs the whole payload once with
HMAC-SHA256 in the X-EuroMail-Signature header. One hash instead of
reconstructing the signed string. See
Webhooks.
Bring your suppression list
Export bounces, complaints, and unsubscribes from the Mailgun dashboard, then replay them:
curl -X POST https://api.euromail.dev/v1/suppressions \
-H "X-EuroMail-Api-Key: em_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"email_address": "[email protected]",
"reason": "migrated from Mailgun: hard bounce"
}'
Details in Suppression Lists.
Your first 24 hours
Every new account spends its first 24 hours -- counted from account creation, not from your first send -- inside a probation window with two limits designed to stop day-one spam blasts without starving a real migration:
- A send-rate cap: 5 emails per minute and 25 per hour on day one, loosening over the first week.
- A recipient allowance: a token bucket of 20 that refills at 2 per
hour, counted per deliverable recipient (
toplus every cc and bcc). A settled, non-zero invoice raises the allowance to 1,000 -- a paid plan still inside its trial does not count as a payment. The allowance lifts entirely once the account is 24 hours old.
A refused send returns 429 with a retry-after header (the SMTP relay
returns 452, which well-behaved mailers retry). Test sends from a
not-yet-verified domain to your own address (sandbox mode) never consume
the allowance, so integration testing is free.
The practical consequence for a migration: create your EuroMail account and verify your domain at least a day before cutover. By the time production traffic moves, the recipient allowance has expired and only the loosened week-one rate cap (15/minute, 75/hour) remains. If you must cut over with real volume on day one, settle your first invoice first -- 1,000 recipients covers a typical day -- or keep Mailgun running in parallel for the first 24 hours.
Honest gaps
- Mailing lists don't port as-is. Mailgun's mailing-list feature
(
[email protected]expansion) has no direct equivalent. Recipient fan-out belongs in your application, or in Contact Lists + Newsletters for marketing sends. - Route-based inbound rules are structured differently. Mailgun's regex-match routing maps to EuroMail's inbound routes, but the rule syntax isn't a drop-in port. Expect to recreate routing logic, not copy-paste it.
- Full comparison at euromail vs Mailgun.
Cutover checklist
- Create a EuroMail account at least 24 hours before cutover (probation limits expire with account age) and an API key with the
emails:sendscope (API Keys). - Verify your sending domain; confirm DKIM/SPF/DMARC show green.
- Register your webhook endpoint and swap the signature verification.
- Import suppressions from your Mailgun export.
- Switch from form-encoded requests to JSON. Update the endpoint, auth
method (Basic → header), and field names (or the SMTP username/password),
accepting
202as success instead of200. - Send a staging email end-to-end; confirm the
deliveredwebhook arrives. - Cut production traffic over; watch the first hour in Analytics and Deliverability Insights.
- Keep Mailgun read-only for a rollback window, then retire it.