---
title: "Switch from Mailgun"
description: "Keep the Mailgun SDK and send through Nextmail. Change its base URL and its key, and your sending code stays as it is."
url: "https://nextmail.sh/docs/knowledge/developers/switch-from-mailgun"
area: "Knowledge base"
category: "Developers"
status: "live"
---

# Switch from Mailgun

Keep the Mailgun SDK and send through Nextmail. Change its base URL and its key, and your sending code stays as it is.

## What to change

Point the SDK at https://api.nextmail.sh/compat/mailgun and give it an API key from Nextmail with the `emails:send` permission in place of the Mailgun key. It travels as the password of the same HTTP Basic sign-in, and the user name is not checked. The domain in the path has to be one of the workspace’s domains, and the From address decides whether a message may go out, as it does everywhere in Nextmail.

**Point the Mailgun SDK at Nextmail**

_Node.js_

```nodejs
import formData from 'form-data'
import Mailgun from 'mailgun.js'

const mailgun = new Mailgun(formData)
const mg = mailgun.client({
  username: 'api',
  key: process.env.NEXTMAIL_API_KEY,
  url: 'https://api.nextmail.sh/compat/mailgun',
})

await mg.messages.create('acme.com', {
  from: 'Acme Billing <billing@acme.com>',
  to: ['ada@example.com'],
  subject: 'Your invoice',
  html: '<p>Your invoice is attached.</p>',
})
```

_Python_

```python
import os
from mailgun.client import Client

with Client(
    auth=("api", os.environ["NEXTMAIL_API_KEY"]),
    api_url="https://api.nextmail.sh/compat/mailgun",
) as client:
    client.messages.create(
        domain="acme.com",
        data={
            "from": "Acme Billing <billing@acme.com>",
            "to": ["ada@example.com"],
            "subject": "Your invoice",
            "html": "<p>Your invoice is attached.</p>",
        },
    )
```

_Ruby_

```ruby
require 'mailgun-ruby'

mg = Mailgun::Client.new(
  ENV.fetch('NEXTMAIL_API_KEY'),
  'api.nextmail.sh/compat/mailgun'
)
mg.send_message('acme.com', {
  from: 'Acme Billing <billing@acme.com>',
  to: 'ada@example.com',
  subject: 'Your invoice',
  html: '<p>Your invoice is attached.</p>'
})
```

_PHP_

```php
<?php

use Http\Client\Common\Plugin\AddPathPlugin;
use Http\Client\Common\PluginClient;
use Http\Discovery\Psr17FactoryDiscovery;
use Http\Discovery\Psr18ClientDiscovery;
use Mailgun\HttpClient\HttpClientConfigurator;
use Mailgun\Mailgun;

$configurator = (new HttpClientConfigurator())
    ->setApiKey(getenv('NEXTMAIL_API_KEY'))
    ->setEndpoint('https://api.nextmail.sh')
    ->setHttpClient(new PluginClient(Psr18ClientDiscovery::find(), [
        new AddPathPlugin(Psr17FactoryDiscovery::findUriFactory()->createUri('/compat/mailgun')),
    ]));

$mg = new Mailgun($configurator);
$mg->messages()->send('acme.com', [
    'from' => 'Acme Billing <billing@acme.com>',
    'to' => 'ada@example.com',
    'subject' => 'Your invoice',
    'html' => '<p>Your invoice is attached.</p>',
]);
```

_curl_

```bash
curl -s --user "api:$NEXTMAIL_API_KEY" \
  https://api.nextmail.sh/compat/mailgun/v3/acme.com/messages \
  -F from='Acme Billing <billing@acme.com>' \
  -F to=ada@example.com \
  -F subject='Your invoice' \
  -F html='<p>Your invoice is attached.</p>'
```

> In Ruby, the second argument is the host and the path without a scheme. In PHP, the SDK keeps only the host of the endpoint it is given, so the path goes in through `AddPathPlugin` from php-http, which the SDK already installs. The official Python package may log a warning that the host is not Mailgun’s, and sends anyway. It also retries a request that failed with 429 or a 5xx, so Nextmail answers 400 rather than 5xx once part of a batch has gone out.

## What maps to what

