Migrar de SendGrid a Postal
SendGrid y Postal envían el mismo correo electrónico, pero la llamada tiene otro formato: en SendGrid, destinatarios, variables y argumentos van dentro de personalizations; en Postal, son campos del propio correo. Esta guía compara campo por campo y dice qué no tiene equivalente.
- Cambia en el código: envío y webhooks
- Reescribir
- Se pueden ejecutar las dos a la vez
- Sí
- Tiempo estimado, según lo que uses
- De horas a días
- Tipo de envío, más marketing
- API y SMTP
Verifica el dominio en Postal
Registra el dominio en el panel y crea los registros que muestra. Por sus nombres, los registros de Postal no chocan con los de SendGrid, y los dos quedan verificados al mismo tiempo durante el cambio. Si ya tienes un _dmarc, conserva el tuyo.
- Postal comprueba los registros por sí sola durante hasta 72 horas y avisa por webhook (
domain.updated) - Crea una clave de API (empieza con
ps_). Para sistemas que solo envían, usa una clave solo de envío, restringida al dominio
| Registro | SendGrid (seguridad automática) | Postal |
|---|---|---|
| DKIM | CNAME en s1._domainkey y s2._domainkey | TXT en postal._domainkey |
| Return-Path y SPF | CNAME en em y un número (por ejemplo, em1234) | MX y TXT en postal |
| DMARC | TXT en _dmarc | Opcional. El mismo _dmarc sirve para los dos |
Reescribe la llamada de envío
Postal no acepta el formato /v3/mail/send: la llamada pasa a ser POST /emails, con los campos en el primer nivel. A continuación, el mismo correo electrónico en los dos formatos. Quien usa el SDK de SendGrid cambia el envío por el SDK de Postal (Node.js, PHP y Laravel) o por HTTP, que es JSON con la clave en el encabezado Authorization.
- SendGrid responde 202 Accepted; Postal responde 200 con el
iddel correo electrónico. Guarda elid: es el que aparece en los webhooks y enGET /emails/{id} - Varios destinatarios en el mismo
toforman un único correo electrónico en el que todos se ven. Para un correo por persona, usa el envío por lotes - Repetir la llamada con la misma
Idempotency-Keyno duplica el envío
Antes: SendGrid
POST https://api.sendgrid.com/v3/mail/send
Authorization: Bearer <clave de SendGrid>
{
"personalizations": [{
"to": [{ "email": "[email protected]", "name": "Ana" }],
"custom_args": { "order_id": "10482" }
}],
"from": { "email": "[email protected]", "name": "Loja Exemplo" },
"subject": "Tu pedido #10482 salió para la entrega",
"content": [
{ "type": "text/plain", "value": "El pedido #10482 está en camino." },
{ "type": "text/html", "value": "<p>El pedido <b>#10482</b> está en camino.</p>" }
],
"categories": ["entrega"]
}Después: Postal
POST https://api.postal.so/emails
Authorization: Bearer ps_sua_chave
Idempotency-Key: pedido-10482-saiu
{
"from": "Loja Exemplo <[email protected]>",
"to": ["Ana <[email protected]>"],
"subject": "Tu pedido #10482 salió para la entrega",
"text": "El pedido #10482 está en camino.",
"html": "<p>El pedido <b>#10482</b> está en camino.</p>",
"tags": [
{ "name": "category", "value": "entrega" },
{ "name": "order_id", "value": "10482" }
]
}En el código
- const sgMail = require('@sendgrid/mail');- sgMail.setApiKey(process.env.SENDGRID_API_KEY);- await sgMail.send({- to: '[email protected]',- from: '[email protected]',- subject, html,- });+ import { Postal } from 'postal-so';+ const postal = new Postal(); // lee POSTAL_API_KEY+ await postal.emails.send({+ from: 'Loja Exemplo <[email protected]>',+ to: '[email protected]',+ subject, html,+ });Mapea los campos
La tabla sirve para el código nuevo y como lista de comprobación de lo que usa hoy tu sistema. Las filas que cambian la forma de armar el correo electrónico son las de destinatarios por lotes, categorías y argumentos personalizados.
| En SendGrid | En Postal | Observación |
|---|---|---|
POST /v3/mail/send | POST /emails | La clave va en Authorization: Bearer en ambos. En Postal empieza con ps_. |
personalizations[].to, lista de {email, name} | to: texto o lista de hasta 50, en el formato Nome <email> | Una lista es un único correo electrónico, en el que todos se ven. |
Varias personalizations (un correo por elemento) | POST /emails/batch: lista de hasta 100 correos electrónicos, cada uno con su propio to | En SendGrid el límite es 1.000 personalizations por solicitud. El lote es un recurso del plan y se valida antes: o todos entran en la cola, o ninguno. |
cc y bcc | cc y bcc | Mismo comportamiento: el bcc no aparece para los demás. |
from: {email, name} | from: texto Nome <email> | El dominio debe estar verificado (y ser el de la clave, si esta es restringida). |
reply_to y reply_to_list | reply_to: texto o lista | |
subject, global o por personalización | subject | Asunto distinto por persona: un elemento por correo electrónico en el lote, o una plantilla con variable en el asunto. |
content: lista de {type, value} | html y text | Campos separados. Sin text, html es obligatorio; envía los dos cuando puedas. |
attachments: content, filename, type, disposition, content_id | attachments: content, filename, content_type, content_id y path (URL) | type pasa a ser content_type. Imagen incrustada: content_id y cid: en el HTML. No existe disposition. Adjuntos de hasta 40 MB en total, no valen en el lote y dependen del plan. |
template_id y dynamic_template_data | template: {id, variables} | Mira “Lo que no tiene equivalente”: las plantillas de Postal solo sustituyen variables. |
headers | headers | Cada plataforma tiene su lista de encabezados que no se pueden cambiar. En Postal: From, To, Cc, Bcc, Subject, Reply-To, Sender, Date, Message-ID, Return-Path, MIME-Version, Content-Type, Content-Transfer-Encoding y DKIM-Signature. |
categories (hasta 10) | tags: [{name, value}] (hasta 50) | Nombre y valor solo aceptan letras ASCII, números, _ y - (hasta 256 caracteres): “Promoción de Navidad” se convierte en promocao-de-natal. |
custom_args | tags | Misma regla de caracteres. En los webhooks, las etiquetas llegan en data.tags ({"order_id": "10482"}), y no como claves sueltas en el evento. Para datos libres, guárdalos en tu base de datos junto con el id del correo electrónico. |
send_at, Unix, hasta 72 horas | scheduled_at, ISO 8601, hasta 30 días | Ejemplo: 2026-10-25T09:00:00-03:00. No vale en el lote y depende del plan. Se puede reprogramar (PATCH /emails/{id}) y cancelar (POST /emails/{id}/cancel). |
mail_settings.sandbox_mode | Direcciones de prueba: [email protected] y otras | En lugar de un modo de la solicitud, cambias el destinatario. El correo electrónico se simula, no usa cuota y genera webhooks reales. |
tracking_settings.open_tracking y click_tracking | open_tracking y click_tracking en el dominio | Se activan por dominio (panel o PATCH /domains/{id}), no por correo electrónico. Dependen del plan. |
Si usas SMTP, cambia la conexión
Quien envía por SMTP no reescribe nada: cambia el servidor, el usuario y la contraseña. El usuario fijo es postal, y no apikey. Postal también acepta 2465 (TLS) y 2587 (STARTTLS) para redes que bloquean los puertos habituales.
- El encabezado
X-SMTPAPI(category,unique_args,sub,send_at,filters…) no lo interpreta Postal: sigue como un encabezado común. Lo que uses de él tiene que pasar a la API - Los destinatarios son los del sobre (
RCPT TO), hasta 50 por mensaje. Quien no aparece en To ni en Cc se trata como Bcc - El SMTP de Postal no tiene etiquetas, programación ni plantillas. Cuotas, lista de supresión, eventos y webhooks son los mismos de la API
- Mensajes de hasta 40 MB; Postal responde
250 2.0.0 OK queued as <id>, y el id es el del correo electrónico en la API
- Servidor: smtp.sendgrid.net- Puerto: 587 (o 25, 2525, 465)- Usuario: apikey- Contraseña: la clave de API de SendGrid+ Servidor: smtp.postal.so+ Puerto: 465 (TLS directo) o 587 (STARTTLS)+ Usuario: postal+ Contraseña: la clave de API de Postal (ps_…)Recrea los webhooks
SendGrid envía el Event Webhook firmado con una clave pública (ECDSA); Postal firma con un secreto por endpoint, en el estándar Standard Webhooks. El cuerpo también cambia: un evento por solicitud, con el correo electrónico dentro de data.
| Evento de SendGrid | Evento de Postal | Observación |
|---|---|---|
processed | email.sent | Aproximado: Postal avisa cuando entregó el correo electrónico al proveedor de envío. |
delivered | email.delivered | El servidor del destinatario aceptó el mensaje. |
deferred | email.delivery_delayed | La entrega se aplazó y los nuevos intentos ocurren solos. |
bounce | email.bounced | data.bounce trae type (Permanent, Transient o Undetermined) y message. Un rebote permanente pone la dirección en la lista de supresión. |
dropped | email.suppressed o email.failed | Aproximado: suppressed cuando el destinatario estaba en la lista de supresión; failed, con failed.reason, cuando no fue posible enviar. |
open | email.opened | Solo con el seguimiento de aperturas activado en el dominio. |
click | email.clicked | data.click trae link, ipAddress, userAgent y timestamp. Solo con el seguimiento de clics activado. |
spamreport | email.complained | La dirección pasa a la lista de supresión. |
unsubscribe | contact.updated | La baja de un contacto de marketing genera contact.updated. |
group_unsubscribe y group_resubscribe | sin evento propio | Los temas de Postal no tienen un evento con ese nombre. |
| En SendGrid | En Postal | Observación |
|---|---|---|
| Un POST con la lista de eventos | Un POST por evento: {type, created_at, data} | Los ejemplos de SendGrid muestran un array de eventos. Si tu código recorre una lista, pasará a recibir un objeto. |
sg_message_id, email, timestamp | data.email_id, data.to, created_at | El email_id es el id devuelto por POST /emails. La hora llega en ISO 8601, y no en Unix. |
Firma ECDSA: X-Twilio-Email-Event-Webhook-Signature y -Timestamp | Standard Webhooks, HMAC-SHA256: webhook-id, webhook-timestamp y webhook-signature | Cambia la verificación por el verifyWebhook del SDK o por el ejemplo de la documentación. El secreto (whsec_…) es de cada endpoint. La documentación de webhooks de Postal no tiene el OAuth 2.0 opcional de SendGrid. |
| Reintentos durante hasta 24 horas, respuesta 2xx | 8 intentos en unas 27 horas, respuesta 2xx en hasta 15 segundos | Todos con el mismo webhook-id: úsalo para descartar duplicados. Reenvío manual por el panel o por POST /webhooks/{id}/events/{event_id}/replay. |
| Event Webhook en Settings, Mail Settings | Webhooks, en el panel, o POST /webhooks | La URL debe ser HTTPS y pública. Eliges los eventos de cada endpoint. |
import express from 'express';
import { verifyWebhook, PostalWebhookVerificationError } from 'postal-so';
const app = express();
// El cuerpo debe llegar crudo (Buffer) para que la firma coincida.
app.post('/webhooks/postal', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = verifyWebhook(req.body, req.headers, process.env.POSTAL_WEBHOOK_SECRET);
if (event.type === 'email.bounced') {
// descarta la dirección
}
res.sendStatus(200);
} catch (error) {
if (error instanceof PostalWebhookVerificationError) {
return res.sendStatus(400);
}
throw error;
}
});Lleva las supresiones y los contactos
Primero la lista de supresión, para no escribir a quien no debe recibir. Después los contactos, si usas la audiencia de marketing. SendGrid exporta cada tipo de supresión por una ruta propia; Postal tiene una sola lista.
- Haz la importación de contactos después de la supresión: las direcciones que ya están en la lista de supresión se ignoran y no usan el límite de contactos del plan
- Las campañas y automatizaciones de marketing hay que recrearlas en Postal; lo que llevas son los contactos y el contenido
curl -X POST https://api.postal.so/suppressions/batch/add \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"emails": ["[email protected]", "[email protected]"]}'| En SendGrid | En Postal | Observación |
|---|---|---|
Rebotes: GET /v3/suppression/bounces, hasta 500 por página (limit, offset) | POST /suppressions/batch/add | Lee el email de cada elemento y envía 100 por solicitud. Las direcciones que vuelvan a rebotar en Postal entran solas. |
Quejas de spam: GET /v3/suppression/spam_reports | POST /suppressions/batch/add | Quien se quejó no debe recibir de nuevo: llévalo también. |
Blocks: GET /v3/suppression/blocks | No importes | En SendGrid, el bloqueo viene de un problema en el mensaje o en la IP de envío y, por defecto, no suprime envíos futuros. Postal no tiene una lista equivalente. |
Bajas globales: GET /v3/suppression/unsubscribes | Contactos con unsubscribed: true (columna de baja en el CSV) | Quien salió solo del marketing queda en la audiencia sin recibir campañas. Si la persona no debe recibir nada, ni correos electrónicos del sistema, pon la dirección en la lista de supresión: no se convierte en contacto. |
| Grupos de baja (ASM) | Temas, con elección de cada contacto (opt_in u opt_out) | La importación en CSV puede suscribir a todos los contactos importados a temas. |
Contactos: PUT /v3/marketing/contacts, hasta 30.000 por solicitud | POST /contacts/batch, hasta 10.000 | Por encima de 1.000 elementos el lote pasa a la cola (202) y lo sigues en GET /contacts/batches/{id}. El lote crea o actualiza. |
Importar CSV: PUT /v3/marketing/contacts/imports | Importar CSV en el panel, o POST /contacts/imports | Archivo de hasta 20 MB. Quien ya existe se actualiza por el correo electrónico. |
Exportar: POST /v3/marketing/contacts/exports (CSV o JSON) | Exporta en CSV e importa | La exportación de SendGrid es asíncrona: descarga los archivos y usa el CSV en la importación. |
| Listas | Segmentos estáticos | En la importación, elige el segmento. Solo los segmentos estáticos reciben contactos importados. |
| Campos personalizados | Propiedades del contacto | Crea las propiedades antes de importar, porque la importación no crea propiedades nuevas. |
Ejecuta las dos en paralelo y cambia
Con el dominio verificado en las dos, pasa un flujo cada vez. Invitaciones, restablecimientos de contraseña y códigos deben salir por un solo camino cada vez.
- Los IDs son de cada plataforma: guarda de qué proveedor salió cada correo electrónico y sigue recibiendo eventos de SendGrid de lo que salió por allí
- Usa
tags(por ejemplo,provider: postal) para separar los correos nuevos en los webhooks - Las cuentas nuevas empiezan con un tope de 500 correos por día, que crece con la reputación de la cuenta, y los envíos se pausan con rebotes permanentes por encima del 3% al 5%, según el nivel, o quejas por encima del 0,3%: sube el volumen por etapas
Lo que no tiene equivalente
| En SendGrid | Qué hacer en Postal |
|---|---|
Plantillas dinámicas con Handlebars ({{#if}}, {{#each}}, dynamic_template_data con listas) | Las plantillas de Postal solo sustituyen variables {{{NOME}}} (texto o número; en el HTML, los valores se escapan). Resuelve condicionales y listas en tu sistema y envía el HTML listo en html. |
ip_pool_name | No hay un campo equivalente en la API de envío. |
batch_id (cancelar o pausar un grupo de envíos) | Cancela cada correo electrónico programado con POST /emails/{id}/cancel. |
asm.group_id en el envío | El envío por POST /emails no acepta tema. Para pedir la baja en correos electrónicos del sistema, usa el encabezado List-Unsubscribe en headers. |
footer, subscription_tracking y ganalytics por correo electrónico | Sin equivalente en la API de envío: arma el pie y los enlaces con UTM en el HTML. Los correos de campaña de Postal ya llevan el enlace de baja. |
bypass_list_management y demás bypass_* | No hay forma de ignorar la lista de supresión: los destinatarios suprimidos se quitan del envío y generan email.suppressed. |
| Seguimiento de aperturas y clics por correo electrónico | Vale por dominio, no por mensaje. |
disposition de adjuntos | No existe. La imagen incrustada usa content_id. |
X-SMTPAPI | No se interpreta. Usa la API para etiquetas, programación y variables. |
| Webhooks con OAuth 2.0 | No consta en la documentación de webhooks de Postal; la protección es la firma. |
Antes de cambiar la clave
- Dominio verificado en Postal, clave de API creada y guardada en el entorno
- Llamada de envío reescrita y probada con una dirección de prueba
- Categorías y argumentos personalizados convertidos en etiquetas (solo letras ASCII, números, _ y -)
- Plantillas con Handlebars resueltas en tu sistema o recreadas con variables simples
- Webhooks recreados, firma comprobada y código que leía una lista de eventos ajustado
- Supresiones y contactos importados, en ese orden
- Tu código guarda de qué proveedor salió cada correo electrónico
- Rebotes revisados en los primeros días después del cambio
Preguntas frecuentes
¿Puedo seguir usando @sendgrid/mail?
No para enviar por Postal: Postal no acepta el formato /v3/mail/send. Cambia el envío por el SDK de Postal o por una llamada HTTP a POST /emails.
¿Necesito reescribir todas mis plantillas?
Solo las que usan condicionales o repeticiones de Handlebars. Las plantillas que solo cambian nombres y números pasan a ser plantillas con {{{VARIAVEL}}}. En los envíos por API, Postal solo sustituye variables.
Mi sistema envía por SMTP. ¿Qué cambia?
El servidor (smtp.postal.so), el usuario (postal) y la contraseña (una clave de API de Postal). Si usa el encabezado X-SMTPAPI, esas funciones tienen que pasar a la API.
¿Puedo mantener el dominio autenticado en SendGrid?
Sí. Los registros de DNS tienen nombres diferentes, así que los dos quedan verificados al mismo tiempo durante el cambio.
Sigue por aquí
Otras guías
Cada plataforma tiene el suyo: el paso a paso general está en Migrar.
¿Listo para reescribir la llamada?
Crea tu cuenta y empieza con el plan Gratis. Para planificar la cuota y el orden de la migración, habla con nosotros por el formulario de contacto.