Eingehende Webhooks
Empfangen Sie geparste eingehende E-Mail-Daten über Webhook-Benachrichtigungen
Wenn eine eingehende E-Mail einer Route entspricht, deren actions webhook enthält, sendet
MailLand die geparste E-Mail an den Webhook dieser Route als JSON-POST-Anfrage. Diese Seite
beschreibt das Payload-Format, die Signaturprüfung und das Zustellverhalten.
Webhook-Payload
MailLand sendet eine POST-Anfrage an Ihre Webhook-URL mit folgendem JSON-Body:
{
"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"
}
}
}Der vollständige E-Mail-Text ist nie inline enthalten: nur eine gekürzte text_preview und die
Flags has_text/has_html. Es gibt kein Feld body.html.
Felder ohne Wert (name, in_reply_to, content_id, reason, data.matched_route und so weiter) werden
vollständig aus dem JSON weggelassen, statt als null gesendet zu werden. Das obige Beispiel
zeigt null zur besseren Lesbarkeit; verlassen Sie sich nicht darauf, dass der Schlüssel vorhanden ist.
Payload-Felder
| Feld | Typ | Beschreibung |
|---|---|---|
event | string | Immer inbound.received |
event_id | string | Ereignis-ID, identisch mit dem Header X-MLND-Event-Id. Sie ist pro eingehender Nachricht und Webhook stabil (evt_ plus 32 Hexadezimalzeichen): Jeder Wiederholungsversuch und jede erneute Veröffentlichung derselben Zustellung trägt dieselbe event_id, denselben Header und denselben Body. Ein manueller Replay trägt eine neue event_id und setzt data.replay_of. |
data.idempotency_key | string | {emailPublicId}_{routePublicId}, wenn eine Route zugetroffen hat, sonst nur {emailPublicId} (auch wenn die Route inzwischen nicht mehr existiert). Über Wiederholungen und manuelle Replays hinweg stabil |
data.replay_of | string | Nur bei einem manuellen Replay vorhanden: die event_id der ursprünglichen Zustellung. Bei normalen Zustellungen und bei Replays von Nachrichten, die vor Einführung stabiler Ereignis-IDs empfangen wurden, entfällt das Feld |
timestamp | string | ISO-8601-Zeitstempel der Ereigniserzeugung |
occurred_at | string | Zeitpunkt, zu dem die E-Mail empfangen wurde; derselbe Zeitpunkt wie data.email.received_at |
schema_version | number | Schemaversion der Nutzdaten, 1; siehe Versionierung |
data.email.id | string | Öffentliche ID der eingehenden E-Mail |
data.email.received_at | string | Empfangszeitpunkt der E-Mail |
data.email.size_bytes | number | Rohnachrichtengröße in Bytes |
data.email.envelope.from | string | SMTP-MAIL FROM-Adresse |
data.email.envelope.to | array | SMTP-RCPT TO-Adressen |
data.email.headers.from | object | { address, name }, geparst aus dem From-Header |
data.email.headers.to / .cc | array | Arrays von { address, name } |
data.email.headers.subject | string | Betreff der E-Mail |
data.email.headers.message_id | string | null | Message-ID-Header |
data.email.headers.in_reply_to | string | null | In-Reply-To-Header |
data.email.headers.reply_to | string | null | Reply-To-Header |
data.email.headers.date | string | null | Date-Header |
data.email.body.text_preview | string | null | Gekürzte Klartextvorschau |
data.email.body.has_text / .has_html | boolean | Ob ein Text- bzw. HTML-Teil vorhanden war |
data.email.attachments | array | Siehe unten |
data.email.authentication.spf/dkim/dmarc.result | string | Kleingeschriebenes Ergebnis, z. B. pass, fail, none |
data.email.spam.score | number | Spam-Score |
data.email.spam.is_spam | boolean | Ob die E-Mail als Spam eingestuft wurde |
data.email.spam.reason | string | null | Klassifizierungsgrund, falls verfügbar |
data.email.source.ip / .hostname | string | null | IP und Reverse-DNS-Hostname des sendenden Servers |
data.matched_route | object | null | { id, name } der zugetroffenen Route, oder null |
Anhänge
Anhangsinhalte sind nie inline enthalten. Jeder Eintrag in data.email.attachments enthält eine
download_url:
| Feld | Typ | Beschreibung |
|---|---|---|
id | string | Anhangs-ID. Stabil: Jeder Wiederholungsversuch und jeder Replay des Ereignisses trägt für denselben Anhang dieselbe id. Erkennen Sie daran einen bereits heruntergeladenen Anhang. download_url wird bei jedem Versuch neu erzeugt |
file_name | string | Ursprünglicher Dateiname |
content_type | string | MIME-Typ |
size_bytes | number | Größe in Bytes |
is_inline | boolean | Ob der Anhang inline referenziert wird (z. B. ein eingebettetes Bild) |
content_id | string | null | Content-ID für Inline-Anhänge |
download_url | string | null | Vorsignierte URL, gültig für 15 Minuten ab Erzeugung des Payloads |
download_url erfordert keinen Authorization-Header: die URL selbst ist das Anmeldedatum und
läuft 15 Minuten nach Erzeugung des Webhook-Payloads ab. Laden Sie Anhänge sofort beim Empfang des
Webhooks herunter. Ist die URL abgelaufen, holen Sie stattdessen die gesamte Rohnachricht:
GET https://api.mail.land/v1/transactional/inbound/emails/{emailId}/rawDies erfordert Ihren normalen Authorization-Header und liefert die ursprüngliche .eml-Datei
(Content-Type: message/rfc822): es gibt keinen Endpunkt zum Herunterladen einzelner Anhänge.
Signaturprüfung
Eingehende Webhooks werden genau wie jeder andere MailLand-Webhook signiert: gleicher Header, gleiches Schema, gleiche Behandlung des Secrets. Die maßgebliche Referenz mit einem durchgerechneten Beispiel und Verifizierungscode in acht Sprachen ist Signature Verification; alles dort gilt hier unverändert.
Kurz zusammengefasst:
X-MLND-Signature: t=1708171200,v1=<hex>tist ein Unix-Zeitstempel in Sekunden,v1ist ein HMAC-SHA256 in Kleinbuchstaben-Hex;- der signierte String ist
{timestamp}.{raw_body}: der Zeitstempel, ein wörtlicher Punkt, dann der Body genau wie empfangen, vor dem JSON-Parsing; - der Schlüssel ist das Signierungssecret des Webhooks, das einmalig bei der Erstellung angezeigt wird;
- vergleichen Sie in konstanter Zeit und lehnen Sie einen Zeitstempel ab, der älter als 300 Sekunden ist.
Es gibt keinen unsignierten Zustellweg: Hat ein Webhook kein verwendbares Secret, schlägt die
Zustellung fehl, statt sie unsigniert zu senden. Lehnen Sie jede Anfrage ab, die ohne
X-MLND-Signature-Header eintrifft.
Zustellverhalten
Antwortcodes
Ihr Endpunkt muss mit einem 2xx-Statuscode antworten, um den Empfang zu bestätigen. Alles
andere, ein Nicht-2xx-Status oder ein Timeout, zählt als fehlgeschlagener Zustellversuch.
| Ihre Antwort | MailLand-Aktion |
|---|---|
200–299 | Zustellung erfolgreich, keine Wiederholung |
| Jeder andere Status, oder keine Antwort innerhalb von 15 Sekunden | Zustellung fehlgeschlagen, Wiederholung (vorbehaltlich des unten genannten Versuchslimits) |
Wiederholungsrichtlinie
Ein fehlgeschlagener Versuch wird nach demselben Zeitplan wiederholt wie jedes andere Webhook-Ereignis, bis zu insgesamt 6 Versuchen pro Ereignis: der erste Versuch, dann Wiederholungen sofort, nach 1 Minute, 5 Minuten, 15 Minuten und 1 Stunde. Nach dem 6. fehlgeschlagenen Versuch wird die Zustellung als dauerhaft fehlgeschlagen markiert. Sie können sie manuell erneut zustellen lassen. Den vollständigen Zeitplan finden Sie unter Wiederholungslogik.
Ein Webhook-Endpunkt, der weniger als etwa anderthalb Stunden ausfällt, wird über dieses gesamte Fenster hinweg erneut angesprochen; ein längerer Ausfall endet in einer dauerhaft fehlgeschlagenen Zustellung. Gestalten Sie Ihren Endpunkt so, dass er auch im Fehlerfall zügig antwortet, und verlassen Sie sich bei längeren Ausfällen auf den manuellen Replay.
Jeder fehlgeschlagene Zustellungsversuch (nicht jedes Ereignis) zählt zur Gesamtzahl
aufeinanderfolgender Fehlschläge des Webhooks, und der Webhook wird automatisch deaktiviert, sobald
diese Gesamtzahl seinen eigenen pauseThreshold erreicht (wenn dieser auf einen Wert größer als 0
gesetzt ist), andernfalls den Standardwert von 10 aufeinanderfolgenden Fehlschlägen: danach werden
für neue eingehende E-Mails überhaupt keine Webhook-Zustellungen mehr eingeplant, und da es sich um
dieselbe Webhook-Zeile handelt, die auch für Nachrichtenereignisse verwendet wird, stoppen auch
diese. Beim Standardwert reichen zwei vollständig fehlgeschlagene eingehende E-Mails (je 6 Versuche)
aus, um ihn zu deaktivieren. Aktivieren Sie den Webhook
auf der Webhooks-Seite erneut und spielen Sie dann die verpassten E-Mails erneut ab.
Einen Webhook erneut zustellen
Den Webhook für eine bestimmte eingehende E-Mail erneut zustellen:
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"
}Dadurch wird eine dauerhafte Zustellung an den Webhook der passenden Route eingereiht, und 202 Accepted wird zurückgegeben, sobald diese Zeile committet ist, es wird nicht synchron gesendet. Der Replay erhält eine neue Ereignis-ID (neue event_id und neuer X-MLND-Event-Id) und führt die ID der ursprünglichen Zustellung in data.replay_of. Ihre Deduplizierung sieht ihn daher als neues Ereignis, Sie können ihn aber weiterhin dem Original zuordnen. data.idempotency_key bleibt gleich wie bei der ursprünglichen Zustellung. Die Anfrage enthält X-MLND-Source: inbound und X-MLND-Inbound-Email-Id; jede download_url eines Anhangs im Payload wird beim Senden signiert und ist ab diesem Zeitpunkt 15 Minuten gültig, nicht ab dem Aufruf des Replays.
Ereignisse deduplizieren
Die Zustellung eingehender Webhooks erfolgt mindestens einmal (at-least-once): Ein Wiederholungsversuch, ein Timeout auf Ihrer Seite nach bereits verarbeiteter Anfrage oder eine Übergabe zwischen Workern kann dasselbe Ereignis mehr als einmal zustellen. Empfänger müssen deduplizieren.
- Jeder eingehende Webhook trägt eine stabile Ereignis-ID in
event_idund im HeaderX-MLND-Event-Id. Sie ist pro eingehender Nachricht und Webhook deterministisch, sodass alle Zustellversuche derselben Nachricht an denselben Webhook dieselbe ID haben. - Deduplizieren Sie anhand dieser ID. Speichern Sie verarbeitete IDs und überspringen Sie jede bereits gesehene.
- Ein manueller Replay ist ein neues Ereignis. Er hat eine neue
event_idund ein Felddata.replay_ofmit der ursprünglichen ID. Soll ein Replay ignoriert werden, wenn das Original bereits verarbeitet wurde, prüfen Sie zusätzlichdata.replay_ofgegen Ihre gespeicherten IDs.
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();
}
});Die ID und das Einreihen der Arbeit in einer Transaktion zu speichern ist wichtig: Würde die ID zuerst gespeichert und das Einreihen schlüge fehl, würde der Wiederholungsversuch von MailLand als Duplikat übersprungen und die E-Mail ginge verloren.
Bewahren Sie verarbeitete IDs mindestens so lange auf wie Ihr längstes Wiederholungsfenster plus jeden Replay, den Sie auslösen könnten.
Webhook-Header
Jede Webhook-Anfrage enthält folgende Header:
| Header | Beispiel | Beschreibung |
|---|---|---|
Content-Type | application/json | |
X-MLND-Signature | t=1708171200,v1=abc123... | Zeitstempel und HMAC-SHA256-Signatur |
X-MLND-Event | inbound.received | Ereignistyp |
X-MLND-Event-Id | evt_a1b2c3d4... | Eindeutige Ereignis-ID |
X-MLND-Event-Version | 1 | Payload-Schemaversion |
X-MLND-Webhook-Id | w4k8m2q7p9x3z1 | Öffentliche ID des Webhooks |
X-MLND-Attempt | 1 | Zustellversuchsnummer (beginnend bei 1) |
X-MLND-Source | inbound | Welche Pipeline das Ereignis erzeugt hat |
X-MLND-Inbound-Email-Id | k7m2x9q4p1w8z3 | Die eingehende E-Mail, um die es bei dieser Zustellung geht |
Best Practices
- Schnell antworten: bestätigen Sie den Webhook sofort mit
200und verarbeiten Sie die E-Mail asynchron in einem Hintergrundjob. - Signaturen prüfen: validieren Sie immer den
X-MLND-Signature-Header, um gefälschte Payloads zu verhindern. - Duplikate behandeln: die Zustellung erfolgt mindestens einmal, daher per
X-MLND-Event-Id(dieevent_idim Payload) deduplizieren. Ein manueller Replay hat eine neue ID unddata.replay_ofverweist auf die ursprüngliche; siehe Ereignisse deduplizieren. - Anhänge sofort herunterladen: jede
download_urlläuft 15 Minuten nach Erzeugung des Payloads ab; danach die Rohnachricht abrufen. - HTTPS verwenden: Webhook-URLs müssen in Produktion HTTPS verwenden, um die Payload-Zustellung abzusichern.
Fehlerantworten
| Status | Code | Beschreibung |
|---|---|---|
400 | INVALID_REQUEST | Keine Route für die E-Mail gefunden |
400 | INVALID_REQUEST | Die Route hat keinen konfigurierten Webhook |
400 | INVALID_REQUEST | Der Webhook der Route ist inaktiv, vor dem Replay aktivieren |
404 | RESOURCE_NOT_FOUND | E-Mail für Replay nicht gefunden |
Nächste Schritte
- Inbound Overview: Einstellungen für eingehende E-Mails konfigurieren und E-Mails anzeigen
- Inbound Routes: Routing-Regeln für die Webhook-Zustellung einrichten
- Webhooks: Konfiguration ausgehender Webhooks für E-Mail-Ereignisse