From Mailgun to Postal, field by field
Postal's API is not the same as Mailgun's: sending swaps form-data for JSON, the key goes in a different header and webhooks have a different format. This guide shows what has a match, what moves elsewhere and what has no counterpart.
- Send format
- Form-data to JSON
- Authentication
- Basic to Bearer
- How to switch traffic
- In stages
Overview
In Mailgun you send with POST /v3/{domínio}/messages, in form-data, with the user api and your key. In Postal sending is POST /emails, in JSON, with Authorization: Bearer ps_…. Most of what you use has a match. The rest needs a decision from you before the cutover, and this guide points out where.
POST api.mailgun.net/v3/{domínio}/messagesform-data · user apiPOST api.postal.so/emailsJSON · Authorization: BearerOnly the format changes
Sender, recipients, subject, HTML, text, attachments, headers, tags, scheduling and SMTP all have a match. What changes is how you send: JSON instead of form-data and a key in the Authorization header.
Moves elsewhere
Tracking, enforced TLS and region stop being a per-message choice and become a domain setting. Mailgun unsubscribes become unsubscribed contacts.
No equivalent
Free-form v: data in events, conditions and loops in templates, choosing the template version at send time, the bounce exception list and sending to a list's address.
Postal does not imitate Mailgun's API. The only API with a compatibility mode is Resend's: if you already use its SDK, see the Resend guide. For Mailgun, the switch happens in your code, with fetch or with Postal's official SDKs for Node.js and PHP.
Add the domain and create a key
Add the domain in the dashboard (or with POST /domains), create the records Postal shows in your DNS and ask for verification. The records use their own names, postal._domainkey and the postal subdomain, so you can publish them next to Mailgun's and only remove the old ones after the cutover. Before that, check that none of your records already uses those names.
- Region: Mailgun has two URLs, US and EU. In Postal there is a single URL and the region is chosen per domain: São Paulo (default), us-east-1, us-east-2, eu-west-1 or ap-northeast-1.
- For systems that only send, create a send-only key, restricted to the domain. The full token appears only once, at creation.
- If your main domain already sends through Mailgun, use a dedicated subdomain in Postal.
Swap the send call
In Mailgun the domain goes in the URL and the fields go in form-data. In Postal the domain comes from from and the body is JSON. The response is {"id": "…"}: keep the ID, which comes back in webhooks as data.email_id and lets you look up the email at GET /emails/{id}. With the Idempotency-Key header, repeating a request does not duplicate the send.
- curl -s --user 'api:$MAILGUN_API_KEY' \- https://api.mailgun.net/v3/mg.exemplo.com.br/messages \- -F from='Aurora Cosméticos <[email protected]>' \- -F [email protected] \- -F subject='Your order #10482 is out for delivery' \- --form-string html='<p>Order <b>#10482</b> is on its way.</p>' \- -F o:tag='shipping'+ curl -X POST https://api.postal.so/emails \+ -H "Authorization: Bearer $POSTAL_API_KEY" \+ -H "Content-Type: application/json" \+ -d '{+ "from": "Aurora Cosméticos <[email protected]>",+ "to": ["[email protected]"],+ "subject": "Your order #10482 is out for delivery",+ "html": "<p>Order <b>#10482</b> is on its way.</p>",+ "tags": [{"name": "category", "value": "shipping"}]+ }'- const form = new FormData();- form.append('from', 'Aurora Cosméticos <[email protected]>');- form.append('to', '[email protected]');- form.append('subject', 'Your order #10482 is out for delivery');- form.append('html', '<p>Order <b>#10482</b> is on its way.</p>');- form.append('o:tag', 'shipping');- const auth = Buffer.from('api:' + process.env.MAILGUN_API_KEY).toString('base64');- await fetch('https://api.mailgun.net/v3/mg.exemplo.com.br/messages', {- method: 'POST',- headers: { Authorization: `Basic ${auth}` },- body: form,- });+ const response = await fetch('https://api.postal.so/emails', {+ method: 'POST',+ headers: {+ Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,+ 'Content-Type': 'application/json',+ 'Idempotency-Key': 'order-10482-shipped',+ },+ body: JSON.stringify({+ from: 'Aurora Cosméticos <[email protected]>',+ to: ['[email protected]'],+ subject: 'Your order #10482 is out for delivery',+ html: '<p>Order <b>#10482</b> is on its way.</p>',+ tags: [{ name: 'category', value: 'shipping' }],+ }),+ });+ const { id } = await response.json();Field by field
Everything Mailgun's send accepts and what happens to each part in Postal.
| Mailgun | On Postal | What changes |
|---|---|---|
| URL and authentication | https://api.postal.so and Authorization: Bearer ps_… | In Mailgun: https://api.mailgun.net/v3/{domínio}/messages (in the EU region, api.eu.mailgun.net) and user api with the key. In Postal there is a single URL and the domain comes from from. |
from | from | Same format (Nome <email@dominio>). The domain must be verified and have sending enabled. |
to | to | Text or list, up to 50 addresses. For more people, send one item per person to POST /emails/batch (up to 100 per call). |
cc, bcc | cc, bcc | Text or list. |
subject, text, html | subject, text, html | Same. Without a template, Postal requires subject and at least html or text; it recommends sending both. |
attachment | attachments | There is no multipart file upload. Send filename and content in Base64, or path with a public URL. Up to 40 MB in total, and attachments are not supported in batches. |
inline | attachments with content_id | In the HTML use <img src="cid:logo"> and, on the attachment, "content_id": "logo". |
o:tag (repeatable, free text) | tags: list of {name, value} | Name and value accept only ASCII letters, numbers, _ and -, up to 256 characters. "September newsletter" becomes september-newsletter. Up to 50 per email. |
o:deliverytime | scheduled_at | ISO 8601 date, in the future and within 30 days. Depends on the plan and is not supported in batches. You can reschedule (PATCH /emails/{id}) and cancel (POST /emails/{id}/cancel). |
o:tracking, o:tracking-opens, o:tracking-clicks | open_tracking and click_tracking on the domain | In Mailgun you can turn them on or off per message. In Postal the choice belongs to the domain and there is no field on the send. Depends on the plan. |
o:testmode | Test addresses | Instead of a flag on the send, you send to [email protected] (and bounced@, complained@, suppressed@, delayed@). It does not use quota and generates real webhooks. Depends on the plan. |
o:require-tls | tls: "enforced" on the domain | Also a domain setting, not a message one. |
h:X-Meu-Cabecalho | headers | An object: {"X-Pedido": "10482"}. You cannot override From, To, Subject, Message-ID and other system headers. |
v:minha-variavel | No matching field | Mailgun returns this free-form data in events (user-variables). In Postal, use tags for short values (they come back in data.tags) and keep the rest in your database, linked to the email's id. |
template and t:variables | template with id and variables | id accepts the ID or the alias. See the templates step. |
t:version | No matching field | Postal always uses the latest published version of the template. |
recipient-variables | One item per person in the batch | Each person becomes an object in POST /emails/batch, with its own template.variables. |
| Size: 25 MB per message | Attachments: 40 MB in total | Mailgun limits the whole message; Postal limits the attachments. Your plan may allow less than 40 MB. |
Recreate the templates
Mailgun templates use a version of Handlebars, with conditions (if, unless), loops (each), with and equal. Postal's only substitute variables: {{{NOME}}}, with three braces, and the value must be text or a number. There are no conditions, loops or nested objects. When your template depends on logic, move the logic into code: pick between two templates or build the HTML yourself and send it in html.
- In Postal you edit a draft and publish; sends always use the latest published version, and old versions can be restored.
- In the HTML, values are escaped. A variable with no value and no default value (
fallback_value) returns a 422 error. - Declare the variables when you create the template: up to 50, with a text or number type.
- <h1>Hello, {{name}}!</h1>- <p>Your order: {{order}}</p>+ <h1>Hello, {{{NAME}}}!</h1>+ <p>Your order: {{{ORDER}}}</p>- -F template='order-shipped' \- -F t:variables='{"name": "Ana", "order": "10482"}'+ "template": {+ "id": "order-shipped",+ "variables": {"NAME": "Ana", "ORDER": "10482"}+ }Point SMTP at Postal
If any system sends over SMTP, change the server, user and password. SMTP is part of the plan. Messages go through the same rules as the API (verified domain, quotas, suppression list) and show up in Emails and Logs.
- Postal's limits per message: 40 MB and 50 recipients. Anything over that gets an error; split the send.
- host: 'smtp.mailgun.org',- port: 587,- secure: false,- auth: { user: '[email protected]',- pass: process.env.MAILGUN_SMTP_PASSWORD },+ host: 'smtp.postal.so',+ port: 465,+ secure: true,+ auth: { user: 'postal', pass: process.env.POSTAL_API_KEY },| Mailgun | Postal | |
|---|---|---|
| Server | smtp.mailgun.org (EU region: smtp.eu.mailgun.org) | smtp.postal.so |
| Ports | 25, 587 and 2525 with STARTTLS; 465 with TLS | 465 with TLS (recommended) or 587 with STARTTLS. If your network blocks them, 2465 (TLS) or 2587 (STARTTLS). |
| Username | The domain's SMTP login, a full address | postal |
| Password | The SMTP password created for the domain | An API key (ps_…). A send-only key is enough. |
| Options via header | X-Mailgun-Tag, X-Mailgun-Variables, X-Mailgun-Track and X-Mailgun-Deliver-By | Over SMTP there are no tags, scheduling or templates. These headers would travel with the message as ordinary headers: remove them. |
Register the webhook and swap the verification
Create the endpoint in Postal and keep the signing_secret. The signature changes completely: in Mailgun it comes in the body and uses a webhook signing key; in Postal it comes in headers and uses each endpoint's secret. Calculate it over the body exactly as it arrived, without parsing and rewriting the JSON.
- The
webhook-idis the same on every attempt: store the ones you have processed to discard duplicates. - Respond 2xx right away and process later, in a queue.
- To test without really sending, send emails to the test addresses or use "Send test event" in the dashboard.
| Event in Mailgun | Postal event | Note |
|---|---|---|
accepted | email.sent | The closest, but not identical: Postal notifies you when it has handed the email to the sending provider. |
delivered | email.delivered | The recipient's server accepted the message. |
temporary_fail | email.delivery_delayed | Delivery was delayed and new attempts happen automatically. |
permanent_fail | email.bounced | In data.bounce.type, Permanent is the bounce that should not be retried. The address goes on the suppression list. |
opened | email.opened | Requires open tracking to be turned on for the domain. |
clicked | email.clicked | Requires click tracking to be turned on for the domain. |
complained | email.complained | The address goes on the suppression list. |
unsubscribed | contact.updated | Applies to campaigns: the unsubscribed contact triggers contact.updated. The email.unsubscribed event exists only in the email history, not as a webhook. |
| Mailgun | Postal | |
|---|---|---|
| Where you register it | On the account or the domain | On the team, with POST /webhooks: HTTPS URL and the list of events. The documentation does not provide filtering by domain or tag: filter in your code. |
| Format | JSON with signature and event-data | JSON with type, created_at and data |
| Signature | HMAC-SHA256 in hexadecimal of timestamp + token, with the webhook signing key. All three fields come inside the body. | Standard Webhooks pattern: webhook-id, webhook-timestamp and webhook-signature headers. HMAC-SHA256 in Base64 of id.timestamp.corpo (the ID, the timestamp and the raw body), with each endpoint's whsec_…. |
| Which email it was | event-data.message.headers.message-id | data.email_id, the same id returned on send |
| Recipient | event-data.recipient | data.to, a list |
| Tags | event-data.tags, a list of strings | data.tags, a name → value object |
v: data | event-data.user-variables | Does not exist. Use tags. |
| Failure reason | reason, severity and delivery-status | data.bounce (type, subType, message, diagnosticCode) and data.failed.reason |
| Expected response | 200. A 406 response rejects the event with no retry. | Any 2xx within 15 seconds. Any other response counts as a failure and triggers a retry; there is no code that discards the event. |
| Retries | For about 8 hours (5, 10 and 15 minutes, then 1, 2 and 4 hours) | 8 attempts over about 27 hours. Resend from the dashboard or with POST /webhooks/{id}/events/{event_id}/replay. |
- const expected = crypto.createHmac('sha256', process.env.MAILGUN_SIGNING_KEY)- .update(timestamp + token).digest('hex');- if (expected !== signature) return res.sendStatus(401);+ import { verifyWebhook } from 'postal-so';+ const event = verifyWebhook(req.body, req.headers, process.env.POSTAL_WEBHOOK_SECRET);{
"type": "email.delivered",
"created_at": "2026-10-24T13:02:14.120Z",
"data": {
"email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"from": "Aurora Cosméticos <[email protected]>",
"to": ["[email protected]"],
"subject": "Your order #10482 is out for delivery",
"tags": { "category": "shipping" }
}
}Bring over suppressions, unsubscribes and lists
Do this before the first real send, so you do not write to people who already opted out or repeat bounces. Postal pauses the team's sending if permanent bounces go over the limit of the account reputation level (3% for a new account) or complaints go over 0.3%; that is why a clean list matters.
| In Mailgun | On Postal | How to bring it over |
|---|---|---|
Bounces (/v3/{domínio}/bounces) | Suppression list | POST /suppressions/batch/add with emails, 100 per call. |
Complaints (/v3/{domínio}/complaints) | Suppression list | The same endpoint. Postal automatically records the bounce and complaint origins for the sends it makes itself; whatever you add comes in as manual. |
Unsubscribes (/v3/{domínio}/unsubscribes) | Contacts with unsubscribed: true | Use POST /contacts/batch. Do not put these people on the suppression list: it blocks all sends, including transactional ones. |
| Bounce exception list (allowlist) | No equivalent in the documentation | If you depend on it, talk to us before migrating. |
| One list per domain | One list per team | A suppressed address stays blocked on all of the team's domains. |
| Remove an address | DELETE /suppressions/{id} or POST /suppressions/batch/remove | The {id} accepts the ID or the email itself. |
| Mailgun mailing list | On Postal | How to bring it over |
|---|---|---|
The list is an address (lista@dominio) and every message sent to it goes to the subscribed members | Contacts, segments and topics. Sending to many people is a campaign, not an address. | Create a static segment and send a campaign to it. There is no "send to the list address". |
Member: address | Contact: email | In Postal the email is stored in lowercase and appears once per team. |
Member: name | first_name and last_name | Split the name before importing. |
Member: subscribed: false | unsubscribed: true | Anyone who left the list stays unsubscribed. |
Member: vars (free-form JSON) | properties | Each property must be created beforehand and has a type. |
| Up to 1,000 members per call (JSON) | Up to 10,000 contacts in POST /contacts/batch | Up to 1,000 are processed immediately. Above that, the batch goes to the queue and the response carries the batch ID. |
Switch traffic gradually
The IDs are different: a Mailgun email ID does not exist in Postal. During the cutover, record which provider each email came from and keep receiving Mailgun webhooks for what went out through it. Send each flow through a single provider, so you do not duplicate sends or pause everything because of the wrong emails.
Before you flip the key
- Domain verified in Postal, with the old records still in place.
- Send-only key, restricted to the domain, kept out of the code and the repository.
- Bounces and complaints imported into the suppression list.
- Unsubscribes imported as contacts with
unsubscribed: true(if you do marketing). - Templates recreated with
{{{VARIÁVEL}}}, published, and the logic moved into code. - Tags renamed to letters, numbers,
_and-. - Webhook created, signature checked against the raw body and a 2xx response within 15 seconds.
- Tests done with
[email protected]and[email protected]. - One flow at a time, watching bounces in the first few days.
Common questions
Can I keep Mailgun while I migrate?
Yes, and it is the safest way. Use both accounts side by side, with the DNS records for both, and switch one flow at a time. Only remove the Mailgun records when nothing is going out through it anymore.
Does Mailgun's event history come along?
No. Postal does not import emails or events from another platform, and it keeps yours for 30 days. Export whatever you need before turning Mailgun off.
I have several domains in Mailgun. How does that work?
Add each domain in Postal, with the region for each one (the number of domains depends on the plan). The suppression list and webhooks belong to the team, not the domain. To separate access, create one key per domain.
Migrate to Postal
Other guides
Each platform has its own differences. Pick the guide for the one you use today.
Migrating from Mailgun?
Create your account and start on the Free plan. To plan quota, warm-up and the order of the migration, talk to us through the contact form with your domain and daily volume.