MailLandDocs
Email entrant

Webhooks entrants

Recevez les données d'e-mails entrants analysés via des notifications webhook

Lorsqu'un e-mail entrant correspond à une route dont les actions incluent webhook, MailLand envoie l'e-mail analysé au webhook de cette route sous forme de requête POST JSON. Cette page décrit le format du payload, la vérification de signature et le comportement de livraison.

Payload du webhook

MailLand envoie une requête POST à votre URL de webhook avec le corps JSON suivant :

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"
    }
  }
}

Le corps complet de l'e-mail n'est jamais inclus en ligne : seulement un aperçu tronqué text_preview et les indicateurs has_text/has_html. Il n'y a pas de champ body.html.

Les champs sans valeur (name, in_reply_to, content_id, reason, data.matched_route, etc.) sont totalement omis du JSON plutôt qu'envoyés comme null. L'exemple ci-dessus montre null par lisibilité ; ne comptez pas sur la présence de la clé.

Champs du payload

ChampTypeDescription
eventstringToujours inbound.received
event_idstringID de l'événement, identique à l'en-tête X-MLND-Event-Id. Il est stable par message entrant et par webhook (evt_ suivi de 32 caractères hexadécimaux) : chaque nouvelle tentative et chaque republication de la même livraison porte le même event_id, le même en-tête et le même corps. Un rejeu manuel porte un nouvel event_id et renseigne data.replay_of.
data.idempotency_keystring{emailPublicId}_{routePublicId} lorsqu'une route a correspondu, ou simplement {emailPublicId} sinon (ou si la route n'existe plus). Stable à travers les tentatives et les rejeux manuels
data.replay_ofstringPrésent uniquement lors d'un rejeu manuel : l'event_id de la livraison d'origine. Omis pour les livraisons normales et pour les rejeux de messages reçus avant que les ID d'événement soient stables
timestampstringHorodatage ISO 8601 de la génération de l'événement
occurred_atstringMoment de réception de l'e-mail ; le même instant que data.email.received_at
schema_versionnumberVersion du schéma de la charge utile, 1 ; voir Versionnage
data.email.idstringID publique de l'e-mail entrant
data.email.received_atstringDate de réception de l'e-mail
data.email.size_bytesnumberTaille du message brut en octets
data.email.envelope.fromstringAdresse SMTP MAIL FROM
data.email.envelope.toarrayAdresses SMTP RCPT TO
data.email.headers.fromobject{ address, name } analysé depuis l'en-tête From
data.email.headers.to / .ccarrayTableaux de { address, name }
data.email.headers.subjectstringObjet de l'e-mail
data.email.headers.message_idstring | nullEn-tête Message-ID
data.email.headers.in_reply_tostring | nullEn-tête In-Reply-To
data.email.headers.reply_tostring | nullEn-tête Reply-To
data.email.headers.datestring | nullEn-tête Date
data.email.body.text_previewstring | nullAperçu tronqué en texte brut
data.email.body.has_text / .has_htmlbooleanSi une partie texte ou HTML était présente
data.email.attachmentsarrayVoir ci-dessous
data.email.authentication.spf/dkim/dmarc.resultstringRésultat en minuscules, ex. pass, fail, none
data.email.spam.scorenumberScore de spam
data.email.spam.is_spambooleanSi l'e-mail a été classé comme spam
data.email.spam.reasonstring | nullMotif de classification, lorsque disponible
data.email.source.ip / .hostnamestring | nullIP et nom d'hôte DNS inverse du serveur émetteur
data.matched_routeobject | null{ id, name } de la route qui a correspondu, ou null

Pièces jointes

Le contenu des pièces jointes n'est jamais inclus en ligne. Chaque entrée de data.email.attachments porte une download_url :

ChampTypeDescription
idstringID de la pièce jointe. Stable : chaque nouvelle tentative et chaque rejeu de l'événement porte le même id pour la même pièce jointe ; utilisez-le pour reconnaître une pièce jointe déjà téléchargée. download_url est générée à nouveau à chaque tentative
file_namestringNom de fichier d'origine
content_typestringType MIME
size_bytesnumberTaille en octets
is_inlinebooleanSi la pièce jointe est référencée en ligne (ex. une image intégrée)
content_idstring | nullContent-ID pour les pièces jointes en ligne
download_urlstring | nullURL pré-signée, valide pendant 15 minutes à partir de la génération du payload

