Verifying signatures
Your webhook URL is public, so anyone could POST to it. Every real
delivery carries an HMAC signature — verify it and you only ever act on events
that genuinely came from MailKite. One scheme, one secret, every webhook:
inbound email.received deliveries and outbound
email.* tracking events are signed
identically, so the verification below covers both.
The signature header
Each delivery includes an x-mailkite-signature header:
x-mailkite-signature: t=1750000000000,v1=4f1a9c… It has two comma-separated parts:
t— the timestamp the event was signed, in milliseconds since the epoch.v1— a hex HMAC-SHA256 of`${t}.${rawBody}`, keyed with your webhook signing secret.
Verify with the SDK
Every official MailKite SDK ships a
verifyWebhook helper, so you don't have to touch any crypto.
Pass the x-mailkite-signature header, the raw
request body, and your signing secret — one call returns true
only when the signature matches and the event is fresh. It needs no API key
and makes no network call.
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) => {
// One call: parses the header, recomputes the HMAC, compares in constant
// time, and rejects events outside the ±5-minute replay window.
if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], req.body, SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// ...trusted: handle event.type === "email.received"
res.sendStatus(200);
});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 hook():
# Verifies the HMAC + replay window in one call. Pass the RAW body bytes.
signature = request.headers.get("x-mailkite-signature", "")
if not verify_webhook(signature, request.get_data(), SECRET):
abort(401)
event = request.get_json()
# ...trusted: handle event["type"] == "email.received"
return "", 200<?php
require "vendor/autoload.php";
use MailKite\Client;
$secret = getenv("MAILKITE_WEBHOOK_SECRET");
$signature = $_SERVER["HTTP_X_MAILKITE_SIGNATURE"] ?? "";
$rawBody = file_get_contents("php://input"); // the RAW request body
$mk = new Client(""); // no API key needed to verify
if (!$mk->verifyWebhook($signature, $rawBody, $secret)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
// ...trusted: handle $event["type"] === "email.received"
http_response_code(200);import dev.mailkite.MailKite;
import java.nio.charset.StandardCharsets;
// signature = the x-mailkite-signature header; rawBody = the exact bytes received.
MailKite mk = new MailKite(""); // no API key needed to verify
String body = new String(rawBody, StandardCharsets.UTF_8);
if (!mk.verifyWebhook(signature, body, secret)) {
response.setStatus(401);
return;
}
// ...trusted: parse body and handle the eventimport (
"io"
"net/http"
mailkite "github.com/mailkite/mailkite-go"
)
func handler(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body) // the RAW request body
signature := r.Header.Get("x-mailkite-signature")
if !mailkite.VerifyWebhook(signature, string(rawBody), secret) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// ...trusted: parse rawBody and handle the event
}require "mailkite"
# signature = the x-mailkite-signature header; raw_body = the exact bytes received.
unless Mailkite.verify_webhook(signature, raw_body, secret)
halt 401
end
# ...trusted: parse raw_body and handle the event
Pass a 4th argument, toleranceMs, to change the replay window; it
defaults to 300000 (5 minutes), and 0 disables the
freshness check.
Verify the raw bytes, not a parsed-and-re-serialized object.
Key ordering and whitespace change the bytes and will break the signature.
Each webhook route has its own signing secret — find it in the dashboard on
that route’s webhooks detail (or via the SDK getWebhookSecret(domainId)).
The account-wide secret stays as a compatibility fallback.
Verify in your language
Not using an SDK? The signature is a plain HMAC, so you can verify it by hand with your standard library. Each example:
- Reads the raw, unparsed request body — the exact bytes you received.
- Concatenates
`${t}.`and the raw body. - Computes
HMAC-SHA256(secret, that string)as lowercase hex. - Compares it to
v1with a constant-time comparison. - Rejects events whose
tis more than ~5 minutes old to block replays.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;
const app = express();
// Capture the RAW body — you must sign the exact bytes, not a re-serialized object.
app.use("/hooks/mailkite", express.raw({ type: "application/json" }));
app.post("/hooks/mailkite", (req, res) => {
if (!verify(req.headers["x-mailkite-signature"], req.body)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// ...trusted: handle event.type === "email.received"
res.sendStatus(200);
});
function verify(header, rawBody) {
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
// Reject stale events (±5 min) to block replays.
if (Math.abs(Date.now() - t) > 5 * 60 * 1000) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${parts.t}.` + rawBody) // sign "<t>." + raw request body
.digest("hex");
// Constant-time compare.
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib, hmac, time
def verify(header: str, raw_body: bytes, secret: str) -> bool:
if not header:
return False
parts = dict(p.split("=", 1) for p in header.split(","))
t = parts.get("t")
if not t or not t.isdigit():
return False
# Reject stale events (±5 min).
if abs(time.time() * 1000 - int(t)) > 5 * 60 * 1000:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))<?php
function verify(string $header, string $rawBody, string $secret): bool {
if ($header === '') return false;
$parts = [];
foreach (explode(',', $header) as $p) {
[$k, $v] = array_pad(explode('=', $p, 2), 2, '');
$parts[$k] = $v;
}
$t = $parts['t'] ?? '';
if ($t === '' || !ctype_digit($t)) return false;
// Reject stale events (±5 min).
if (abs(round(microtime(true) * 1000) - (int) $t) > 5 * 60 * 1000) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
import java.util.*;
boolean verify(String header, byte[] rawBody, String secret) throws Exception {
if (header == null || header.isEmpty()) return false;
Map<String, String> parts = new HashMap<>();
for (String p : header.split(",")) {
String[] kv = p.split("=", 2);
if (kv.length == 2) parts.put(kv[0], kv[1]);
}
String t = parts.get("t");
if (t == null || !t.matches("\\d+")) return false;
// Reject stale events (±5 min).
if (Math.abs(System.currentTimeMillis() - Long.parseLong(t)) > 5 * 60 * 1000) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
mac.update(rawBody);
StringBuilder hex = new StringBuilder();
for (byte b : mac.doFinal()) hex.append(String.format("%02x", b));
return MessageDigest.isEqual(
hex.toString().getBytes(StandardCharsets.UTF_8),
parts.getOrDefault("v1", "").getBytes(StandardCharsets.UTF_8));
}import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func verify(header string, rawBody []byte, secret string) bool {
if header == "" {
return false
}
parts := map[string]string{}
for _, p := range strings.Split(header, ",") {
if kv := strings.SplitN(p, "=", 2); len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
return false
}
// Reject stale events (±5 min).
if d := time.Now().UnixMilli() - t; d > 5*60*1000 || d < -5*60*1000 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}require "openssl"
require "rack"
def verify(header, raw_body, secret)
return false unless header
parts = header.split(",").map { |p| p.split("=", 2) }.to_h
t = parts["t"]
return false unless t && t.match?(/\A\d+\z/)
# Reject stale events (±5 min).
return false if ((Time.now.to_f * 1000) - t.to_i).abs > 5 * 60 * 1000
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
Rack::Utils.secure_compare(expected, parts["v1"] || "")
end The same signature is used for live deliveries, retries, and the test event — so you can confirm your verification works before going live.