Migrate from Mailgun
Keep mailgun.js. Pass a url on the client, use a Sendly key as the basic-auth password, and read the one behaviour difference before you switch.
Last updated 2026-09-04
What Sendly implements
Mailgun's POST /v3/<domain>/messages. Success comes back in Mailgun's shape — an id and the familiar Queued. Thank you. message — so code that logs the returned id keeps logging it.
| Setting | Value |
|---|---|
| Base URL | https://api.sendly.now/api/compat/mailgun |
| Auth | HTTP Basic, username api, password sk_… |
| Path the SDK appends | /v3/<domain>/messages |
The username is the literal string api. That is Mailgun's own convention, not a Sendly one, and it is what the SDK already sends — so only the password and the URL change.
The change
import formData from "form-data";
import Mailgun from "mailgun.js";
const mailgun = new Mailgun(formData);
const mg = mailgun.client({
username: "api",
key: process.env.SENDLY_API_KEY,
url: "https://api.sendly.now/api/compat/mailgun",
});
// every call site below is untouched
await mg.messages.create("yourdomain.com", {
from: "you@yourdomain.com",
to: "customer@example.com",
subject: "Hello",
html: "<p>Welcome aboard.</p>",
});Mailgun publishes no official Python SDK, so a Python sender either calls the compat endpoint over plain multipart HTTP or moves to the Sendly Python SDK. For a Python codebase the native SDK is usually less work than preserving the vendor shape.
The behaviour difference to read before you switch
The Resend, SendGrid, Postmark and Plunk dialects reject what they cannot map, so an unsupported field is a loud error. The Mailgun dialect does not work that way.
It honours the fields in the supported list and ignores everything else. Mailgun options past o:tag — scheduled delivery via o:deliverytime, the tracking toggles, stored templates — are not applied, and the send still succeeds. If your traffic depends on any of them, that is a silent behaviour change, and the only way to catch it is to look for it.
| Field | Support | Note |
|---|---|---|
from | Full | Must be on a verified domain, and that domain must match the one in the URL path. |
to / cc / bcc | Full | Repeatable, as Mailgun sends them. |
subject | Full | |
html / text | Full | html wins when both are present; at least one is required. |
h:X-* headers | Full | The h: prefix is stripped. |
o:tag | Partial | Repeatable, mapped onto Sendly tags, sanitized. |
attachment | Full | File parts are read and attached. |
Every other o: option | Ignored | Not applied, and the send still succeeds. This is the row that surprises people. |
The domain in the path is not decorative
The domain segment in the URL path must match the domain of your from address. A mismatch is a 400 in Mailgun's error shape, not a best-effort send — which is the right outcome, because a from address on a domain you have not verified is exactly what a sending platform must refuse.
Auth, validation and domain-match failures all return Mailgun's bare { message } body with the matching status; a bad key is a 401. So mailgun.js raises the error your handling already catches, and error.status and error.details read the way they do today.
Before you switch a production sender
Verify your domain and import your suppression list first, then move one slice at a time. The staged rollout plan is the seven steps we would follow, in the order that keeps each one reversible. Given the ignored-options behaviour above, the compare step matters more here than on any other dialect.
The full supported-field table lives in the Mailgun migration guide in the docs.
Questions people ask
- Why is the username api?
- That is Mailgun's own basic-auth convention: the username is the literal string api and the password is the key. mailgun.js already sends it that way, so only the password and the URL change.
- What happens to o:deliverytime and the tracking options?
- They are ignored, and the send still succeeds. Unlike the other dialects, the Mailgun one honours the fields it supports and drops the rest rather than erroring. Test for this explicitly if your sends rely on scheduled delivery or tracking toggles.
- Why does the domain have to match my from address?
- Because sending from a domain you have not verified is what a sending platform has to refuse. A mismatch returns a 400 in Mailgun's error shape rather than sending anyway.
- Is there a Python path?
- Mailgun publishes no official Python SDK, so there is no vendor shape to preserve. Either call the compat endpoint over plain multipart HTTP, or use the Sendly Python SDK — for most Python codebases the native SDK is the smaller change.
Something on this page out of date, or missing the case you are in? Write to support@sendly.now — we answer our own email. The full technical detail lives in the documentation.