Novedad: campañas, automatizaciones y correos del producto en la misma cuenta. Conoce Postal
Iniciar sesión Crear cuenta

De Mailgun a Postal, campo a campo

La API de Postal no es igual a la de Mailgun: el envío cambia form-data por JSON, la clave va en otro encabezado y los webhooks tienen otro formato. Esta guía muestra lo que tiene correspondiente, lo que cambia de lugar y lo que queda sin par.

Formato del envío
Form-data a JSON
Autenticación
Basic a Bearer
Cómo cambiar el tráfico
Por etapas

Visión general

En Mailgun envías con POST /v3/{domínio}/messages, en form-data, con el usuario api y tu clave. En Postal el envío es POST /emails, en JSON, con Authorization: Bearer ps_…. La mayor parte de lo que usas tiene correspondiente. Lo demás exige una decisión tuya antes del cambio, y esta guía señala dónde.

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

Cambia solo el formato

Remitente, destinatarios, asunto, HTML, texto, adjuntos, encabezados, tags, programación y SMTP tienen correspondiente. Lo que cambia es la forma de enviar: JSON en lugar de form-data y una clave en el encabezado Authorization.

Cambia de lugar

El seguimiento, el TLS obligatorio y la región dejan de elegirse en cada mensaje y pasan a ser del dominio. Las bajas de Mailgun pasan a ser contactos dados de baja.

No tiene equivalente

Datos libres v: en los eventos, condiciones y bucles en las plantillas, elección de la versión de la plantilla al enviar, lista de excepciones de rebotes y envío a la dirección de una lista.

Postal no imita la API de Mailgun. La única API con modo de compatibilidad es la de Resend: si ya usas su SDK, consulta la guía de Resend. Para Mailgun, el cambio se hace en el código, con fetch o con los SDK oficiales de Postal para Node.js y PHP.

1

Registra el dominio y crea una clave

Añade el dominio en el panel (o con POST /domains), crea en tu DNS los registros que muestra Postal y solicita la verificación. Los registros usan nombres propios, postal._domainkey y el subdominio postal, así que puedes publicarlos junto a los de Mailgun y quitar los antiguos solo después del cambio. Antes, comprueba que ninguno de tus registros use ya esos nombres.

  • Región: Mailgun tiene dos URL, US y EU. En Postal la URL es una sola y la región se elige por dominio: São Paulo (predeterminada), us-east-1, us-east-2, eu-west-1 o ap-northeast-1.
  • Para sistemas que solo envían, crea una clave solo de envío, restringida al dominio. El token completo aparece una única vez, al crearla.
  • Si el dominio principal ya envía por Mailgun, usa un subdominio dedicado en Postal.
2

Cambia la llamada de envío

En Mailgun el dominio va en la URL y los campos van en form-data. En Postal el dominio sale del from y el cuerpo es JSON. La respuesta es {"id": "…"}: guarda el ID, que vuelve en los webhooks como data.email_id y sirve para consultar el correo electrónico en GET /emails/{id}. Con el encabezado Idempotency-Key, repetir una solicitud no duplica el envío.

curl · antes y después
- 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='Tu pedido #10482 salió para la entrega' \-   --form-string html='<p>El pedido <b>#10482</b> está en camino.</p>' \-   -F o:tag='envío'+ 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": "Tu pedido #10482 salió para la entrega",+     "html": "<p>El pedido <b>#10482</b> está en camino.</p>",+     "tags": [{"name": "category", "value": "shipping"}]+   }'
Node.js · antes y después
- const form = new FormData();- form.append('from', 'Aurora Cosméticos <[email protected]>');- form.append('to', '[email protected]');- form.append('subject', 'Tu pedido #10482 salió para la entrega');- form.append('html', '<p>El pedido <b>#10482</b> está en camino.</p>');- form.append('o:tag', 'envío');- 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: 'Tu pedido #10482 salió para la entrega',+     html: '<p>El pedido <b>#10482</b> está en camino.</p>',+     tags: [{ name: 'category', value: 'shipping' }],+   }),+ });+ const { id } = await response.json();

Campo a campo

Todo lo que acepta el envío de Mailgun y qué ocurre con cada parte en Postal.

