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:
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:
{
"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:
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:
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:
{
"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:
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());
});import os
from flask import Flask, request, abort
from mailkite import verify_webhook, reply_ok
SECRET = os.environ["MAILKITE_WEBHOOK_SECRET"]
app = Flask(__name__)
@app.post("/hooks/mailkite")
def hook():
sig = request.headers.get("x-mailkite-signature", "")
if not verify_webhook(sig, request.get_data(), SECRET):
abort(401)
event = request.get_json()
# ...handle event["type"] == "email.received"
# Confirm receipt: returns the JSON body {"status":"ok"}.
return reply_ok(), 200, {"content-type": "application/json"}<?php
$mk = new \MailKite\Client(""); // no API key needed to verify
$secret = getenv("MAILKITE_WEBHOOK_SECRET");
$signature = $_SERVER["HTTP_X_MAILKITE_SIGNATURE"] ?? "";
$rawBody = file_get_contents("php://input");
if (!$mk->verifyWebhook($signature, $rawBody, $secret)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
// ...handle $event["type"] === "email.received"
header("content-type: application/json");
echo $mk->replyOk(); // {"status":"ok"}// Spring Boot
@PostMapping(value = "/hooks/mailkite", produces = "application/json")
public ResponseEntity<String> mailkite(
@RequestHeader("x-mailkite-signature") String signature,
@RequestBody byte[] rawBody) throws Exception {
MailKite mk = new MailKite(""); // no API key needed to verify
String body = new String(rawBody, StandardCharsets.UTF_8);
if (!mk.verifyWebhook(signature, body, System.getenv("MAILKITE_WEBHOOK_SECRET"))) {
return ResponseEntity.status(401).build();
}
// ...handle the event
return ResponseEntity.ok(mk.replyOk()); // {"status":"ok"}
}http.HandleFunc("/hooks/mailkite", func(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body)
sig := r.Header.Get("x-mailkite-signature")
if !mailkite.VerifyWebhook(sig, string(rawBody), secret) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var event map[string]any
json.Unmarshal(rawBody, &event)
// ...handle event["type"] == "email.received"
w.Header().Set("content-type", "application/json")
w.Write([]byte(mailkite.ReplyOk())) // {"status":"ok"}
})require "sinatra"
require "json"
require "mailkite"
post "/hooks/mailkite" do
raw_body = request.body.read
sig = request.env["HTTP_X_MAILKITE_SIGNATURE"]
halt 401 unless Mailkite.verify_webhook(sig, raw_body, ENV["MAILKITE_WEBHOOK_SECRET"])
event = JSON.parse(raw_body)
# ...handle event["type"] == "email.received"
content_type :json
Mailkite.reply_ok # {"status":"ok"}
end
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.