MailLandDocs
Email entrante

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:

Inbound webhook payload
{
  "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

CampoTipoDescripción
eventstringSiempre inbound.received
event_idstringID 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_keystring{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_ofstringSolo 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
timestampstringMarca de tiempo ISO 8601 de cuándo se generó el evento
occurred_atstringMomento en que se recibió el correo; el mismo instante que data.email.received_at
schema_versionnumberVersión del esquema de la carga útil, 1; consulte Versionado
data.email.idstringID pública del correo entrante
data.email.received_atstringCuándo se recibió el correo
data.email.size_bytesnumberTamaño del mensaje original en bytes
data.email.envelope.fromstringDirección MAIL FROM de SMTP
data.email.envelope.toarrayDirecciones RCPT TO de SMTP
data.email.headers.fromobject{ address, name } procesado del encabezado From
data.email.headers.to / .ccarrayArreglos de { address, name }
data.email.headers.subjectstringAsunto del correo
data.email.headers.message_idstring | nullEncabezado Message-ID
data.email.headers.in_reply_tostring | nullEncabezado In-Reply-To
data.email.headers.reply_tostring | nullEncabezado Reply-To
data.email.headers.datestring | nullEncabezado Date
data.email.body.text_previewstring | nullVista previa truncada en texto plano
data.email.body.has_text / .has_htmlbooleanSi había una parte de texto o HTML
data.email.attachmentsarrayVer abajo
data.email.authentication.spf/dkim/dmarc.resultstringResultado en minúsculas, p. ej. pass, fail, none
data.email.spam.scorenumberPuntaje de spam
data.email.spam.is_spambooleanSi el correo se clasificó como spam
data.email.spam.reasonstring | nullMotivo de la clasificación, cuando está disponible
data.email.source.ip / .hostnamestring | nullIP y nombre de host DNS inverso del servidor emisor
data.matched_routeobject | 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:

CampoTipoDescripción
idstringID 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_namestringNombre de archivo original
content_typestringTipo MIME
size_bytesnumberTamaño en bytes
is_inlinebooleanSi el adjunto se referencia en línea (p. ej. una imagen incrustada)
content_idstring | nullContent-ID para adjuntos en línea
download_urlstring | nullURL 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}/raw

Esto 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>
  • t es una marca de tiempo Unix en segundos, v1 es 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 respuestaAcción de MailLand
200–299Entrega exitosa, sin reintento
Cualquier otro estado, o sin respuesta en 15 segundosEntrega 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-webhook
Replay webhook delivery
curl -X POST https://api.mail.land/v1/transactional/inbound/emails/k7m2x9q4p1w8z3/replay-webhook \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
Replay response (202 Accepted)
{
  "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_id y en el encabezado X-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_id nuevo y un campo data.replay_of con el ID original. Si quiere que se ignore una reproducción cuando el original ya se procesó, compare también data.replay_of con sus IDs almacenados.
Idempotent receiver (Node.js / Express)
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:

EncabezadoEjemploDescripción
Content-Typeapplication/json
X-MLND-Signaturet=1708171200,v1=abc123...Marca de tiempo y firma HMAC-SHA256
X-MLND-Eventinbound.receivedTipo de evento
X-MLND-Event-Idevt_a1b2c3d4...ID única del evento
X-MLND-Event-Version1Versión del esquema del payload
X-MLND-Webhook-Idw4k8m2q7p9x3z1ID pública del webhook
X-MLND-Attempt1Número de intento de entrega (empieza en 1)
X-MLND-SourceinboundQué pipeline produjo el evento
X-MLND-Inbound-Email-Idk7m2x9q4p1w8z3El correo entrante al que corresponde esta entrega

Buenas prácticas

  • Responda rápido: confirme el webhook con 200 de inmediato y procese el correo de forma asíncrona en un trabajo en segundo plano.
  • Verifique las firmas: valide siempre el encabezado X-MLND-Signature para evitar payloads falsificados.
  • Gestione los duplicados: la entrega es al menos una vez, así que deduplique por X-MLND-Event-Id (el event_id del payload). Una reproducción manual tiene un ID nuevo y data.replay_of apunta al original; consulte Deduplicar eventos.
  • Descargue los adjuntos de inmediato: cada download_url expira 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

EstadoCódigoDescripción
400INVALID_REQUESTNo se encontró ninguna ruta para el correo
400INVALID_REQUESTLa ruta no tiene un webhook configurado
400INVALID_REQUESTEl webhook de la ruta está inactivo, actívelo antes de reenviar
404RESOURCE_NOT_FOUNDCorreo 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

En esta página