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_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
| Field | Type | Notes |
|---|---|---|
id | string | Stable message id. Use it to make processing idempotent. |
type | string | Always email.received for inbound. |
from | object | { address, name? } of the sender. name is the display name — omitted 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. |
html | string · null | HTML body, already decoded. |
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 | Edge verdicts: spf, dkim, dmarc, spam (each may be null if not scored). |
attachments | array | See 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);
# 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.