Do Mailgun para a Postal, campo a campo
A API da Postal não é igual à do Mailgun: o envio troca form-data por JSON, a chave vai em outro cabeçalho e os webhooks têm outro formato. Este guia mostra o que tem correspondente, o que muda de lugar e o que fica sem par.
- Formato do envio
- Form-data para JSON
- Autenticação
- Basic para Bearer
- Como virar o tráfego
- Em etapas
Visão geral
No Mailgun você envia com POST /v3/{domínio}/messages, em form-data, com o usuário api e a sua chave. Na Postal o envio é POST /emails, em JSON, com Authorization: Bearer ps_…. A maior parte do que você usa tem correspondente. O resto exige uma decisão sua antes da virada, e este guia aponta onde.
POST api.mailgun.net/v3/{domínio}/messagesform-data · usuário apiPOST api.postal.so/emailsJSON · Authorization: BearerMuda só o formato
Remetente, destinatários, assunto, HTML, texto, anexos, cabeçalhos, tags, agendamento e SMTP têm correspondente. O que muda é o jeito de mandar: JSON no lugar de form-data e uma chave no cabeçalho Authorization.
Muda de lugar
Rastreamento, TLS obrigatório e região deixam de ser escolha de cada mensagem e passam a ser do domínio. Os descadastros do Mailgun passam a ser contatos descadastrados.
Não tem equivalente
Dados livres v: nos eventos, condições e laços nos templates, escolha da versão do template no envio, lista de exceções de bounces e enviar para o endereço de uma lista.
A Postal não imita a API do Mailgun. A única API com modo de compatibilidade é a da Resend: se você já usa o SDK dela, veja o guia da Resend. Para o Mailgun, a troca é no código, com fetch ou com os SDKs oficiais da Postal para Node.js e PHP.
Cadastre o domínio e crie uma chave
Adicione o domínio no painel (ou com POST /domains), crie na sua DNS os registros que a Postal mostra e peça a verificação. Os registros usam nomes próprios, postal._domainkey e o subdomínio postal, então dá para publicá-los ao lado dos do Mailgun e só tirar os antigos depois da virada. Antes, confira que nenhum registro seu já usa esses nomes.
- Região: o Mailgun tem duas URLs, US e EU. Na Postal a URL é uma só e a região é escolhida por domínio: São Paulo (padrão), us-east-1, us-east-2, eu-west-1 ou ap-northeast-1.
- Para sistemas que só enviam, crie uma chave só de envio, restrita ao domínio. O token completo aparece uma única vez, na criação.
- Se o domínio principal já envia pelo Mailgun, use um subdomínio dedicado na Postal.
Troque a chamada de envio
No Mailgun o domínio vai na URL e os campos vão em form-data. Na Postal o domínio vem do from e o corpo é JSON. A resposta é {"id": "…"}: guarde o ID, que volta nos webhooks como data.email_id e serve para consultar o e-mail em GET /emails/{id}. Com o cabeçalho Idempotency-Key, repetir uma requisição não duplica o envio.
- 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='Seu pedido #10482 saiu para entrega' \- --form-string html='<p>O pedido <b>#10482</b> está a caminho.</p>' \- -F o:tag='envio'+ 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": "Seu pedido #10482 saiu para entrega",+ "html": "<p>O pedido <b>#10482</b> está a caminho.</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', 'Seu pedido #10482 saiu para entrega');- form.append('html', '<p>O pedido <b>#10482</b> está a caminho.</p>');- form.append('o:tag', 'envio');- 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: 'Seu pedido #10482 saiu para entrega',+ html: '<p>O pedido <b>#10482</b> está a caminho.</p>',+ tags: [{ name: 'category', value: 'shipping' }],+ }),+ });+ const { id } = await response.json();Campo a campo
Tudo o que o envio do Mailgun aceita e o que acontece com cada parte na Postal.
| Mailgun | Na Postal | O que muda |
|---|---|---|
| URL e autenticação | https://api.postal.so e Authorization: Bearer ps_… | No Mailgun: https://api.mailgun.net/v3/{domínio}/messages (na região EU, api.eu.mailgun.net) e usuário api com a chave. Na Postal a URL é uma só e o domínio vem do from. |
from | from | Mesmo formato (Nome <email@dominio>). O domínio precisa estar verificado e com envio ativo. |
to | to | Texto ou lista, até 50 endereços. Para mais gente, mande um item por pessoa em POST /emails/batch (até 100 por chamada). |
cc, bcc | cc, bcc | Texto ou lista. |
subject, text, html | subject, text, html | Iguais. Sem template, a Postal exige subject e ao menos html ou text; recomenda mandar os dois. |
attachment | attachments | Não há envio de arquivo em multipart. Mande filename e content em Base64, ou path com uma URL pública. Até 40 MB no total, e anexos não valem no lote. |
inline | attachments com content_id | No HTML use <img src="cid:logo"> e, no anexo, "content_id": "logo". |
o:tag (repetível, texto livre) | tags: lista de {name, value} | Nome e valor só aceitam letras ASCII, números, _ e -, até 256 caracteres. "September newsletter" vira september-newsletter. Até 50 por e-mail. |
o:deliverytime | scheduled_at | Data em ISO 8601, no futuro e a até 30 dias. Depende do plano e não vale no lote. Dá para reagendar (PATCH /emails/{id}) e cancelar (POST /emails/{id}/cancel). |
o:tracking, o:tracking-opens, o:tracking-clicks | open_tracking e click_tracking do domínio | No Mailgun dá para ligar ou desligar em cada mensagem. Na Postal a escolha é do domínio e não existe campo no envio. Depende do plano. |
o:testmode | Endereços de teste | Em vez de uma marca no envio, você manda para [email protected] (e bounced@, complained@, suppressed@, delayed@). Não usa cota e gera webhooks reais. Depende do plano. |
o:require-tls | tls: "enforced" do domínio | Também é do domínio, não da mensagem. |
h:X-Meu-Cabecalho | headers | Um objeto: {"X-Pedido": "10482"}. Não dá para sobrescrever From, To, Subject, Message-ID e outros cabeçalhos de sistema. |
v:minha-variavel | Sem campo igual | O Mailgun devolve esses dados livres nos eventos (user-variables). Na Postal, use tags para valores curtos (voltam em data.tags) e guarde o resto no seu banco, ligado ao id do e-mail. |
template e t:variables | template com id e variables | id aceita o ID ou o apelido (alias). Veja o passo de templates. |
t:version | Sem campo igual | A Postal usa sempre a última versão publicada do template. |
recipient-variables | Um item por pessoa no lote | Cada pessoa vira um objeto em POST /emails/batch, com o próprio template.variables. |
| Tamanho: 25 MB por mensagem | Anexos: 40 MB no total | O Mailgun limita a mensagem inteira; a Postal limita os anexos. O seu plano pode permitir menos que 40 MB. |
Recrie os templates
Os templates do Mailgun usam uma versão de Handlebars, com condições (if, unless), laços (each), with e equal. Os da Postal só substituem variáveis: {{{NOME}}}, com três chaves, e o valor precisa ser texto ou número. Não há condições, laços nem objetos aninhados. Quando o seu template depende de lógica, mova a lógica para o código: escolha entre dois templates ou monte o HTML você mesmo e mande em html.
- Na Postal você edita um rascunho e publica; os envios usam sempre a última versão publicada, e as versões antigas podem ser restauradas.
- No HTML os valores são escapados. Uma variável sem valor e sem valor padrão (
fallback_value) devolve erro 422. - Declare as variáveis ao criar o template: até 50, com tipo texto ou número.
- <h1>Olá, {{name}}!</h1>- <p>Seu pedido: {{order}}</p>+ <h1>Olá, {{{NAME}}}!</h1>+ <p>Seu pedido: {{{ORDER}}}</p>- -F template='pedido-enviado' \- -F t:variables='{"name": "Ana", "order": "10482"}'+ "template": {+ "id": "pedido-enviado",+ "variables": {"NAME": "Ana", "ORDER": "10482"}+ }Aponte o SMTP para a Postal
Se algum sistema envia por SMTP, troque servidor, usuário e senha. O SMTP faz parte do plano. As mensagens passam pelas mesmas regras da API (domínio verificado, cotas, lista de supressão) e aparecem em E-mails e Logs.
- Limites da Postal por mensagem: 40 MB e 50 destinatários. Quem passar disso recebe erro; divida o envio.
- 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 | |
|---|---|---|
| Servidor | smtp.mailgun.org (região EU: smtp.eu.mailgun.org) | smtp.postal.so |
| Portas | 25, 587 e 2525 com STARTTLS; 465 com TLS | 465 com TLS (recomendada) ou 587 com STARTTLS. Se a rede bloquear, 2465 (TLS) ou 2587 (STARTTLS). |
| Usuário | O login SMTP do domínio, um endereço completo | postal |
| Senha | A senha SMTP criada para o domínio | Uma chave de API (ps_…). Uma chave só de envio basta. |
| Opções por cabeçalho | X-Mailgun-Tag, X-Mailgun-Variables, X-Mailgun-Track e X-Mailgun-Deliver-By | Pelo SMTP não há tags, agendamento nem templates. Esses cabeçalhos seguiriam na mensagem como cabeçalhos comuns: tire-os. |
Cadastre o webhook e troque a verificação
Crie o endpoint na Postal e guarde o signing_secret. A assinatura muda por completo: no Mailgun ela vem no corpo e usa uma chave de assinatura de webhooks; na Postal vem em cabeçalhos e usa o segredo de cada endpoint. Calcule sobre o corpo exatamente como chegou, sem ler e reescrever o JSON.
- O
webhook-idé o mesmo em todas as tentativas: guarde os já processados para descartar duplicatas. - Responda 2xx logo e processe depois, numa fila.
- Para testar sem enviar de verdade, mande e-mails para os endereços de teste ou use "Enviar evento de teste" no painel.
| Evento no Mailgun | Evento na Postal | Observação |
|---|---|---|
accepted | email.sent | O mais próximo, mas não idêntico: a Postal avisa quando entregou o e-mail ao provedor de envio. |
delivered | email.delivered | O servidor do destinatário aceitou a mensagem. |
temporary_fail | email.delivery_delayed | A entrega foi adiada e novas tentativas acontecem sozinhas. |
permanent_fail | email.bounced | Em data.bounce.type, Permanent é o bounce que não deve ser repetido. O endereço entra na lista de supressão. |
opened | email.opened | Precisa do rastreamento de aberturas ligado no domínio. |
clicked | email.clicked | Precisa do rastreamento de cliques ligado no domínio. |
complained | email.complained | O endereço entra na lista de supressão. |
unsubscribed | contact.updated | Vale para campanhas: o contato descadastrado dispara contact.updated. O evento email.unsubscribed existe só no histórico do e-mail, não como webhook. |
| Mailgun | Postal | |
|---|---|---|
| Onde se cadastra | Na conta ou no domínio | No time, com POST /webhooks: URL HTTPS e a lista de eventos. A documentação não prevê filtro por domínio nem por tag: filtre no seu código. |
| Formato | JSON com signature e event-data | JSON com type, created_at e data |
| Assinatura | HMAC-SHA256 em hexadecimal de timestamp + token, com a chave de assinatura de webhooks. Os três campos vêm dentro do corpo. | Padrão Standard Webhooks: cabeçalhos webhook-id, webhook-timestamp e webhook-signature. HMAC-SHA256 em Base64 de id.timestamp.corpo, com o whsec_… de cada endpoint. |
| Qual e-mail foi | event-data.message.headers.message-id | data.email_id, o mesmo id devolvido no envio |
| Destinatário | event-data.recipient | data.to, uma lista |
| Tags | event-data.tags, uma lista de textos | data.tags, um objeto nome → valor |
Dados v: | event-data.user-variables | Não existe. Use as tags. |
| Motivo da falha | reason, severity e delivery-status | data.bounce (type, subType, message, diagnosticCode) e data.failed.reason |
| Resposta esperada | 200. A resposta 406 recusa o evento sem nova tentativa. | Qualquer 2xx em até 15 segundos. Toda outra resposta conta como falha e gera nova tentativa; não há código que descarte o evento. |
| Novas tentativas | Por cerca de 8 horas (5, 10 e 15 minutos, depois 1, 2 e 4 horas) | 8 tentativas em cerca de 27 horas. Reenvio pelo painel ou por 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": "Seu pedido #10482 saiu para entrega",
"tags": { "category": "shipping" }
}
}Leve a supressão, os descadastros e as listas
Faça isso antes do primeiro envio real, para não escrever a quem já pediu para sair nem repetir bounces. A Postal pausa os envios do time se os bounces permanentes passam do limite do nível da reputação da conta (3% numa conta nova) ou as reclamações passam de 0,3%; por isso a lista limpa importa.
| No Mailgun | Na Postal | Como levar |
|---|---|---|
Bounces (/v3/{domínio}/bounces) | Lista de supressão | POST /suppressions/batch/add com emails, 100 por chamada. |
Reclamações (/v3/{domínio}/complaints) | Lista de supressão | O mesmo endpoint. A Postal registra sozinha as origens bounce e complaint dos envios que ela mesma faz; o que você adiciona entra como manual. |
Descadastros (/v3/{domínio}/unsubscribes) | Contatos com unsubscribed: true | Use POST /contacts/batch. Não ponha essas pessoas na lista de supressão: ela bloqueia todos os envios, inclusive os transacionais. |
| Lista de exceções (allowlist) de bounces | Sem equivalente na documentação | Se você depende dela, fale com a gente antes de migrar. |
| Uma lista por domínio | Uma lista por time | Um endereço suprimido fica bloqueado em todos os domínios do time. |
| Remover um endereço | DELETE /suppressions/{id} ou POST /suppressions/batch/remove | O {id} aceita o ID ou o próprio e-mail. |
| Lista de envio do Mailgun | Na Postal | Como levar |
|---|---|---|
A lista é um endereço (lista@dominio) e cada mensagem enviada a ele vai para os membros inscritos | Contatos, segmentos e tópicos. O envio para muita gente é uma campanha, não um endereço. | Crie um segmento estático e envie uma campanha para ele. Não existe "enviar para o endereço da lista". |
Membro: address | Contato: email | Na Postal o e-mail é guardado em minúsculas e aparece uma vez por time. |
Membro: name | first_name e last_name | Separe o nome antes de importar. |
Membro: subscribed: false | unsubscribed: true | Quem saiu da lista continua descadastrado. |
Membro: vars (JSON livre) | properties | Cada propriedade precisa ser criada antes e tem um tipo. |
| Até 1.000 membros por chamada (JSON) | Até 10.000 contatos em POST /contacts/batch | Até 1.000 são processados na hora. Acima disso o lote vai para a fila e a resposta traz o ID do lote. |
Vire o tráfego aos poucos
Os IDs são diferentes: o ID de um e-mail do Mailgun não existe na Postal. Durante a virada, guarde de qual provedor veio cada e-mail e continue recebendo os webhooks do Mailgun para o que saiu por lá. Mande cada fluxo por um provedor só, para não duplicar nem pausar tudo por causa de e-mails errados.
Antes de virar a chave
- Domínio verificado na Postal, com os registros antigos ainda no lugar.
- Chave só de envio, restrita ao domínio, fora do código e do repositório.
- Bounces e reclamações importados para a lista de supressão.
- Descadastros importados como contatos com
unsubscribed: true(se você faz marketing). - Templates recriados com
{{{VARIÁVEL}}}, publicados, e a lógica movida para o código. - Tags renomeadas para letras, números,
_e-. - Webhook criado, assinatura conferida com o corpo cru e resposta 2xx em até 15 segundos.
- Testes feitos com
[email protected]e[email protected]. - Um fluxo por vez, olhando os bounces nos primeiros dias.
Perguntas comuns
Posso manter o Mailgun enquanto migro?
Pode, e é o mais seguro. Use as duas contas lado a lado, com os registros DNS das duas, e vire um fluxo de cada vez. Só tire os registros do Mailgun quando nada mais sair por lá.
O histórico de eventos do Mailgun vem junto?
Não. A Postal não importa e-mails nem eventos de outra plataforma, e guarda os seus por 30 dias. Exporte o que precisar antes de desligar o Mailgun.
Tenho vários domínios no Mailgun. Como fica?
Cadastre cada domínio na Postal, com a região de cada um (o número de domínios depende do plano). A lista de supressão e os webhooks são do time, não do domínio. Para separar o acesso, crie uma chave por domínio.
Migrar para a Postal
Outros guias
Cada plataforma tem as suas diferenças. Escolha o guia da que você usa hoje.
Vai migrar do Mailgun?
Crie a sua conta e comece no plano Grátis. Para planejar a cota, o aquecimento e a ordem da migração, fale com a gente pelo formulário de contato com o domínio e o volume diário.