MailKite
Get your API key
All guides Inbound email parsing

Postmark Inbound

Inbound parsing turns the email your users send into structured data your app can act on: a reply becomes a support ticket, a forwarded PDF becomes a row in your database, an incoming message becomes a task for an agent. This guide walks through Postmark's inbound processing end to end, then shows the same flow on MailKite.

What you'll need

  • A domain you control, with access to its DNS records.
  • A public HTTPS endpoint to receive the webhook (a tunnel like ngrok works while testing).
  • A Postmark account with an inbound-enabled server for the first half.

Part 1 — Parse inbound email with Postmark

1. Choose how mail reaches Postmark

Postmark gives every server a unique inbound address the moment it's created — a hash mailbox at inbound.postmarkapp.com, like yourhash@inbound.postmarkapp.com (the hash is the server's InboundHash). Send test mail there and it's parsed immediately, no DNS required.

To receive on your own domain instead, use a dedicated subdomain — commonly inbound.yourdomain.com — and add an MX record pointing at Postmark's inbound host so mail lands there instead of your own server:

DNS — inbound.yourdomain.com
Host                      Type   Priority   Value
inbound.yourdomain.com MX 10 inbound.postmarkapp.com.

Then set that subdomain as the Inbound Domain in your server's settings. Any address on it — support@inbound.yourdomain.com, ada@inbound.yourdomain.com — now flows into Postmark. DNS can take a few hours to propagate, so add this early.

2. Set the inbound webhook URL

Tell Postmark where to POST parsed messages under Servers → your server → Settings → Inbound. Because the URL is public, secure it: prepend HTTP Basic credentials (https://user:pass@yourapp.com/hooks/postmark) over HTTPS, and/or restrict your firewall to Postmark's published inbound IP ranges.

Postmark now calls your URL every time a message arrives, retrying on any non-200 response.

3. Parse the payload

Postmark POSTs one JSON body per message. It carries the sender as both From and a structured FromFull, To/ToFull and Cc/CcFull, the Subject, MessageID, MailboxHash, both body formats, a signature-stripped StrippedTextReply, the raw Headers array, and any Attachments — each inlined as base64 Content:

POST /hooks/postmark
{
"FromName": "Ada Lovelace",
"From": "ada@example.com",
"FromFull": {
"Email": "ada@example.com",
"Name": "Ada Lovelace",
"MailboxHash": ""
},
"To": "\"Support\" <support@inbound.yourdomain.com>",
"ToFull": [
{ "Email": "support@inbound.yourdomain.com", "Name": "Support", "MailboxHash": "" }
],
"Cc": "",
"Subject": "Can't update my card",
"MessageID": "73e6d360-66eb-11e1-8e72-a8904824019b",
"MailboxHash": "",
"TextBody": "Hi — my payment keeps failing…",
"HtmlBody": "<p>Hi — my payment keeps failing…</p>",
"StrippedTextReply": "Hi — my payment keeps failing…",
"Headers": [
{ "Name": "X-Spam-Status", "Value": "No" },
{ "Name": "X-Spam-Score", "Value": "-0.1" }
],
"Attachments": [
{
"Name": "screenshot.png",
"Content": "iVBORw0KGgoAAAANSUhEUgAA…",
"ContentType": "image/png",
"ContentLength": 20418
}
]
}

Read the JSON, pull the fields you care about, and base64-decode each attachment:

handle-inbound.js
import express from "express";
import { writeFile } from "node:fs/promises";

const app = express();
app.use(express.json({ limit: "25mb" })); // attachments are inlined, so bodies get big

app.post("/hooks/postmark", async (req, res) => {
const email = req.body;
const body = email.StrippedTextReply ?? email.TextBody;
console.log("from", email.FromFull.Email, "·", email.Subject);
// ...open a ticket, reply, hand to an agent.

for (const att of email.Attachments ?? []) {
// Content is base64 right in the payload — decode it yourself:
const bytes = Buffer.from(att.Content, "base64");
await writeFile(`/tmp/${att.Name}`, bytes);
}
res.sendStatus(200);
});

That's inbound on Postmark: an inbound address or an MX to your subdomain, a secured webhook URL, and a handler that reads the JSON and decodes attachments from their base64 Content.

Part 2 — The same flow on MailKite

When we built inbound into MailKite, we wanted three things to be true: one record to receive on your whole domain, a payload you don't have to reshape, and a webhook you can actually trust. Here's the same parse-a-reply flow.

1. Point your domain at MailKite

One MX record makes every address on the domain receivable — no dedicated subdomain, no second record to prioritise:

DNS — yourdomain.com
Host              Type   Priority   Value
yourdomain.com MX 10 mx.mailkite.dev

The domain verifies as soon as that record resolves. See Domains & DNS for the SPF/DKIM/DMARC records that let you send from it too.

2. Register your webhook

Add your endpoint URL in the dashboard or via the API and MailKite POSTs an email.received event the moment mail arrives. Want specific addresses to hit specific endpoints? Add a route — otherwise a catch-all sends the whole domain to one webhook.

3. Parse the payload

The message arrives already decoded — flat from, to, subject, text, and html fields, edge auth verdicts (SPF/DKIM/DMARC/spam), and attachments as signed, time-limited links rather than base64 inlined into the body:

POST /hooks/mailkite
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "ada@example.com", "name": "Ada Lovelace" },
"to": [{ "address": "support@myapp.ai", "name": "Support" }],
"subject": "Re: invoice #1042",
"text": "Looks good — approved!",
"html": "<p>Looks good — approved!</p>",
"threadId": "<a1b2c3@mail.example.com>",
"receivedAt": 1785196800000,
"receivedAtIso": "2026-07-28T00:00:00.000Z",
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
"attachments": [
{
"id": "msg_2Hk9…:0",
"filename": "po.pdf",
"contentType": "application/pdf",
"size": 18213,
"url": "https://api.mailkite.dev/att/2Hk9…/0?exp=…&sig=…"
}
]
}

Because the URL is public, MailKite signs every request. Verify the signature over the raw body first, then handle the event — one message per call, no batch to unwrap:

handle-inbound
import express from "express";
import { MailKite } from "mailkite";

const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;
const app = express();
app.use("/hooks/mailkite", express.raw({ type: "application/json" }));

app.post("/hooks/mailkite", (req, res) => {
if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], req.body, SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// ...handle event.type === "email.received"
// Confirm receipt: returns the JSON body {"status":"ok"}.
res.type("application/json").send(MailKite.replyOk());
});
Install Docs →

The verifyWebhook helper recomputes the HMAC and rejects forged or stale events — see Verifying signatures for the header format and a no-SDK version. Attachments come as signed URLs valid for 7 days by default; on zero-retention domains MailKite inlines them as base64 instead. Fields and attachment links are documented in full under Inbound webhooks.

A little about MailKite

MailKite is programmable email for developers and AI agents. Publish one MX record and every address on your domain becomes a signed webhook of clean, decoded JSON — no IMAP, no polling, no MIME wrangling. The same API sends mail, and any inbox can be handed straight to an agent that reads and replies on its own. Inbound and receiving are on the free tier.

Next: Inbound webhooks for routing, retries, and attachments, or the Quickstart to get a verified domain and API key.