Campos del envío: en qué se convierte cada uno.
MailgunEn PostalQué cambia
URL y autenticaciónhttps://api.postal.so y Authorization: Bearer ps_…En Mailgun: https://api.mailgun.net/v3/{domínio}/messages (en la región EU, api.eu.mailgun.net) y usuario api con la clave. En Postal la URL es una sola y el dominio sale del from.
fromfromMismo formato (Nome <email@dominio>). El dominio debe estar verificado y con envío activo.
totoTexto o lista, hasta 50 direcciones. Para más personas, envía un elemento por persona en POST /emails/batch (hasta 100 por llamada).
cc, bcccc, bccTexto o lista.
subject, text, htmlsubject, text, htmlIguales. Sin plantilla, Postal exige subject y al menos html o text; recomienda enviar ambos.
attachmentattachmentsNo hay envío de archivos en multipart. Envía filename y content en Base64, o path con una URL pública. Hasta 40 MB en total, y los adjuntos no valen en el lote.
inlineattachments con content_idEn el HTML usa <img src="cid:logo"> y, en el adjunto, "content_id": "logo".
o:tag (repetible, texto libre)tags: lista de {name, value}El nombre y el valor solo aceptan letras ASCII, números, _ y -, hasta 256 caracteres. "September newsletter" se convierte en september-newsletter. Hasta 50 por correo electrónico.
o:deliverytimescheduled_atFecha en ISO 8601, en el futuro y dentro de los próximos 30 días. Depende del plan y no vale en el lote. Se puede reprogramar (PATCH /emails/{id}) y cancelar (POST /emails/{id}/cancel).
o:tracking, o:tracking-opens, o:tracking-clicksopen_tracking y click_tracking del dominioEn Mailgun se puede activar o desactivar en cada mensaje. En Postal la decisión es del dominio y no existe un campo en el envío. Depende del plan.
o:testmodeDirecciones de pruebaEn lugar de una marca en el envío, mandas a [email protected] (y bounced@, complained@, suppressed@, delayed@). No consume cuota y genera webhooks reales. Depende del plan.
o:require-tlstls: "enforced" del dominioTambién es del dominio, no del mensaje.
h:X-Meu-CabecalhoheadersUn objeto: {"X-Pedido": "10482"}. No se pueden sobrescribir From, To, Subject, Message-ID ni otros encabezados del sistema.
v:minha-variavelSin campo equivalenteMailgun devuelve esos datos libres en los eventos (user-variables). En Postal, usa tags para valores cortos (vuelven en data.tags) y guarda el resto en tu base de datos, vinculado al id del correo electrónico.
template y t:variablestemplate con id y variablesid acepta el ID o el alias. Consulta el paso de plantillas.
t:versionSin campo equivalentePostal usa siempre la última versión publicada de la plantilla.
recipient-variablesUn elemento por persona en el loteCada persona se convierte en un objeto en POST /emails/batch, con su propio template.variables.
Tamaño: 25 MB por mensajeAdjuntos: 40 MB en totalMailgun limita el mensaje completo; Postal limita los adjuntos. Tu plan puede permitir menos de 40 MB.
3

Recrea las plantillas

Las plantillas de Mailgun usan una versión de Handlebars, con condiciones (if, unless), bucles (each), with y equal. Las de Postal solo sustituyen variables: {{{NOME}}}, con tres llaves, y el valor debe ser texto o número. No hay condiciones, bucles ni objetos anidados. Cuando tu plantilla depende de lógica, pásala al código: elige entre dos plantillas o arma tú mismo el HTML y envíalo en html.

  • En Postal editas un borrador y lo publicas; los envíos usan siempre la última versión publicada, y las versiones antiguas se pueden restaurar.
  • En el HTML los valores se escapan. Una variable sin valor y sin valor predeterminado (fallback_value) devuelve un error 422.
  • Declara las variables al crear la plantilla: hasta 50, de tipo texto o número.
Variables de la plantilla
- <h1>¡Hola, {{name}}!</h1>- <p>Tu pedido: {{order}}</p>+ <h1>¡Hola, {{{NAME}}}!</h1>+ <p>Tu pedido: {{{ORDER}}}</p>
Envío con plantilla
- -F template='pedido-enviado' \- -F t:variables='{"name": "Ana", "order": "10482"}'+ "template": {+   "id": "pedido-enviado",+   "variables": {"NAME": "Ana", "ORDER": "10482"}+ }
4

Apunta el SMTP a Postal

Si algún sistema envía por SMTP, cambia servidor, usuario y contraseña. El SMTP forma parte del plan. Los mensajes pasan por las mismas reglas de la API (dominio verificado, cuotas, lista de supresión) y aparecen en Correos y Registros.

  • Límites de Postal por mensaje: 40 MB y 50 destinatarios. Quien los supere recibe un error; divide el envío.
