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:
{
"id": "msg_4f3c1a9e2b7d48e1a05c6f8b3d2e7a91",
"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>",
"textFromHtml": null,
"threadId": "<a1b2c3@mail.example.com>",
"receivedAt": 1785196800000,
"receivedAtIso": "2026-07-28T00:00:00.000Z",
"auth": {
"spf": "pass", "dkim": "pass", "dmarc": "pass",
"spam": "ham", "spamScore": 0.05, "spamSignals": []
},
"attachments": [
{
"id": "msg_4f3c1a9e2b7d48e1a05c6f8b3d2e7a91:0",
"filename": "po.pdf",
"contentType": "application/pdf",
"size": 18213,
"contentId": null,
"disposition": "attachment",
"url": "https://api.mailkite.dev/att/4f3c1a9e2b7d48e1a05c6f8b3d2e7a91/0?exp=…&sig=…",
"content": null
}
]
} Fields
| Field | Type | Notes |
|---|---|---|
id | string | Stable message id — msg_ + a v4 UUID with the hyphens removed. Use it to make processing idempotent. See below. |
type | string | Always email.received for inbound. |
from | object | { address, name } of the sender. name is the display name — null when the message had none. See below. |
to | array | Recipient address(es): [{ address, name }]. |
subject | string · null | Decoded subject line. |
text | string · null | Plain-text body, already decoded, exactly as the sender sent it. |
html | string · null | HTML body, already decoded. |
textFromHtml | string · null | Plain text we derived from html — set only when text is null, so text ?? textFromHtml always gives you a body. See below. |
threadId | string · null | In-Reply-To or Message-ID — group a conversation. |
receivedAt | integer | When the message arrived, Unix epoch milliseconds (UTC). See below. |
receivedAtIso | string | The same instant as ISO 8601, e.g. 2026-07-28T00:00:00.000Z. |
auth | object | spf, dkim, dmarc, spam, spamScore, spamSignals. Each is null when that check didn't run — null is not a pass. |
attachments | array | See below — each has a short-lived signed URL. |
Every field is always present
The payload has one rule for missing data: every field above is always
in the body, and a value we don't have is null. Nothing is ever omitted, at
any nesting level — so you write one check, not two, and the shape never changes with the
message. null means we don't know; it is never a stand-in for a value
we verified. In particular a null auth verdict is not a pass.
The id format
id is the literal prefix msg_ followed by a
version-4 UUID with its hyphens removed — 32 lowercase hex characters, 36
in total, matching /^msg_[0-9a-f]{32}$/. It's identical across retries and
replays, which is what makes it a safe idempotency key.
To store it as a native UUID, drop the prefix and put the hyphens back at 8-4-4-4-12 — the
transform is lossless in both directions:
const uuid = id.slice(4).replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, "$1-$2-$3-$4-$5");
Every MailKite id is built this way and differs only in the prefix
(rte_, dom_, usr_, …). The prefix is deliberate: it
makes “this is a route id, not a message id” obvious at a glance.
Getting a body every time
Roughly a fifth of real inbound mail has no plain-text part at all, so text is
null and only html arrives. textFromHtml closes that
gap: it's plain text we generated from the HTML, and it's populated only when
text is null — so the body never ships twice and one line always gets you
something readable:
const body = event.text ?? event.textFromHtml;
text and html stay exactly what the sender sent. We don't write
into them, because a conversion of ours presented as the sender's words is a different
thing from the message you received.
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 null — always present, never omitted — 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
null. So a non-null 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);
# Flask
import os
from flask import Flask, request, abort
from mailkite import verify_webhook
SECRET = os.environ["MAILKITE_WEBHOOK_SECRET"]
app = Flask(__name__)
@app.post("/hooks/mailkite")
def mailkite():
# Reject anything that isn't a genuine, fresh MailKite delivery (RAW body).
sig = request.headers.get("x-mailkite-signature", "")
if not verify_webhook(sig, request.get_data(), SECRET):
abort(401)
event = request.get_json()
if event["type"] == "email.received":
print("from", event["from"]["address"], "·", event["subject"])
# ...create a ticket, reply, store it, hand to an agent.
return "", 200 # ack fast; do heavy work out of band
<?php
// Any framework — raw PHP shown.
$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"); // the RAW request body
if (!$mk->verifyWebhook($signature, $rawBody, $secret)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
if (($event['type'] ?? '') === 'email.received') {
error_log("from {$event['from']['address']} · {$event['subject']}");
// ...create a ticket, reply, store it, hand to an agent.
}
http_response_code(200); // ack fast; do heavy work out of band
// Spring Boot — capture the RAW body to verify the exact bytes received.
@PostMapping("/hooks/mailkite")
public ResponseEntity<Void> 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();
}
Map<String, Object> event = new ObjectMapper().readValue(body, new TypeReference<>() {});
if ("email.received".equals(event.get("type"))) {
System.out.println("from " + event.get("from") + " · " + event.get("subject"));
// ...create a ticket, reply, store it, hand to an agent.
}
return ResponseEntity.ok().build(); // ack fast; heavy work out of band
}
// net/http
http.HandleFunc("/hooks/mailkite", func(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body) // the RAW request 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)
if event["type"] == "email.received" {
log.Println("from", event["from"], "·", event["subject"])
// ...create a ticket, reply, store it, hand to an agent.
}
w.WriteHeader(http.StatusOK) // ack fast; heavy work out of band
})
# Sinatra
require "sinatra"
require "json"
require "mailkite"
post "/hooks/mailkite" do
raw_body = request.body.read # the RAW request body
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)
if event["type"] == "email.received"
puts "from #{event['from']['address']} · #{event['subject']}"
# ...create a ticket, reply, store it, hand to an agent.
end
status 200 # ack fast; do heavy work out of band
end
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
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 Dashboard → Routes → [ + New route ]
Match support@myapp.ai
Action Webhook
Destination https://myapp.ai/hooks/support
Click [ Save ]
Send mail for support@myapp.ai to https://myapp.ai/hooks/support
await mk.createRoute({
match: "support@myapp.ai",
action: "webhook",
destination: "https://myapp.ai/hooks/support",
});
mk.createRoute({
"match": "support@myapp.ai",
"action": "webhook",
"destination": "https://myapp.ai/hooks/support",
})
$mk->createRoute([
'match' => 'support@myapp.ai',
'action' => 'webhook',
'destination' => 'https://myapp.ai/hooks/support',
]);
mk.createRoute(Map.of(
"match", "support@myapp.ai",
"action", "webhook",
"destination", "https://myapp.ai/hooks/support"
));
_, err := mk.CreateRoute(map[string]any{
"match": "support@myapp.ai",
"action": "webhook",
"destination": "https://myapp.ai/hooks/support",
})
mk.createRoute(
"match" => "support@myapp.ai",
"action" => "webhook",
"destination" => "https://myapp.ai/hooks/support"
)
curl https://api.mailkite.dev/api/routes \
-H "Authorization: Bearer <session-token>" \
-H "Content-Type: application/json" \
-d '{
"match": "support@myapp.ai",
"action": "webhook",
"destination": "https://myapp.ai/hooks/support"
}'
Connect
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
A route has three parts:
Field Values Meaning matchsupport@domain or *@domainWhich recipient(s) this rule applies to. actionwebhook · forward · store · dropWhat to do with a match. Defaults to webhook. destinationURL or address Required 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 Dashboard → Domains → myapp.ai → Webhook
Click [ Send test event ]
Send a test webhook event for myapp.ai
await mk.testWebhook("dom_…");
mk.testWebhook("dom_…")
$mk->testWebhook('dom_…');
mk.testWebhook("dom_…");
_, err := mk.TestWebhook("dom_…")
mk.testWebhook("dom_…")
curl -X POST https://api.mailkite.dev/api/domains/dom_…/webhook/test \
-H "Authorization: Bearer <session-token>"
Connect
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
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 Dashboard → Messages → the failed delivery
Click [ Retry ]
Retry the failed delivery dlv_…
await mk.retryDelivery("dlv_…");
mk.retryDelivery("dlv_…")
$mk->retryDelivery('dlv_…');
mk.retryDelivery("dlv_…");
_, err := mk.RetryDelivery("dlv_…")
mk.retryDelivery("dlv_…")
curl -X POST https://api.mailkite.dev/api/deliveries/dlv_…/retry \
-H "Authorization: Bearer <session-token>"
Connect
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Next: verify webhook signatures so you
only act on real events.