`POST /v3/{domain}/messages` is the endpoint served, as `multipart/form-data`, which attachments need, or `application/x-www-form-urlencoded`. A field name ending in `[]` is read without it.

| Mailgun | In Nextmail |
| --- | --- |
| `from` | The sender, with its name. |
| `to` | Recipients, repeated or separated by commas. With `cc` and `bcc`, up to 50 per message. |
| `subject` | The subject. |
| `html` | The HTML body. `text` becomes the text body, and one of the two, or `template`, is required. |
| `attachment` | Files, 20 at most and 10 MB in all. |
| `inline` | An image the HTML uses as `cid:` and its file name is embedded where it appears. Any other inline file arrives as an ordinary attachment. |
| `o:tag` | Tags named `tag`, `tag_2` and so on, each holding one tag. |
| `v:` | Each variable becomes a tag with its name and value. With `o:tag`, at most 10 per message. |
| `o:deliverytime` | A scheduled send, up to a year ahead. A time already past sends at once. |
| `o:tracking` | With `o:tracking-clicks` and `o:tracking-opens`, turns tracking on or off for the message. `htmlonly` counts as on. |
| `o:testmode` | `yes` records the message as sent without delivering it, as an `oe_test_` key does. |
| `h:Reply-To` | The reply-to address. Any other `h:` field becomes a custom header: `X-*`, `List-*`, Precedence, Auto-Submitted, Importance, Priority and Feedback-ID. |
| `recipient-variables` | A batch send. Every `to` address gets its own message, with `%recipient.key%` filled in from its variables and `%recipient%` as its address, and `cc` and `bcc` go on each one. A placeholder without a value is left as it is. |
| `template` | The slug or id (`tpl_...`) of a template in Nextmail, filled in from `t:variables`, or else from `h:X-Mailgun-Variables`. `t:version` picks a version by its number. |

## What is refused, and why

- A `template` with `html` or `text`, because a template in Nextmail supplies the whole body, and a `t:version` that is not a version number.
- `o:deliverytime-optimize-period` and `o:time-zone-localize`, because Nextmail does not pick a send time for each recipient. Other `h:X-Mailgun-` headers, which are instructions to Mailgun: use the matching `o:` option instead.
- `amp-html` on its own. Next to `html` or `text` it is left out, because those still carry the message.
- More than one reply-to address, more than 10 tags, a tag name other than letters, digits, `_` and `-`, and a batch of more than 100 recipients. Mailgun takes 1,000, so split larger batches.

`o:dkim`, `o:require-tls`, `o:skip-verification`, `o:sending-ip`, `o:sending-ip-pool`, `o:tracking-pixel-location-top`, `o:archive-to`, `o:deliver-within` and `t:text` are accepted and change nothing.

## Responses and errors

- A send answers 200 with the message `Queued. Thank you.` and an `id`: the Nextmail message id in angle brackets, which `GET /emails/{id}` and webhooks use without them. A batch send makes one message per recipient, each with its own id, and answers with the first. An `Idempotency-Key` header works as it does on the rest of the API.
- A missing or unknown key answers 401 with the plain text `Forbidden`, and a domain the workspace does not have answers 404 with `Domain not found`. Everything else comes back as a `message`: 400 for a message that cannot be sent, 403 for a key without `emails:send`, a From address the key may not use, a domain that cannot send yet or a workspace that has used up its sending allowance, and 413 for a body over 25 MB or attachments over 5 MB.
- When one recipient of a batch fails after others were accepted, the error names the messages already sent and answers 400, so an SDK that retries does not send them twice.

## Bring your account across

Migrations → From a provider reads a Mailgun account with one API key and brings across what can leave it, from both its US and its EU region. Running it twice duplicates nothing, and the key is held encrypted only while the import runs.

- Suppressions keep a reason: bounces as bounces, complaints as complaints, and unsubscribes that cover every tag as manual.
- Every mailing list becomes an audience, with its subscribed members as contacts.
- Templates arrive from the account and from each domain, with their active version.
- Domain and account webhooks are registered with the same URL and the nearest matching events, switched off, because deliveries here are signed differently.
- Domains come back as a checklist with their records, and API keys by name, because a signing key or a token cannot leave the service that made it.