Nodemailer · antes y después
- 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
Servidorsmtp.mailgun.org (región EU: smtp.eu.mailgun.org)smtp.postal.so
Puertos25, 587 y 2525 con STARTTLS; 465 con TLS465 con TLS (recomendado) o 587 con STARTTLS. Si la red lo bloquea, 2465 (TLS) o 2587 (STARTTLS).
UsuarioEl inicio de sesión SMTP del dominio, una dirección completapostal
ContraseñaLa contraseña SMTP creada para el dominioUna clave de API (ps_…). Basta una clave solo de envío.
Opciones por encabezadoX-Mailgun-Tag, X-Mailgun-Variables, X-Mailgun-Track y X-Mailgun-Deliver-ByPor SMTP no hay etiquetas, programación ni plantillas. Esos encabezados viajarían en el mensaje como encabezados comunes: quítalos.
5

Registra el webhook y cambia la verificación

Crea el endpoint en Postal y guarda el signing_secret. La firma cambia por completo: en Mailgun viene en el cuerpo y usa una clave de firma de webhooks; en Postal viene en encabezados y usa el secreto de cada endpoint. Calcúlala sobre el cuerpo exactamente como llegó, sin leer y reescribir el JSON.

  • El webhook-id es el mismo en todos los intentos: guarda los ya procesados para descartar duplicados.
  • Responde 2xx enseguida y procesa después, en una cola.
  • Para probar sin enviar de verdad, envía correos electrónicos a las direcciones de prueba o usa "Enviar evento de prueba" en el panel.
Eventos de correo. Postal también tiene email.failed, email.suppressed, email.replied, email.received y otros.
Evento en MailgunEvento en PostalObservación
acceptedemail.sentLo más cercano, pero no idéntico: Postal avisa cuando entregó el correo electrónico al proveedor de envío.
deliveredemail.deliveredEl servidor del destinatario aceptó el mensaje.
temporary_failemail.delivery_delayedLa entrega se aplazó y los nuevos intentos ocurren solos.
permanent_failemail.bouncedEn data.bounce.type, Permanent es el rebote que no debe repetirse. La dirección entra en la lista de supresión.
openedemail.openedRequiere el seguimiento de aperturas activado en el dominio.
clickedemail.clickedRequiere el seguimiento de clics activado en el dominio.
complainedemail.complainedLa dirección entra en la lista de supresión.
unsubscribedcontact.updatedVale para campañas: el contacto dado de baja dispara contact.updated. El evento email.unsubscribed existe solo en el historial del correo electrónico, no como webhook.
MailgunPostal
Dónde se registraEn la cuenta o en el dominioEn el equipo, con POST /webhooks: URL HTTPS y la lista de eventos. La documentación no prevé filtro por dominio ni por etiqueta: filtra en tu código.
FormatoJSON con signature y event-dataJSON con type, created_at y data
FirmaHMAC-SHA256 en hexadecimal de timestamp + token, con la clave de firma de webhooks. Los tres campos vienen dentro del cuerpo.Estándar Standard Webhooks: encabezados webhook-id, webhook-timestamp y webhook-signature. HMAC-SHA256 en Base64 de id.timestamp.corpo, con el whsec_… de cada endpoint.
Cuál es el correoevent-data.message.headers.message-iddata.email_id, el mismo id devuelto en el envío
Destinatarioevent-data.recipientdata.to, una lista
Etiquetasevent-data.tags, una lista de textosdata.tags, un objeto nombre → valor
Datos v:event-data.user-variablesNo existe. Usa las etiquetas.
Motivo del falloreason, severity y delivery-statusdata.bounce (type, subType, message, diagnosticCode) y data.failed.reason
Respuesta esperada200. La respuesta 406 rechaza el evento sin nuevo intento.Cualquier 2xx en un máximo de 15 segundos. Cualquier otra respuesta cuenta como fallo y genera un nuevo intento; no hay un código que descarte el evento.
Nuevos intentosDurante unas 8 horas (5, 10 y 15 minutos, después 1, 2 y 4 horas)8 intentos en unas 27 horas. Reenvío desde el panel o con POST /webhooks/{id}/events/{event_id}/replay.
Verificación · antes y después
- 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 · lo que llega a tu servidor
{
  "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": "Tu pedido #10482 salió para la entrega",
    "tags": { "category": "shipping" }
  }
}
200Responde 2xx en un máximo de 15 s
6

