Get your API key
Receiving email

Inbound webhooks

When mail arrives at any address on a verified domain, MailKite parses it and POSTs a JSON event to your webhook. No IMAP, no polling, no MIME wrangling — just the whole message, decoded.

The email.received event

This is exactly what hits your endpoint the moment an email arrives:

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=…"
}
]
}

Fields

FieldTypeNotes
idstringStable message id. Use it to make processing idempotent.
typestringAlways email.received for inbound.
fromobject{ address, name? } of the sender. name is the display name — omitted when the message had none. See below.
toarrayRecipient address(es): [{ address, name? }].
subjectstring · nullDecoded subject line.
textstring · nullPlain-text body, already decoded.
htmlstring · nullHTML body, already decoded.
threadIdstring · nullIn-Reply-To or Message-ID — group a conversation.
receivedAtintegerWhen the message arrived, Unix epoch milliseconds (UTC). See below.
receivedAtIsostringThe same instant as ISO 8601, e.g. 2026-07-28T00:00:00.000Z.
authobjectEdge verdicts: spf, dkim, dmarc, spam (each may be null if not scored).
attachmentsarraySee below — each has a short-lived signed URL.

The full machine-readable contract for this body is published as JSON Schema: email-received-event.json — point a validator straight at it, or open the API reference and flip that endpoint's Docs / JSON pill.

Addresses and display names

address is the address from the SMTP envelope — the one we actually routed on. name is the display name decoded from the message's From: / To: header, so Ada Lovelace <ada@example.com> arrives as { "address": "ada@example.com", "name": "Ada Lovelace" }.

name is omitted — never null — when the message carried no display name, or when the header names a different address than the envelope did. That second case is normal for mailing lists and forwarders, where the envelope sender is a bounce address and the header names a person. Rather than label one identity with the other's name, we send the address alone. So name is always a name for the address beside it.

Display names are asserted by the sender and verified by nobody — "PayPal Support" <attacker@evil.tld> is trivial to send. Treat name as presentation, never as identity: authorize on address, and check the auth block before you trust either.

Arrival time

receivedAt is when we accepted the message, not when this POST was made. It's read from the stored message, so an automatic retry hours later — or a replay months later — reports the same instant it did on the first attempt. That's what makes it safe to use as the message's timestamp in your own system.

Don't confuse it with the sender's Date: header. That's whatever the sending client asserted — routinely skewed, and trivially forged. It stays available in the stored message's headers; receivedAt is the value we observed and stand behind.

Handling the event

Verify the signature first so you only act on real events, respond with any 2xx status to acknowledge, and keep the handler fast — do heavy work asynchronously so you don't hold the connection open.

webhook handler
// Express
import express from "express";
import { MailKite } from "mailkite";

const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;
const app = express();

// Capture the RAW body — verify the exact bytes, not a re-serialized object.
app.use("/hooks/mailkite", express.raw({ type: "application/json" }));

app.post("/hooks/mailkite", (req, res) => {
// Reject anything that isn't a genuine, fresh MailKite delivery.
if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], req.body, SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "email.received") {
console.log("from", event.from.address, "·", event.subject);
// ...create a ticket, reply, store it, hand to an agent.
}
res.sendStatus(200); // ack fast; do heavy work out of band
});

app.listen(3000);
Install Docs →
Each handler above calls the SDK's verifyWebhook helper — it recomputes the HMAC over the raw body and rejects forged or stale events. Your webhook URL is public, so this is what proves a request really came from MailKite. See Verifying signatures for the header format and a no-SDK version.

Attachments

Attachments aren't inlined. Each entry carries a url: a signed, time-limited GET link you can fetch with no credentials. Links stay valid for 7 days, after which the stored object is deleted.

attachment
{
"id": "msg_2Hk9…:0",
"filename": "po.pdf",
"contentType": "application/pdf",
"size": 18213,
"url": "https://api.mailkite.dev/att/2Hk9…/0?exp=…&sig=…"
}

The link is bound to that exact object and self-expiring, so a leaked URL can't be repurposed or extended. Download what you need to keep within the retention window.

On a domain with zero-retention passthrough or at-rest encryption, there's no stored object to link to, so attachments arrive inlined as base64 content instead of a url. Handle both: if content is present, decode it; otherwise fetch url.

Routing: choose which address goes where

By default a catch-all sends every address on a domain to the domain's webhook. To direct specific addresses to specific endpoints, create routes:

create a route
await mk.createRoute({
match: "support@myapp.ai",
action: "webhook",
destination: "https://myapp.ai/hooks/support",
});
Install Docs →

A route has three parts:

FieldValuesMeaning
matchsupport@domain or *@domainWhich recipient(s) this rule applies to.
actionwebhook · forward · store · dropWhat to do with a match. Defaults to webhook.
destinationURL or addressRequired for webhook (a URL) and forward (an address).

Whatever the action, every inbound message is also stored so you can list, inspect, and replay it later — see Messages.

Test events & retries

Send a representative email.received event to your endpoint at any time — it's signed exactly like a live delivery:

send a test event
await mk.testWebhook("dom_…");
Install Docs →

Each delivery attempt is recorded with its HTTP status. If your endpoint was down or returned a non-2xx, re-deliver the stored message — the exact same payload — to the same destination:

retry a delivery
await mk.retryDelivery("dlv_…");
Install Docs →

Next: verify webhook signatures so you only act on real events.