Webhooks de entrada
Reciba datos de correos entrantes procesados mediante notificaciones de webhook
Cuando un correo entrante coincide con una ruta cuyas actions incluyen webhook, MailLand envía
el correo procesado al webhook de esa ruta como una solicitud POST en JSON. Esta página cubre el
formato del payload, la verificación de firma y el comportamiento de entrega.
Payload del webhook
MailLand envía una solicitud POST a su URL de webhook con el siguiente cuerpo JSON:
{
"event": "inbound.received",
"event_id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
"timestamp": "2026-02-17T14:30:00Z",
"occurred_at": "2026-02-17T14:29:58Z",
"schema_version": 1,
"data": {
"idempotency_key": "k7m2x9q4p1w8z3_r7k3m9q2p4w8x1",
"email": {
"id": "k7m2x9q4p1w8z3",
"received_at": "2026-02-17T14:29:58Z",
"size_bytes": 48213,
"envelope": {
"from": "[email protected]",
"to": ["[email protected]"]
},
"headers": {
"from": { "address": "[email protected]", "name": "Jane Customer" },
"to": [{ "address": "[email protected]", "name": null }],
"cc": [],
"subject": "Need help with my account",
"message_id": "<[email protected]>",
"in_reply_to": null,
"reply_to": "[email protected]",
"date": "2026-02-17T14:29:55Z"
},
"body": {
"text_preview": "I need assistance with my account settings.",
"has_text": true,
"has_html": true
},
"attachments": [
{
"id": "a3k7m2q9p4w1x8",
"file_name": "screenshot.png",
"content_type": "image/png",
"size_bytes": 34500,
"is_inline": false,
"content_id": null,
"download_url": "https://r2.example.com/screenshot.png?X-Amz-Signature=..."
}
],
"authentication": {
"spf": { "result": "pass" },
"dkim": { "result": "pass" },
"dmarc": { "result": "pass" }
},
"spam": {
"score": 1.2,
"is_spam": false,
"reason": null
},
"source": {
"ip": "203.0.113.42",
"hostname": "mail.example.com"
}
},
"matched_route": {
"id": "r7k3m9q2p4w8x1",
"name": "Support Inbox"
}
}
}El cuerpo completo del correo nunca se incluye en línea: solo una vista previa truncada en
text_preview y los indicadores has_text/has_html. No existe el campo body.html.
Los campos sin valor (name, in_reply_to, content_id, reason, data.matched_route, etc.) se
omiten por completo del JSON en lugar de enviarse como null. El ejemplo anterior muestra
null por claridad; no dependa de que la clave esté presente.
Campos del payload
| Campo | Tipo | Descripción |
|---|---|---|
event | string | Siempre inbound.received |
event_id | string | ID del evento, igual al encabezado X-MLND-Event-Id. Es estable por mensaje entrante y webhook (evt_ seguido de 32 caracteres hexadecimales): cada reintento y cada republicación de la misma entrega lleva el mismo event_id, encabezado y cuerpo. Una reproducción manual lleva un event_id nuevo y establece data.replay_of. |
data.idempotency_key | string | {emailPublicId}_{routePublicId} cuando coincidió una ruta, o solo {emailPublicId} si no coincidió ninguna (o la ruta ya no existe). Estable a través de reintentos y reenvíos manuales |
data.replay_of | string | Solo presente en una reproducción manual: el event_id que llevó la entrega original. Se omite en las entregas normales y en las reproducciones de mensajes recibidos antes de que los IDs de evento fueran estables |
timestamp | string | Marca de tiempo ISO 8601 de cuándo se generó el evento |
occurred_at | string | Momento en que se recibió el correo; el mismo instante que data.email.received_at |
schema_version | number | Versión del esquema de la carga útil, 1; consulte Versionado |
data.email.id | string | ID pública del correo entrante |
data.email.received_at | string | Cuándo se recibió el correo |
data.email.size_bytes | number | Tamaño del mensaje original en bytes |
data.email.envelope.from | string | Dirección MAIL FROM de SMTP |
data.email.envelope.to | array | Direcciones RCPT TO de SMTP |
data.email.headers.from | object | { address, name } procesado del encabezado From |
data.email.headers.to / .cc | array | Arreglos de { address, name } |
data.email.headers.subject | string | Asunto del correo |
data.email.headers.message_id | string | null | Encabezado Message-ID |
data.email.headers.in_reply_to | string | null | Encabezado In-Reply-To |
data.email.headers.reply_to | string | null | Encabezado Reply-To |
data.email.headers.date | string | null | Encabezado Date |
data.email.body.text_preview | string | null | Vista previa truncada en texto plano |
data.email.body.has_text / .has_html | boolean | Si había una parte de texto o HTML |
data.email.attachments | array | Ver abajo |
data.email.authentication.spf/dkim/dmarc.result | string | Resultado en minúsculas, p. ej. pass, fail, none |
data.email.spam.score | number | Puntaje de spam |
data.email.spam.is_spam | boolean | Si el correo se clasificó como spam |
data.email.spam.reason | string | null | Motivo de la clasificación, cuando está disponible |
data.email.source.ip / .hostname | string | null | IP y nombre de host DNS inverso del servidor emisor |
data.matched_route | object | null | { id, name } de la ruta que coincidió, o null |
Adjuntos
El contenido de los adjuntos nunca se incluye en línea. Cada entrada en data.email.attachments lleva un
download_url:
| Campo | Tipo | Descripción |
|---|---|---|
id | string | ID del adjunto. Estable: cada reintento y cada reenvío manual del evento lleva el mismo id para el mismo adjunto; úselo para reconocer un adjunto que ya descargó. download_url se genera de nuevo en cada intento |
file_name | string | Nombre de archivo original |
content_type | string | Tipo MIME |
size_bytes | number | Tamaño en bytes |
is_inline | boolean | Si el adjunto se referencia en línea (p. ej. una imagen incrustada) |
content_id | string | null | Content-ID para adjuntos en línea |
download_url | string | null | URL prefirmada, válida durante 15 minutos desde que se generó el payload |
download_url no requiere encabezado Authorization: la URL misma es la credencial, y expira 15
minutos después de generarse el payload del webhook. Descargue los adjuntos inmediatamente al
recibir el webhook. Si la URL expiró, obtenga en su lugar el mensaje original completo:
GET https://api.mail.land/v1/transactional/inbound/emails/{emailId}/rawEsto requiere su encabezado Authorization habitual y devuelve el archivo .eml original
(Content-Type: message/rfc822): no existe un endpoint de descarga por adjunto.
Verificación de firma
Los webhooks de entrada se firman exactamente igual que cualquier otro webhook de MailLand: mismo encabezado, mismo esquema, mismo manejo del secreto. La referencia canónica, con un ejemplo completo y código verificador en ocho lenguajes, es Signature Verification; todo lo indicado allí aplica aquí sin cambios.
En resumen:
X-MLND-Signature: t=1708171200,v1=<hex>tes una marca de tiempo Unix en segundos,v1es un HMAC-SHA256 en hexadecimal minúscula;- la cadena firmada es
{timestamp}.{raw_body}: la marca de tiempo, un punto literal, y luego el cuerpo exactamente como se recibió, antes del análisis JSON; - la clave es el secreto de firma del webhook, mostrado una sola vez al crearlo;
- compare en tiempo constante y rechace una marca de tiempo con más de 300 segundos de antigüedad.
No existe una ruta de entrega sin firmar: si un webhook no tiene un secreto utilizable, MailLand
falla la entrega en lugar de enviarla sin firma. Rechace cualquier solicitud que llegue sin el
encabezado X-MLND-Signature.
Comportamiento de entrega
Códigos de respuesta
Su endpoint debe responder con un código de estado 2xx para confirmar la recepción. Cualquier
otra cosa, un estado que no sea 2xx o un tiempo de espera agotado, cuenta como un intento de
entrega fallido.
| Su respuesta | Acción de MailLand |
|---|---|
200–299 | Entrega exitosa, sin reintento |
| Cualquier otro estado, o sin respuesta en 15 segundos | Entrega fallida, se reintenta (sujeto al límite de intentos indicado abajo) |
Política de reintentos
Un intento fallido se reintenta con el mismo calendario que cualquier otro evento de webhook, hasta un total de 6 intentos por evento: el primer intento y luego reintentos de inmediato, a 1 minuto, 5 minutos, 15 minutos y 1 hora. Después del 6.º intento fallido, la entrega se marca como fallida permanentemente. Puede reenviarla manualmente. Consulte Lógica de reintentos para el calendario completo.
Un endpoint caído durante menos de una hora y media aproximadamente recibe reintentos a lo largo de toda esa ventana, pero una caída más larga termina en una entrega fallida permanentemente. Diseñe su endpoint para responder con prontitud incluso en error y confíe en el reenvío manual para caídas más largas.
Cada intento de entrega fallido (no cada evento) cuenta para el total de fallos consecutivos del
webhook, y el webhook se desactiva automáticamente en cuanto ese total alcanza su propio
pauseThreshold (cuando está fijado a un valor mayor que 0) o, si no, el valor predeterminado de 10
fallos consecutivos: después de eso, los nuevos correos entrantes no encolan ninguna entrega de
webhook para él, y como es la misma fila de webhook usada para eventos de mensajes, estos también se
detienen. Con el valor predeterminado, dos correos entrantes completamente fallidos (6 intentos cada
uno) bastan para desactivarlo. Reactive el webhook en la página de Webhooks y luego reenvíe los
correos perdidos.
Reenviar un webhook
Vuelva a entregar el webhook de un correo entrante específico:
POST https://api.mail.land/v1/transactional/inbound/emails/{id}/replay-webhookcurl -X POST https://api.mail.land/v1/transactional/inbound/emails/k7m2x9q4p1w8z3/replay-webhook \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."{
"emailId": "k7m2x9q4p1w8z3",
"queued": true,
"webhookUrl": "https://yourapp.com/webhooks/support",
"message": "Webhook replay queued successfully"
}Esto pone en cola una entrega duradera al webhook de la ruta coincidente y devuelve 202 Accepted una vez confirmada esa fila; no envía de forma síncrona. La reproducción recibe un ID de evento nuevo (un event_id y un X-MLND-Event-Id nuevos) y lleva el ID de la entrega original en data.replay_of, de modo que su deduplicación la trata como un evento nuevo y aun así puede vincularla con el original. data.idempotency_key se mantiene igual que en la entrega original. La solicitud incluye X-MLND-Source: inbound y X-MLND-Inbound-Email-Id, y cualquier download_url de adjunto en el payload se firma en el momento del envío: es válida durante 15 minutos desde entonces, no desde que llamó a la reproducción.
Deduplicar eventos
La entrega de webhooks entrantes es al menos una vez (at-least-once): un reintento, un timeout de su lado después de haber procesado ya la solicitud o un traspaso entre workers puede entregar el mismo evento más de una vez. Los receptores deben deduplicar.
- Todo webhook entrante lleva un ID de evento estable en
event_idy en el encabezadoX-MLND-Event-Id. Es determinista por mensaje entrante y webhook, así que todos los intentos de entrega del mismo mensaje al mismo webhook tienen el mismo ID. - Deduplique por ese ID. Guarde los IDs procesados y omita cualquier ID ya visto.
- Una reproducción manual es un evento nuevo. Tiene un
event_idnuevo y un campodata.replay_ofcon el ID original. Si quiere que se ignore una reproducción cuando el original ya se procesó, compare tambiéndata.replay_ofcon sus IDs almacenados.
app.post("/webhooks/support", express.raw({ type: "application/json" }), async (req, res) => {
// 1. Verify X-MLND-Signature first (see Signature Verification).
const eventId = req.header("X-MLND-Event-Id");
const client = await db.connect();
try {
await client.query("BEGIN");
// 2. Record the event id; the unique constraint makes this atomic.
const inserted = await client.query(
"INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT (event_id) DO NOTHING",
[eventId],
);
// 3. Only a first-time id enqueues work, in the SAME transaction (jobs table).
if (inserted.rowCount > 0) {
await client.query("INSERT INTO jobs (kind, payload) VALUES ($1, $2)", [
"process-inbound-email",
req.body.toString(),
]);
}
await client.query("COMMIT");
res.sendStatus(200); // new or duplicate: acknowledge
} catch (err) {
await client.query("ROLLBACK"); // id and job are both undone, so the retry is not a "duplicate"
res.sendStatus(500);
} finally {
client.release();
}
});Registrar el ID y encolar el trabajo en una sola transacción es importante: si el ID se guardara primero y el encolado fallara, el reintento de MailLand se omitiría como duplicado y el correo se perdería.
Conserve los IDs procesados al menos tanto como su ventana de reintentos más larga, más cualquier reproducción que pueda lanzar.
Encabezados del webhook
Cada solicitud de webhook incluye estos encabezados:
| Encabezado | Ejemplo | Descripción |
|---|---|---|
Content-Type | application/json | |
X-MLND-Signature | t=1708171200,v1=abc123... | Marca de tiempo y firma HMAC-SHA256 |
X-MLND-Event | inbound.received | Tipo de evento |
X-MLND-Event-Id | evt_a1b2c3d4... | ID única del evento |
X-MLND-Event-Version | 1 | Versión del esquema del payload |
X-MLND-Webhook-Id | w4k8m2q7p9x3z1 | ID pública del webhook |
X-MLND-Attempt | 1 | Número de intento de entrega (empieza en 1) |
X-MLND-Source | inbound | Qué pipeline produjo el evento |
X-MLND-Inbound-Email-Id | k7m2x9q4p1w8z3 | El correo entrante al que corresponde esta entrega |
Buenas prácticas
- Responda rápido: confirme el webhook con
200de inmediato y procese el correo de forma asíncrona en un trabajo en segundo plano. - Verifique las firmas: valide siempre el encabezado
X-MLND-Signaturepara evitar payloads falsificados. - Gestione los duplicados: la entrega es al menos una vez, así que deduplique por
X-MLND-Event-Id(elevent_iddel payload). Una reproducción manual tiene un ID nuevo ydata.replay_ofapunta al original; consulte Deduplicar eventos. - Descargue los adjuntos de inmediato: cada
download_urlexpira 15 minutos después de generarse el payload; después de eso, obtenga el mensaje original. - Use HTTPS: las URLs de webhook deben usar HTTPS en producción para una entrega segura del payload.
Respuestas de error
| Estado | Código | Descripción |
|---|---|---|
400 | INVALID_REQUEST | No se encontró ninguna ruta para el correo |
400 | INVALID_REQUEST | La ruta no tiene un webhook configurado |
400 | INVALID_REQUEST | El webhook de la ruta está inactivo, actívelo antes de reenviar |
404 | RESOURCE_NOT_FOUND | Correo no encontrado para el reenvío |
Próximos pasos
- Inbound Overview: configurar los ajustes de entrada y ver los correos
- Inbound Routes: configurar reglas de enrutamiento para la entrega de webhooks
- Webhooks: configuración de webhooks salientes para eventos de correo