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" },
"to": [{ "address": "support@myapp.ai" }],
"subject": "Re: invoice #1042",
"text": "Looks good — approved!",
"html": "<p>Looks good — approved!</p>",
"threadId": "<a1b2c3@mail.example.com>",
"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 } of the sender. |
to | array | Recipient address(es): [{ address }]. |
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. |
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. |
Handling the event
Respond with any 2xx status to acknowledge. Keep the handler fast
— do heavy work asynchronously so you don't hold the connection open.
webhook handler // Express
import express from "express";
const app = express();
app.use(express.json({ limit: "5mb" }));
app.post("/hooks/mailkite", (req, res) => {
const event = req.body;
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
from flask import Flask, request
app = Flask(__name__)
@app.post("/hooks/mailkite")
def mailkite():
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.
$event = json_decode(file_get_contents('php://input'), 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
@PostMapping("/hooks/mailkite")
public ResponseEntity<Void> mailkite(@RequestBody Map<String, Object> event) {
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) {
var event map[string]any
json.NewDecoder(r.Body).Decode(&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"
post "/hooks/mailkite" do
event = JSON.parse(request.body.read)
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
Verify the x-mailkite-signature header before trusting an event —
see Verifying signatures. Your webhook
URL is public, so signature checking is what proves an event really came from
MailKite.
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.
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",
});
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"
}'
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 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>"
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_…");
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>"
Next: verify webhook signatures so you
only act on real events.