download_url ne nécessite aucun en-tête Authorization : l'URL elle-même est l'identifiant, et elle expire 15 minutes après la génération du payload du webhook. Téléchargez les pièces jointes immédiatement à la réception du webhook. Si l'URL a expiré, récupérez plutôt le message brut complet :

GET https://api.mail.land/v1/transactional/inbound/emails/{emailId}/raw

Cela nécessite votre en-tête Authorization habituel et renvoie le fichier .eml d'origine (Content-Type: message/rfc822) : il n'existe pas de point de terminaison de téléchargement par pièce jointe.

Vérification de signature

Les webhooks entrants sont signés exactement comme tout autre webhook MailLand : même en-tête, même schéma, même gestion du secret. La référence canonique, avec un exemple détaillé et du code de vérification en huit langages, est Signature Verification ; tout ce qui y est décrit s'applique ici sans changement.

En résumé :

X-MLND-Signature: t=1708171200,v1=<hex>
  • t est un horodatage Unix en secondes, v1 est un HMAC-SHA256 en hexadécimal minuscule ;
  • la chaîne signée est {timestamp}.{raw_body} : l'horodatage, un point littéral, puis le corps exactement tel que reçu, avant l'analyse JSON ;
  • la clé est le secret de signature du webhook, affiché une seule fois à sa création ;
  • comparez en temps constant et rejetez un horodatage vieux de plus de 300 secondes.

Il n'existe aucun chemin de livraison non signé : si un webhook n'a pas de secret utilisable, MailLand fait échouer la livraison plutôt que de l'envoyer sans signature. Rejetez toute requête arrivant sans l'en-tête X-MLND-Signature.

Comportement de livraison

Codes de réponse

Votre point de terminaison doit répondre avec un code de statut 2xx pour accuser réception. Tout le reste, un statut autre que 2xx ou un délai dépassé, compte comme une tentative de livraison échouée.

Votre réponseAction de MailLand
200–299Livraison réussie, pas de nouvelle tentative
Tout autre statut, ou aucune réponse dans les 15 secondesLivraison échouée, nouvelle tentative (dans la limite indiquée ci-dessous)

Politique de nouvelles tentatives

Une tentative échouée est rejouée selon le même calendrier que tout autre événement webhook, jusqu'à 6 tentatives au total par événement : la première tentative, puis des nouvelles tentatives immédiatement, après 1 minute, 5 minutes, 15 minutes et 1 heure. Après la 6ᵉ tentative échouée, la livraison est marquée comme définitivement échouée. Vous pouvez la rejouer manuellement. Voir Logique de nouvelle tentative pour le calendrier complet.

Un point de terminaison indisponible moins d'environ une heure et demie est sollicité de nouveau sur toute cette fenêtre, mais une panne plus longue se termine par une livraison définitivement échouée. Concevez votre point de terminaison pour répondre vite même en cas d'erreur et comptez sur le rejeu manuel pour les pannes plus longues.

Chaque tentative de livraison échouée (pas chaque événement) compte dans le total d'échecs consécutifs du webhook, et le webhook est automatiquement désactivé dès que ce total atteint son propre pauseThreshold (lorsqu'il est réglé sur une valeur supérieure à 0), sinon la valeur par défaut de 10 échecs consécutifs : après quoi, les nouveaux e-mails entrants ne mettent plus aucune livraison de webhook en file d'attente pour lui, et comme il s'agit de la même ligne de webhook utilisée pour les événements de message, ceux-ci s'arrêtent aussi. Avec la valeur par défaut, deux e-mails entrants totalement en échec (6 tentatives chacun) suffisent à le désactiver. Réactivez le webhook sur la page Webhooks, puis rejouez les e-mails manqués.

Rejouer un webhook

Relivrer le webhook pour un e-mail entrant spécifique :

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"
}