Lleva la supresión, las bajas y las listas

Hazlo antes del primer envío real, para no escribir a quien ya pidió la baja ni repetir rebotes. Postal pausa los envíos del equipo si los rebotes permanentes superan el límite del nivel de la reputación de la cuenta (3% en una cuenta nueva) o las quejas superan el 0,3%; por eso importa una lista limpia.

En MailgunEn PostalCómo llevarlo
Rebotes (/v3/{domínio}/bounces)Lista de supresiónPOST /suppressions/batch/add con emails, 100 por llamada.
Quejas (/v3/{domínio}/complaints)Lista de supresiónEl mismo endpoint. Postal registra por sí sola los orígenes bounce y complaint de los envíos que hace ella misma; lo que tú añades entra como manual.
Bajas (/v3/{domínio}/unsubscribes)Contactos con unsubscribed: trueUsa POST /contacts/batch. No pongas a estas personas en la lista de supresión: bloquea todos los envíos, incluidos los transaccionales.
Lista de excepciones (allowlist) de rebotesSin equivalente en la documentaciónSi dependes de ella, habla con nosotros antes de migrar.
Una lista por dominioUna lista por equipoUna dirección suprimida queda bloqueada en todos los dominios del equipo.
Quitar una direcciónDELETE /suppressions/{id} o POST /suppressions/batch/removeEl {id} acepta el ID o el propio correo electrónico.
Lista de envío de MailgunEn PostalCómo llevarlo
La lista es una dirección (lista@dominio) y cada mensaje enviado a ella llega a los miembros suscritosContactos, segmentos y temas. El envío a mucha gente es una campaña, no una dirección.Crea un segmento estático y envía una campaña a él. No existe "enviar a la dirección de la lista".
Miembro: addressContacto: emailEn Postal el correo electrónico se guarda en minúsculas y aparece una vez por equipo.
Miembro: namefirst_name y last_nameSepara el nombre antes de importar.
Miembro: subscribed: falseunsubscribed: trueQuien salió de la lista sigue dado de baja.
Miembro: vars (JSON libre)propertiesCada propiedad debe crearse antes y tiene un tipo.
Hasta 1.000 miembros por llamada (JSON)Hasta 10.000 contactos en POST /contacts/batchHasta 1.000 se procesan al instante. Por encima de eso, el lote pasa a la cola y la respuesta trae el ID del lote.
7

Cambia el tráfico poco a poco

Los ID son diferentes: el ID de un correo de Mailgun no existe en Postal. Durante el cambio, guarda de qué proveedor salió cada correo y sigue recibiendo los webhooks de Mailgun para lo que salió por allí. Envía cada flujo por un solo proveedor, para no duplicar ni pausar todo por culpa de correos equivocados.

Antes de cambiar la clave

  • Dominio verificado en Postal, con los registros antiguos todavía en su sitio.
  • Clave solo de envío, restringida al dominio, fuera del código y del repositorio.
  • Rebotes y quejas importados a la lista de supresión.
  • Bajas importadas como contactos con unsubscribed: true (si haces marketing).
  • Plantillas recreadas con {{{VARIÁVEL}}}, publicadas, y la lógica movida al código.
  • Etiquetas renombradas con letras, números, _ y -.
  • Webhook creado, firma comprobada con el cuerpo sin procesar y respuesta 2xx en un máximo de 15 segundos.
  • Pruebas hechas con [email protected] y [email protected].
  • Un flujo cada vez, vigilando los rebotes los primeros días.

Preguntas frecuentes

¿Puedo mantener Mailgun mientras migro?

Sí, y es lo más seguro. Usa las dos cuentas en paralelo, con los registros DNS de ambas, y cambia un flujo cada vez. Quita los registros de Mailgun solo cuando ya no salga nada por allí.

¿Viene incluido el historial de eventos de Mailgun?

No. Postal no importa correos ni eventos de otra plataforma, y guarda los tuyos durante 30 días. Exporta lo que necesites antes de dar de baja Mailgun.

Tengo varios dominios en Mailgun. ¿Cómo queda?

Registra cada dominio en Postal, con la región de cada uno (el número de dominios depende del plan). La lista de supresión y los webhooks son del equipo, no del dominio. Para separar el acceso, crea una clave por dominio.

¿Vas a migrar desde Mailgun?

Crea tu cuenta y empieza con el plan Gratis. Para planificar la cuota, el calentamiento y el orden de la migración, habla con nosotros por el formulario de contacto con el dominio y el volumen diario.