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 :
{
"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
| Champ | Type | Description |
|---|---|---|
event | string | Toujours inbound.received |
event_id | string | ID 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_key | string | {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_of | string | Pré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 |
timestamp | string | Horodatage ISO 8601 de la génération de l'événement |
occurred_at | string | Moment de réception de l'e-mail ; le même instant que data.email.received_at |
schema_version | number | Version du schéma de la charge utile, 1 ; voir Versionnage |
data.email.id | string | ID publique de l'e-mail entrant |
data.email.received_at | string | Date de réception de l'e-mail |
data.email.size_bytes | number | Taille du message brut en octets |
data.email.envelope.from | string | Adresse SMTP MAIL FROM |
data.email.envelope.to | array | Adresses SMTP RCPT TO |
data.email.headers.from | object | { address, name } analysé depuis l'en-tête From |
data.email.headers.to / .cc | array | Tableaux de { address, name } |
data.email.headers.subject | string | Objet de l'e-mail |
data.email.headers.message_id | string | null | En-tête Message-ID |
data.email.headers.in_reply_to | string | null | En-tête In-Reply-To |
data.email.headers.reply_to | string | null | En-tête Reply-To |
data.email.headers.date | string | null | En-tête Date |
data.email.body.text_preview | string | null | Aperçu tronqué en texte brut |
data.email.body.has_text / .has_html | boolean | Si une partie texte ou HTML était présente |
data.email.attachments | array | Voir ci-dessous |
data.email.authentication.spf/dkim/dmarc.result | string | Résultat en minuscules, ex. pass, fail, none |
data.email.spam.score | number | Score de spam |
data.email.spam.is_spam | boolean | Si l'e-mail a été classé comme spam |
data.email.spam.reason | string | null | Motif de classification, lorsque disponible |
data.email.source.ip / .hostname | string | null | IP et nom d'hôte DNS inverse du serveur émetteur |
data.matched_route | object | 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 :
| Champ | Type | Description |
|---|---|---|
id | string | ID 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_name | string | Nom de fichier d'origine |
content_type | string | Type MIME |
size_bytes | number | Taille en octets |
is_inline | boolean | Si la pièce jointe est référencée en ligne (ex. une image intégrée) |
content_id | string | null | Content-ID pour les pièces jointes en ligne |
download_url | string | null | URL 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}/rawCela 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>test un horodatage Unix en secondes,v1est 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éponse | Action de MailLand |
|---|---|
200–299 | Livraison réussie, pas de nouvelle tentative |
| Tout autre statut, ou aucune réponse dans les 15 secondes | Livraison é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-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"
}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_idet dans l'en-têteX-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_idet un champdata.replay_ofcontenant l'ID d'origine. Si vous voulez qu'un rejeu soit ignoré lorsque l'original a déjà été traité, comparez aussidata.replay_ofà vos ID stockés.
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ête | Exemple | Description |
|---|---|---|
Content-Type | application/json | |
X-MLND-Signature | t=1708171200,v1=abc123... | Horodatage et signature HMAC-SHA256 |
X-MLND-Event | inbound.received | Type d'événement |
X-MLND-Event-Id | evt_a1b2c3d4... | ID unique de l'événement |
X-MLND-Event-Version | 1 | Version du schéma du payload |
X-MLND-Webhook-Id | w4k8m2q7p9x3z1 | ID publique du webhook |
X-MLND-Attempt | 1 | Numéro de tentative de livraison (commence à 1) |
X-MLND-Source | inbound | Le pipeline ayant produit l'événement |
X-MLND-Inbound-Email-Id | k7m2x9q4p1w8z3 | L'e-mail entrant concerné par cette livraison |
Bonnes pratiques
- Répondez rapidement : accusez réception du webhook avec
200immé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-Signaturepour 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_iddu payload). Un rejeu manuel a un nouvel ID etdata.replay_ofdésigne l'original ; voir Dédupliquer les événements. - Téléchargez les pièces jointes immédiatement : chaque
download_urlexpire 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
| Statut | Code | Description |
|---|---|---|
400 | INVALID_REQUEST | Aucune route ne correspond à l'e-mail |
400 | INVALID_REQUEST | La route n'a pas de webhook configuré |
400 | INVALID_REQUEST | Le webhook de la route est inactif, activez-le avant de rejouer |
404 | RESOURCE_NOT_FOUND | E-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