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.

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);
});

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:

  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);
}

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