Inbound Webhooks
Receive parsed inbound email data via webhook notifications
When an inbound email matches a route whose actions include webhook, MailLand sends the parsed
email to that route's webhook as a JSON POST request. This page covers the payload format,
signature verification, and delivery behavior.
Webhook Payload
MailLand sends a POST request to your webhook URL with the following 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"
}
}
}The full email body is never included inline: only a truncated text_preview and the has_text/
has_html flags. There is no body.html field.
Fields without a value (name, in_reply_to, content_id, reason, data.matched_route, and so on) are
omitted from the JSON entirely rather than sent as null. The sample above shows null for
readability; do not rely on the key being present.
Payload Fields
| Field | Type | Description |
|---|---|---|
event | string | Always inbound.received |
event_id | string | Event ID, equal to the X-MLND-Event-Id header. It is stable per inbound message and webhook (evt_ followed by 32 hex characters): every retry and re-publish of the same delivery carries the same event_id, header and body. A manual replay carries a new event_id and sets data.replay_of. |
data.idempotency_key | string | {emailPublicId}_{routePublicId} when a route matched, or just {emailPublicId} when it did not (or the route no longer exists). Stable across retries and manual replays |
data.replay_of | string | Present only on a manual replay: the event_id that the original delivery carried. Omitted on normal deliveries, and on replays of messages received before stable event ids were introduced |
timestamp | string | ISO 8601 timestamp of when the event was generated |
occurred_at | string | When the email was received; the same instant as data.email.received_at |
schema_version | number | Payload schema version, 1; see Versioning |
data.email.id | string | Public ID of the inbound email |
data.email.received_at | string | When the email was received |
data.email.size_bytes | number | Raw message size in bytes |
data.email.envelope.from | string | SMTP MAIL FROM address |
data.email.envelope.to | array | SMTP RCPT TO addresses |
data.email.headers.from | object | { address, name } parsed from the From header |
data.email.headers.to / .cc | array | Arrays of { address, name } |
data.email.headers.subject | string | Email subject |
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 | Truncated plain-text preview |
data.email.body.has_text / .has_html | boolean | Whether a text or HTML part was present |
data.email.attachments | array | See below |
data.email.authentication.spf/dkim/dmarc.result | string | Lowercase result, e.g. pass, fail, none |
data.email.spam.score | number | Spam score |
data.email.spam.is_spam | boolean | Whether the email was classified as spam |
data.email.spam.reason | string | null | Classification reason, when available |
data.email.source.ip / .hostname | string | null | Sending server's IP and reverse-DNS hostname |
data.matched_route | object | null | { id, name } of the route that matched, or null |
Attachments
Attachment content is never included inline. Each entry in data.email.attachments carries a
download_url:
| Field | Type | Description |
|---|---|---|
id | string | Attachment ID. Stable: every retry and replay of the event carries the same id for the same attachment, so use it to recognise an attachment you already downloaded. download_url is minted again on every attempt |
file_name | string | Original file name |
content_type | string | MIME type |
size_bytes | number | Size in bytes |
is_inline | boolean | Whether the attachment is referenced inline (e.g. an embedded image) |
content_id | string | null | Content-ID for inline attachments |
download_url | string | null | Pre-signed URL, valid for 15 minutes from when the payload was generated |
download_url requires no Authorization header: the URL itself is the credential, and it expires
15 minutes after the webhook payload is generated. Download attachments immediately when you
receive the webhook. If the URL has expired, fetch the whole raw message instead:
GET https://api.mail.land/v1/transactional/inbound/emails/{emailId}/rawThis requires your normal Authorization header and returns the original .eml file
(Content-Type: message/rfc822): there is no per-attachment download endpoint.
Signature Verification
Inbound webhooks are signed exactly like every other MailLand webhook: same header, same scheme, same secret handling. The canonical reference, with a worked example and verifier code in eight languages, is Signature Verification; everything there applies here unchanged.
In short:
X-MLND-Signature: t=1708171200,v1=<hex>tis a Unix timestamp in seconds,v1is a lowercase-hex HMAC-SHA256;- the signed string is
{timestamp}.{raw_body}: the timestamp, a literal period, then the body exactly as received, before JSON parsing; - the key is the webhook's signing secret, shown once when the webhook is created;
- compare in constant time and reject a timestamp more than 300 seconds old.
There is no unsigned delivery path: if a webhook has no usable secret, MailLand fails the delivery
rather than sending it without a signature. Reject any request that arrives without an
X-MLND-Signature header.
Delivery Behavior
Response Codes
Your endpoint must respond with a 2xx status code to acknowledge receipt. Anything else, a
non-2xx status or a timeout, counts as a failed delivery attempt.
| Your Response | MailLand Action |
|---|---|
200–299 | Delivery successful, no retry |
| Any other status, or no response within 15 seconds | Delivery failed, retried (subject to the attempt limit below) |
Retry Policy
A failed attempt is retried on the same schedule as every other webhook event, up to 6 attempts total per event: the first attempt, then retries immediately, after 1 minute, 5 minutes, 15 minutes and 1 hour. After the 6th failed attempt, the delivery is marked permanently failed. You can replay it manually. See Retry Logic for the full schedule.
A webhook endpoint that is down for less than about an hour and a half is retried across that whole window, but a longer outage ends in a permanently failed delivery. Design your endpoint to respond quickly even on error, and rely on manual replay for outages longer than that.
Every failed delivery attempt (not every event) counts toward the webhook's consecutive-failure
total, and the webhook is automatically disabled once that total reaches its own pauseThreshold
(when set to a value greater than 0) or, otherwise, the default of 10 consecutive failed attempts:
after that, new inbound emails queue no webhook delivery for it at all, and this applies to the same
webhook row used for message events, so those stop too. At the default threshold, two fully-failed
inbound emails (6 attempts each) are enough to disable it. Re-enable the webhook on the Webhooks
page, then replay the missed emails.
Replay a Webhook
Re-deliver the webhook for a specific inbound email:
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"
}This queues one durable delivery to the route's matched webhook and returns 202 Accepted once that
row has committed, it does not send synchronously. The replay gets a new event ID (a new event_id and
X-MLND-Event-Id) and carries the original delivery's ID in data.replay_of, so your deduplication treats
it as a new event while you can still tie it to the original. data.idempotency_key stays the same as the
original delivery. The request carries X-MLND-Source: inbound and X-MLND-Inbound-Email-Id, and any
attachment download_url in the payload is signed at send time: valid for 15 minutes from then, not
from when you called replay.
Deduplicating Events
Inbound webhook delivery is at-least-once: a retry, a timeout on your side after you already processed the request, or a worker handover can deliver the same event more than once. Receivers must deduplicate.
- Every inbound webhook carries a stable event ID in
event_idand theX-MLND-Event-Idheader. It is deterministic per inbound message and webhook, so every delivery attempt of the same message to the same webhook has the same ID. - Deduplicate by that ID. Store the IDs you have processed and skip any ID you have seen.
- A manual replay is a new event. It has a new
event_idand adata.replay_offield holding the original ID. If you want a replay to be ignored when the original was already processed, also checkdata.replay_ofagainst your stored 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();
}
});Recording the ID and enqueuing the work in one transaction matters: if the ID were saved first and the enqueue then failed, MailLand's retry would be skipped as a duplicate and the email would be lost.
Keep processed IDs for at least as long as your longest retry window plus any replay you may trigger.
Webhook Headers
Every webhook request includes these headers:
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | |
X-MLND-Signature | t=1708171200,v1=abc123... | Timestamp and HMAC-SHA256 signature |
X-MLND-Event | inbound.received | Event type |
X-MLND-Event-Id | evt_a1b2c3d4... | Unique event ID |
X-MLND-Event-Version | 1 | Payload schema version |
X-MLND-Webhook-Id | w4k8m2q7p9x3z1 | The webhook's public ID |
X-MLND-Attempt | 1 | Delivery attempt number (1-based) |
X-MLND-Source | inbound | Which pipeline produced the event |
X-MLND-Inbound-Email-Id | k7m2x9q4p1w8z3 | The inbound email this delivery is about |
Best Practices
- Respond quickly: acknowledge the webhook with
200immediately and process the email asynchronously in a background job. - Verify signatures: always validate the
X-MLND-Signatureheader to prevent spoofed payloads. - Handle duplicates: delivery is at-least-once, so deduplicate by
X-MLND-Event-Id(the payloadevent_id). A manual replay has a new ID anddata.replay_ofset to the original; see Deduplicating Events. - Download attachments immediately: each
download_urlexpires 15 minutes after the payload is generated; after that, fetch the raw message instead. - Use HTTPS: webhook URLs must use HTTPS in production for secure payload delivery.
Error Responses
| Status | Code | Description |
|---|---|---|
400 | INVALID_REQUEST | No route matched the email |
400 | INVALID_REQUEST | The route has no webhook configured |
400 | INVALID_REQUEST | The route's webhook is inactive, activate it before replaying |
404 | RESOURCE_NOT_FOUND | Email not found for replay |
Next Steps
- Inbound Overview: configure inbound settings and view emails
- Inbound Routes: set up routing rules for webhook delivery
- Webhooks: outbound webhook configuration for email events