New: campaigns, automations and product emails in the same account. Meet Postal
Sign in Create account

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.

MailgunPOST api.mailgun.net/v3/{domínio}/messagesform-data · user api
PostalPOST api.postal.so/emailsJSON · Authorization: Bearer

Only 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.

1

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.
2

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 · before and after
- 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"}]+   }'
Node.js · before and after
- 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.

Send fields: what each one becomes.
MailgunOn PostalWhat changes
URL and authenticationhttps://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.
fromfromSame format (Nome <email@dominio>). The domain must be verified and have sending enabled.
totoText or list, up to 50 addresses. For more people, send one item per person to POST /emails/batch (up to 100 per call).
cc, bcccc, bccText or list.
subject, text, htmlsubject, text, htmlSame. Without a template, Postal requires subject and at least html or text; it recommends sending both.
attachmentattachmentsThere 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.
inlineattachments with content_idIn 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:deliverytimescheduled_atISO 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-clicksopen_tracking and click_tracking on the domainIn 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:testmodeTest addressesInstead 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-tlstls: "enforced" on the domainAlso a domain setting, not a message one.
h:X-Meu-CabecalhoheadersAn object: {"X-Pedido": "10482"}. You cannot override From, To, Subject, Message-ID and other system headers.
v:minha-variavelNo matching fieldMailgun 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:variablestemplate with id and variablesid accepts the ID or the alias. See the templates step.
t:versionNo matching fieldPostal always uses the latest published version of the template.
recipient-variablesOne item per person in the batchEach person becomes an object in POST /emails/batch, with its own template.variables.
Size: 25 MB per messageAttachments: 40 MB in totalMailgun limits the whole message; Postal limits the attachments. Your plan may allow less than 40 MB.
3

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.
Template variables
- <h1>Hello, {{name}}!</h1>- <p>Your order: {{order}}</p>+ <h1>Hello, {{{NAME}}}!</h1>+ <p>Your order: {{{ORDER}}}</p>
Sending with a template
- -F template='order-shipped' \- -F t:variables='{"name": "Ana", "order": "10482"}'+ "template": {+   "id": "order-shipped",+   "variables": {"NAME": "Ana", "ORDER": "10482"}+ }
4

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.
Nodemailer · before and after
- 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 },
MailgunPostal
Serversmtp.mailgun.org (EU region: smtp.eu.mailgun.org)smtp.postal.so
Ports25, 587 and 2525 with STARTTLS; 465 with TLS465 with TLS (recommended) or 587 with STARTTLS. If your network blocks them, 2465 (TLS) or 2587 (STARTTLS).
UsernameThe domain's SMTP login, a full addresspostal
PasswordThe SMTP password created for the domainAn API key (ps_…). A send-only key is enough.
Options via headerX-Mailgun-Tag, X-Mailgun-Variables, X-Mailgun-Track and X-Mailgun-Deliver-ByOver SMTP there are no tags, scheduling or templates. These headers would travel with the message as ordinary headers: remove them.
5

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-id is 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.
Email events. Postal also has email.failed, email.suppressed, email.replied, email.received and others.
Event in MailgunPostal eventNote
acceptedemail.sentThe closest, but not identical: Postal notifies you when it has handed the email to the sending provider.
deliveredemail.deliveredThe recipient's server accepted the message.
temporary_failemail.delivery_delayedDelivery was delayed and new attempts happen automatically.
permanent_failemail.bouncedIn data.bounce.type, Permanent is the bounce that should not be retried. The address goes on the suppression list.
openedemail.openedRequires open tracking to be turned on for the domain.
clickedemail.clickedRequires click tracking to be turned on for the domain.
complainedemail.complainedThe address goes on the suppression list.
unsubscribedcontact.updatedApplies to campaigns: the unsubscribed contact triggers contact.updated. The email.unsubscribed event exists only in the email history, not as a webhook.
MailgunPostal
Where you register itOn the account or the domainOn 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.
FormatJSON with signature and event-dataJSON with type, created_at and data
SignatureHMAC-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 wasevent-data.message.headers.message-iddata.email_id, the same id returned on send
Recipientevent-data.recipientdata.to, a list
Tagsevent-data.tags, a list of stringsdata.tags, a name → value object
v: dataevent-data.user-variablesDoes not exist. Use tags.
Failure reasonreason, severity and delivery-statusdata.bounce (type, subType, message, diagnosticCode) and data.failed.reason
Expected response200. 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.
RetriesFor 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.
Verification · before and after
- 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);
email.delivered · what reaches your server
{
  "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" }
  }
}
200Respond 2xx within 15 s
6

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 MailgunOn PostalHow to bring it over
Bounces (/v3/{domínio}/bounces)Suppression listPOST /suppressions/batch/add with emails, 100 per call.
Complaints (/v3/{domínio}/complaints)Suppression listThe 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: trueUse 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 documentationIf you depend on it, talk to us before migrating.
One list per domainOne list per teamA suppressed address stays blocked on all of the team's domains.
Remove an addressDELETE /suppressions/{id} or POST /suppressions/batch/removeThe {id} accepts the ID or the email itself.
Mailgun mailing listOn PostalHow to bring it over
The list is an address (lista@dominio) and every message sent to it goes to the subscribed membersContacts, 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: addressContact: emailIn Postal the email is stored in lowercase and appears once per team.
Member: namefirst_name and last_nameSplit the name before importing.
Member: subscribed: falseunsubscribed: trueAnyone who left the list stays unsubscribed.
Member: vars (free-form JSON)propertiesEach 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/batchUp to 1,000 are processed immediately. Above that, the batch goes to the queue and the response carries the batch ID.
7

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.

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.