MailLandDocs

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:

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

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

FieldTypeDescription
eventstringAlways inbound.received
event_idstringEvent 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_keystring{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_ofstringPresent 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
timestampstringISO 8601 timestamp of when the event was generated
occurred_atstringWhen the email was received; the same instant as data.email.received_at
schema_versionnumberPayload schema version, 1; see Versioning
data.email.idstringPublic ID of the inbound email
data.email.received_atstringWhen the email was received
data.email.size_bytesnumberRaw message size in bytes
data.email.envelope.fromstringSMTP MAIL FROM address
data.email.envelope.toarraySMTP RCPT TO addresses
data.email.headers.fromobject{ address, name } parsed from the From header
data.email.headers.to / .ccarrayArrays of { address, name }
data.email.headers.subjectstringEmail subject
data.email.headers.message_idstring | nullMessage-ID header
data.email.headers.in_reply_tostring | nullIn-Reply-To header
data.email.headers.reply_tostring | nullReply-To header
data.email.headers.datestring | nullDate header
data.email.body.text_previewstring | nullTruncated plain-text preview
data.email.body.has_text / .has_htmlbooleanWhether a text or HTML part was present
data.email.attachmentsarraySee below
data.email.authentication.spf/dkim/dmarc.resultstringLowercase result, e.g. pass, fail, none
data.email.spam.scorenumberSpam score
data.email.spam.is_spambooleanWhether the email was classified as spam
data.email.spam.reasonstring | nullClassification reason, when available
data.email.source.ip / .hostnamestring | nullSending server's IP and reverse-DNS hostname
data.matched_routeobject | 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:

FieldTypeDescription
idstringAttachment 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_namestringOriginal file name
content_typestringMIME type
size_bytesnumberSize in bytes
is_inlinebooleanWhether the attachment is referenced inline (e.g. an embedded image)
content_idstring | nullContent-ID for inline attachments
download_urlstring | nullPre-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}/raw

This 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>
  • t is a Unix timestamp in seconds, v1 is 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 ResponseMailLand Action
200–299Delivery successful, no retry
Any other status, or no response within 15 secondsDelivery 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-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"
}

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_id and the X-MLND-Event-Id header. 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_id and a data.replay_of field holding the original ID. If you want a replay to be ignored when the original was already processed, also check data.replay_of against your stored IDs.
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();
  }
});

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:

HeaderExampleDescription
Content-Typeapplication/json
X-MLND-Signaturet=1708171200,v1=abc123...Timestamp and HMAC-SHA256 signature
X-MLND-Eventinbound.receivedEvent type
X-MLND-Event-Idevt_a1b2c3d4...Unique event ID
X-MLND-Event-Version1Payload schema version
X-MLND-Webhook-Idw4k8m2q7p9x3z1The webhook's public ID
X-MLND-Attempt1Delivery attempt number (1-based)
X-MLND-SourceinboundWhich pipeline produced the event
X-MLND-Inbound-Email-Idk7m2x9q4p1w8z3The inbound email this delivery is about

Best Practices

  • Respond quickly: acknowledge the webhook with 200 immediately and process the email asynchronously in a background job.
  • Verify signatures: always validate the X-MLND-Signature header to prevent spoofed payloads.
  • Handle duplicates: delivery is at-least-once, so deduplicate by X-MLND-Event-Id (the payload event_id). A manual replay has a new ID and data.replay_of set to the original; see Deduplicating Events.
  • Download attachments immediately: each download_url expires 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

StatusCodeDescription
400INVALID_REQUESTNo route matched the email
400INVALID_REQUESTThe route has no webhook configured
400INVALID_REQUESTThe route's webhook is inactive, activate it before replaying
404RESOURCE_NOT_FOUNDEmail not found for replay

Next Steps

On this page