Para probar un webhook de Postmark en localhost, ejecuta el handler local, expón su puerto con npx portpreview PORT y registra la ruta HTTPS resultante en el Message Stream adecuado. Protege el endpoint con Basic Authentication o un header secreto, valida el JSON, persístelo de forma idempotente y responde HTTP 200 cuanto antes.
Qué envía Postmark a un webhook
Postmark hace un HTTP POST cuando ocurre un evento. Los streams salientes notifican delivery, bounce, open, click, spam complaint y cambios de suscripción; los entrantes envían el correo ya analizado. Enruta por RecordType y valida el schema de cada tipo. Delivery solo significa que el servidor de destino aceptó el mensaje, no que llegó al inbox. Bounce incluye Type, TypeCode, Inactive y CanActivate. documentación general de webhooks referencia del webhook de rebotes
Crear un receptor Express local y pequeño
El ejemplo usa Express en el puerto 3000, comprueba Basic Auth antes del JSON, valida el sobre mínimo y guarda una clave de deduplicación antes de confirmar. Sustituye los helpers por una transacción o cola duradera. Limita el tamaño y no registres correos completos: pueden contener datos personales, enlaces de acceso, adjuntos o información confidencial.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.use('/webhooks/postmark', express.json({ limit: '2mb' }));
function safeEqual(actual, expected) {
const a = Buffer.from(actual);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function authorized(req) {
const value = req.get('authorization') ?? '';
if (!value.startsWith('Basic ')) return false;
const decoded = Buffer.from(value.slice(6), 'base64').toString('utf8');
const separator = decoded.indexOf(':');
if (separator < 0) return false;
return safeEqual(decoded.slice(0, separator), process.env.POSTMARK_WEBHOOK_USER ?? '') &&
safeEqual(decoded.slice(separator + 1), process.env.POSTMARK_WEBHOOK_PASSWORD ?? '');
}
app.post('/webhooks/postmark', async (req, res) => {
if (!authorized(req)) return res.sendStatus(401);
const event = req.body;
if (typeof event?.RecordType !== 'string' ||
typeof event?.MessageID !== 'string') {
return res.status(400).json({ error: 'Invalid Postmark event' });
}
const deliveryKey = `${event.RecordType}:${event.MessageID}:${event.ID ?? ''}`;
await saveWebhookOnce({ provider: 'postmark', deliveryKey, event });
res.sendStatus(200);
});
app.listen(3000);
Exponer localhost con una URL HTTPS pública
Arranca el receptor, comprueba el puerto y ejecuta npx portpreview 3000 en otra terminal. Añade la ruta, por ejemplo https://YOUR-TUNNEL.portpreview.dev/webhooks/postmark, y mantén el túnel activo. El túnel aporta acceso público y HTTPS, pero no autentica a Postmark; siguen siendo necesarias la autenticación y la validación. guía de seguridad de túneles localhost
Configurar el webhook correcto de Postmark
En Postmark selecciona el Server y Message Stream saliente correctos, agrega la URL en Webhooks y habilita solo los triggers implementados. El correo entrante usa la URL propia de Inbound Message Stream. Webhooks API admite HttpAuth, HttpHeaders y triggers. X-Postmark-Server-Token administra la API; no es una credencial enviada al receptor. API de Webhooks
La autenticación de Postmark no es una firma criptográfica
Postmark no admite actualmente firmas HMAC de webhooks: no hay signing secret para recalcular el raw body ni header X-Postmark-Signature. Basic Authentication, una allowlist IP o un header secreto prueban que se posee una credencial compartida, pero no la vinculan criptográficamente al body. Usa HTTPS, valida el payload y consulta rangos IP vigentes. Prefiere HttpAuth; si usas https://username:[email protected]/path, crea credenciales fuertes, codifica caracteres y evita filtrar la URL. Nunca reutilices el Server API token.
Procesar entregas y rebotes según su tipo
Saca la lógica de negocio de la petición HTTP y deja que un worker procese los eventos persistidos de forma idempotente. No deduzcas una supresión permanente solo por un campo: aplica la clasificación actual de bounce y tu política. Spam complaint y subscription change tienen tipos propios; open y click pueden repetirse.
async function processPostmarkEvent(event) {
switch (event.RecordType) {
case 'Delivery':
await markAcceptedByRecipientServer({
messageId: event.MessageID,
deliveredAt: event.DeliveredAt
});
break;
case 'Bounce':
await recordBounce({
bounceId: String(event.ID),
messageId: event.MessageID,
type: event.Type,
inactive: event.Inactive,
canActivate: event.CanActivate
});
break;
default:
await recordUnhandledPostmarkType(event.RecordType);
}
}
Diseñar para reintentos y entregas duplicadas
Postmark reintenta si no recibe HTTP 200. Bounce e inbound siguen una secuencia más larga que click, open, delivery y subscription change; 403 detiene los reintentos. Un timeout después del commit produce un duplicado legítimo. Usa una restricción única sobre MessageID y, en endpoints mixtos, añade RecordType y un identificador como ID. Responde 200 tras una entrega duradera mínima y procesa APIs externas de forma asíncrona. reintentos e idempotencia de webhooks
Probar eventos reales de forma segura
Primero manda un POST sintético con curl. Después genera eventos reales: delivery hacia una dirección bajo tu control y bounce mediante las herramientas documentadas por Postmark, incluido el black-hole test domain cuando corresponda. Guarda MessageID, envía dos veces un fixture saneado y verifica un único efecto.
Resolver fallos habituales de webhooks de Postmark
El endpoint no recibe solicitudes
Revisa el proceso del túnel, la ruta completa y el puerto. Ante 401 compara las credenciales, reinicia la app y comprueba que el proxy no elimine Authorization, sin imprimirlo. Si hay reintentos tras procesar, observa status y latency públicos: Postmark exige 200. Si cambia el payload, revisa RecordType, trigger e inbound/outbound; rechaza campos obligatorios ausentes y tolera adiciones documentadas.
Todas las solicitudes devuelven HTTP 401
Hay reintentos después de procesar
Revisa el proceso del túnel, la ruta completa y el puerto. Ante 401 compara las credenciales, reinicia la app y comprueba que el proxy no elimine Authorization, sin imprimirlo. Si hay reintentos tras procesar, observa status y latency públicos: Postmark exige 200. Si cambia el payload, revisa RecordType, trigger e inbound/outbound; rechaza campos obligatorios ausentes y tolera adiciones documentadas.
El payload no coincide
Revisa el proceso del túnel, la ruta completa y el puerto. Ante 401 compara las credenciales, reinicia la app y comprueba que el proxy no elimine Authorization, sin imprimirlo. Si hay reintentos tras procesar, observa status y latency públicos: Postmark exige 200. Si cambia el payload, revisa RecordType, trigger e inbound/outbound; rechaza campos obligatorios ausentes y tolera adiciones documentadas.
Lista de seguridad para producción
Usa HTTPS y credenciales Basic Auth o header dedicado de alta entropía; separa tokens API y secretos; rota tras las pruebas; elimina URLs antiguas; valida content type, tamaño, tipo e identificadores; redacta datos de correo y secretos en logs; aplica privilegio mínimo y monitoriza fallos, lag, duplicados y dead letters.
