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.
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. Find your signing secret in the dashboard.
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.