Cela met en file une livraison durable vers le webhook de la route correspondante et renvoie 202 Accepted une fois cette ligne validée ; l'envoi n'est pas synchrone. Le rejeu reçoit un ID d'événement nouveau (un nouvel event_id et un nouvel X-MLND-Event-Id) et porte l'ID de la livraison d'origine dans data.replay_of : votre déduplication le traite donc comme un nouvel événement, tout en vous permettant de le rattacher à l'original. data.idempotency_key reste identique à celui de la livraison d'origine. La requête porte X-MLND-Source: inbound et X-MLND-Inbound-Email-Id, et chaque download_url de pièce jointe du payload est signée au moment de l'envoi : elle est valable 15 minutes à partir de ce moment, et non à partir de l'appel au rejeu.

Dédupliquer les événements

La livraison des webhooks entrants est au moins une fois (at-least-once) : une nouvelle tentative, un délai dépassé de votre côté alors que la requête a déjà été traitée, ou un relais entre workers peut livrer le même événement plus d'une fois. Les récepteurs doivent dédupliquer.

  • Chaque webhook entrant porte un ID d'événement stable dans event_id et dans l'en-tête X-MLND-Event-Id. Il est déterministe par message entrant et par webhook : toutes les tentatives de livraison du même message vers le même webhook ont donc le même ID.
  • Dédupliquez sur cet ID. Conservez les ID traités et ignorez tout ID déjà vu.
  • Un rejeu manuel est un nouvel événement. Il a un nouvel event_id et un champ data.replay_of contenant l'ID d'origine. Si vous voulez qu'un rejeu soit ignoré lorsque l'original a déjà été traité, comparez aussi data.replay_of à vos ID stockés.
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();
  }
});

Enregistrer l'ID et mettre le travail en file dans une seule transaction est essentiel : si l'ID était enregistré d'abord et que la mise en file échouait, la nouvelle tentative de MailLand serait ignorée comme doublon et l'e-mail serait perdu.

Conservez les ID traités au moins aussi longtemps que votre plus longue fenêtre de nouvelles tentatives, plus tout rejeu que vous pourriez déclencher.

En-têtes du webhook

Chaque requête webhook inclut les en-têtes suivants :

En-têteExempleDescription
Content-Typeapplication/json
X-MLND-Signaturet=1708171200,v1=abc123...Horodatage et signature HMAC-SHA256
X-MLND-Eventinbound.receivedType d'événement
X-MLND-Event-Idevt_a1b2c3d4...ID unique de l'événement
X-MLND-Event-Version1Version du schéma du payload
X-MLND-Webhook-Idw4k8m2q7p9x3z1ID publique du webhook
X-MLND-Attempt1Numéro de tentative de livraison (commence à 1)
X-MLND-SourceinboundLe pipeline ayant produit l'événement
X-MLND-Inbound-Email-Idk7m2x9q4p1w8z3L'e-mail entrant concerné par cette livraison

Bonnes pratiques

  • Répondez rapidement : accusez réception du webhook avec 200 immédiatement et traitez l'e-mail de manière asynchrone dans une tâche de fond.
  • Vérifiez les signatures : validez toujours l'en-tête X-MLND-Signature pour empêcher les payloads falsifiés.
  • Gérez les doublons : la livraison est au moins une fois ; dédupliquez donc sur X-MLND-Event-Id (l'event_id du payload). Un rejeu manuel a un nouvel ID et data.replay_of désigne l'original ; voir Dédupliquer les événements.
  • Téléchargez les pièces jointes immédiatement : chaque download_url expire 15 minutes après la génération du payload ; ensuite, récupérez le message brut.
  • Utilisez HTTPS : les URL de webhook doivent utiliser HTTPS en production pour une livraison sécurisée du payload.

Réponses d'erreur

StatutCodeDescription
400INVALID_REQUESTAucune route ne correspond à l'e-mail
400INVALID_REQUESTLa route n'a pas de webhook configuré
400INVALID_REQUESTLe webhook de la route est inactif, activez-le avant de rejouer
404RESOURCE_NOT_FOUNDE-mail introuvable pour le rejeu

Étapes suivantes

  • Inbound Overview : configurer les paramètres entrants et consulter les e-mails
  • Inbound Routes : configurer les règles de routage pour la livraison des webhooks
  • Webhooks : configuration des webhooks sortants pour les événements d'e-mail

Sur cette page