Get your API key
Receiving email

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:

http
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.

verify.*
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);
});
Install Docs →

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:

  1. Reads the raw, unparsed request body — the exact bytes you received.
  2. Concatenates `${t}.` and the raw body.
  3. Computes HMAC-SHA256(secret, that string) as lowercase hex.
  4. Compares it to v1 with a constant-time comparison.
  5. Rejects events whose t is more than ~5 minutes old to block replays.
verify-manual.*
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);
}
Install Docs →

The same signature is used for live deliveries, retries, and the test event — so you can confirm your verification works before going live.