Webhook delivery & reliability
Every inbound email becomes a signed POST to your endpoint. If
that delivery fails, MailKite retries it on a backoff schedule, stores the
event so you can replay it later, and emails you when an endpoint goes
unhealthy. The promise is simple: you never miss an email.
What counts as a successful delivery
A delivery attempt succeeds only when your endpoint returns a 2xx:
- Success is HTTP
2xxonly. Anything else is a failure and gets retried. - Redirects are not followed. A
3xxis treated as a failure — point the webhook at the final URL. - Each attempt has a 15-second timeout. A slower endpoint counts as a failure, so ack fast and do heavy work out of band.
- The response body is ignored in the default mode — the status code is all that matters (the acknowledgement modes below change this).
Every attempt is signed exactly like a live delivery, so verify the signature before acting on it — see Verifying signatures.
Acknowledgement modes
By default any 2xx acknowledges a delivery. But a placeholder
endpoint that returns 200 without doing anything looks identical
to one that actually processed the message. Set an acknowledgement
mode per webhook (the ackMode field, configured when you
set up the webhook) to make MailKite require proof of handling:
ackMode | What MailKite expects |
|---|---|
lenient (default) | Any 2xx. The response body is ignored. |
ack |
Every delivery must return { "status": "ok" } (or an
x-mailkite-ack: ok header). A 2xx with no ack is treated
as failing — so a stub endpoint is detected and retried.
|
control | The 2xx response body tells MailKite what to do with the stored message (see below). |
Acknowledging a delivery (ack mode)
Return the SDK's replyOk() body — { "status": "ok" } —
once you've handled the event. It needs no API key:
ack response body { "status": "ok" }
acknowledge a delivery 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
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Control mode: act on a message from your response
In control mode your 2xx body is an instruction.
Return one of these and MailKite applies it to the stored message:
control responses { "status": "ok" } // processed — keep the stored message
{ "status": "spam" } // flag the stored message as spam
{ "status": "drop" } // discard (delete) the stored message
{ "status": "ok", "actions": [{ "type": "block-sender" }] } // block this sender for good
Response Effect { "status": "ok" }Processed normally — keep the stored message. { "status": "spam" }Mark the stored message as spam. { "status": "drop" }Discard (delete) the stored message. { "status": "ok", "actions": [{ "type": "block-sender" }] }Block this sender — future inbound from them is dropped before delivery.
The SDKs ship a helper per response so you don't hand-build the JSON:
replyOk(), replySpam(), replyDrop(),
and replyBlockSender() (each language uses its own naming — Go
ReplySpam(), Python reply_spam(), and so on). Each
returns the body string above.
control a message 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"));
res.type("application/json");
if (isObviousSpam(event)) {
// Mark stored as spam — or block the sender for good:
return res.send(MailKite.replyBlockSender()); // {"status":"ok","actions":[{"type":"block-sender"}]}
}
// ...process the message normally
res.send(MailKite.replyOk());
});
import os
from flask import Flask, request, abort
from mailkite import verify_webhook, reply_ok, reply_spam, reply_block_sender
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()
headers = {"content-type": "application/json"}
if is_obvious_spam(event):
# Mark stored as spam, or block the sender for good:
return reply_block_sender(), 200, headers
# ...process the message normally
return reply_ok(), 200, headers
<?php
$mk = new \MailKite\Client("");
$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);
header("content-type: application/json");
if (is_obvious_spam($event)) {
echo $mk->replySpam(); // {"status":"spam"} (or $mk->replyBlockSender())
exit;
}
// ...process the message normally
echo $mk->replyOk();
// 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("");
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 (isObviousSpam(event)) {
// Mark stored as spam, or block the sender for good:
return ResponseEntity.ok(mk.replyBlockSender());
}
// ...process the message normally
return ResponseEntity.ok(mk.replyOk());
}
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)
w.Header().Set("content-type", "application/json")
if isObviousSpam(event) {
// Mark stored as spam, or block the sender for good:
w.Write([]byte(mailkite.ReplyBlockSender()))
return
}
// ...process the message normally
w.Write([]byte(mailkite.ReplyOk()))
})
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)
content_type :json
if obvious_spam?(event)
# Mark stored as spam, or block the sender for good:
next Mailkite.reply_block_sender
end
# ...process the message normally
Mailkite.reply_ok
end
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Automatic retries
When a delivery fails — a non-2xx, a redirect, a timeout, or a
connection error — MailKite retries it automatically on an exponential
backoff schedule. Seven retries spread over roughly 27.5 hours, and they
stop early the moment an attempt succeeds:
Retry Wait after the previous attempt 1 5 seconds 2 5 minutes 3 30 minutes 4 2 hours 5 5 hours 6 10 hours 7 10 hours
Every attempt is recorded with its HTTP status, so you can see the full
delivery history in the dashboard.
Manual replay
Every inbound email is stored for your plan's
retention window, so you can replay a
delivery any time within it — the exact same signed payload, to the same
destination. Replay one delivery:
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>"
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Install
Docs →
Recovering from an outage? Re-arm all failed deliveries at
once — for a single route or the whole account. This resets the backoff so
every failed event is attempted again right away:
re-arm failed deliveries # Re-arm every failed delivery for one route (resets the backoff)…
curl -X POST https://api.mailkite.dev/api/routes/rt_…/retry-failed \
-H "authorization: Bearer <session-token>"
# …or for the whole account at once.
curl -X POST https://api.mailkite.dev/api/deliveries/retry-failed \
-H "authorization: Bearer <session-token>"
Replay is bounded by retention: a message that has aged out of your plan's
window is no longer stored, so there's nothing to replay. On
zero-retention passthrough domains nothing
is stored, so there's no replay at all — the live delivery is the only one.
Endpoint health & alerts
After sustained failures, MailKite flags the webhook unhealthy
in the dashboard and emails the account owner — once per
failing streak, so a flapping endpoint doesn't spam you.
Crucially, inbound mail is never silently dropped. A failed
delivery doesn't lose the email: the event is stored and auto-retried on the
schedule above, and you can replay it manually for the whole retention
window. Even while your endpoint is down, the mail keeps arriving and waiting
— so when you fix it, nothing was lost.
Next: verify webhook signatures so you
only act on real events, or revisit
inbound webhooks for the event shape.