MailKite
Start free
All posts
Gabe 6 min read

Email to webhook: the complete guide to receiving email as JSON

Point your domain's MX at MailKite and every message becomes a signed JSON POST to your endpoint — no mail server, no IMAP polling, no MIME parsing. The full payload shape, signature verification, working receivers in Node, Python, Go, and PHP, and how it compares to SendGrid Inbound Parse, Mailgun Routes, and Postmark.

Receiving email in an app is supposed to be the hard direction. Sending is a POST; receiving traditionally means running an MX host, speaking SMTP, parsing MIME, decoding transfer encodings, and reassembling multipart bodies before you ever see the text a human typed. Email-to-webhook collapses all of that into one thing your app already knows how to handle: an HTTP request with a JSON body.

Here is the whole idea in one sentence: point your domain’s MX records at MailKite, and every message sent to any address on that domain is parsed and POSTed to your endpoint as structured JSON — headers, text, HTML, and attachments already broken out, signed so you can trust it, retried if your endpoint is down. No mail server to run, no IMAP connection to babysit, no mailparse in your dependency tree.

This is the complete reference for that pattern: how it works, the exact payload you receive, verifying the signature, working receivers in four languages, and an honest comparison to the incumbents. If you want a single-language walkthrough instead, the Node tutorial and the Python one go slower; this page is the map they hang off.

How it works, end to end

senderany mail client MX edgeparse + SPF/DKIM auth signed POSTapplication/json your endpointverify → 200 Blue = operated by MailKite. Everything left of "your endpoint" — the SMTP conversation, MIME parsing, and auth — happens before the POST lands.
Email-to-webhook: the MX edge does the SMTP and MIME work; your app only ever sees a signed JSON request.

Three moving parts:

  1. DNS. You add the MX record MailKite generates for your domain. When it verifies, mail for *@yourdomain.com starts flowing to the edge. (Sending from the same domain needs SPF + DKIM, which MailKite also manages — one domain, both directions.)
  2. The edge. MailKite’s MX host accepts the SMTP connection, checks SPF/DKIM, and parses the raw message into a clean object: decoded text and HTML, split headers, and attachments pulled out of their multipart wrapping.
  3. The webhook. That object is signed and POSTed to the endpoint you registered. Your endpoint verifies the signature, does its work, and returns 2xx. If it returns anything else — or times out — MailKite retries on a backoff schedule and alerts you, so a deploy blip never silently drops mail.

The payload you receive

Every inbound message arrives as the same shape. Here is a representative email.received event:

{
  "type": "email.received",
  "message_id": "<CAF9x2h1a@mail.example.com>",
  "in_reply_to": "<9920-outbound@myapp.ai>",
  "from": { "address": "ada@example.com", "name": "Ada Lovelace" },
  "to": [{ "address": "support@myapp.ai", "name": "Support" }],
  "subject": "Re: your invoice",
  "text": "Thanks — can you resend the PDF?\n\nAda",
  "html": "<p>Thanks — can you resend the PDF?</p><p>Ada</p>",
  "headers": {
    "date": "Sat, 04 Jul 2026 14:12:09 +0000",
    "reply-to": "ada@example.com"
  },
  "attachments": [
    {
      "filename": "signature.png",
      "content_type": "image/png",
      "size": 20481,
      "content": "iVBORw0KGgoAAAANSUhEUg…"
    }
  ]
}

A few things worth calling out because they save you real work:

  • text and html are already decoded. No quoted-printable, no base64 body, no charset guessing.
  • in_reply_to and message_id are what you thread on. Store the message_id you sent with; when a reply comes back, in_reply_to points at it, and you have a conversation. That is the whole basis of reply-by-email.
  • Attachments come with content (base64) inline. You can also fetch larger ones by URL, but prefer the inline content field in your handlers — it avoids an extra round trip and a class of same-zone fetch failures.

Verify the signature — before anything else

The webhook is a public URL. Anyone who finds it can POST to it, so the first thing your handler does, every time, is confirm the request actually came from MailKite. Each POST carries an x-mailkite-signature header: an HMAC of the raw request body with your webhook secret. Recompute it and compare in constant time.

Two rules that trip people up:

  • You need the raw bytes. If your framework parses JSON before you can read the body, the bytes you hash won’t match the bytes MailKite hashed. Reach for the raw-body option (express.raw, Flask’s request.get_data(), io.ReadAll, php://input).
  • Honor the replay window. The signature covers a timestamp; reject anything older than a few minutes so a captured request can’t be re-fired later.

The SDKs wrap all of that — recompute, constant-time compare, timestamp check — in one call. The deep dive on how the HMAC is constructed (and how to verify it by hand if you’re not on an SDK) is in Verifying inbound webhooks with HMAC.

Working receivers, four languages

Each of these is a complete, runnable endpoint. Same three steps every time: read the raw body, verify, handle.

Node (Express)

import express from "express";
import { MailKite } from "mailkite";

const app = express();
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;

// raw body — do NOT let express.json() touch it first
app.use("/inbound", express.raw({ type: "application/json" }));

app.post("/inbound", (req, res) => {
  if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], req.body, SECRET)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200); // ack fast

  const email = JSON.parse(req.body);
  if (email.type !== "email.received") return;
  console.log(`${email.from.address}: ${email.subject}`);
});

app.listen(3000);

Python (Flask)

from flask import Flask, request, abort
from mailkite import MailKite

app = Flask(__name__)
SECRET = os.environ["MAILKITE_WEBHOOK_SECRET"]

@app.post("/inbound")
def inbound():
    raw = request.get_data()  # raw bytes, before JSON parsing
    sig = request.headers.get("X-MailKite-Signature", "")
    if not MailKite.verify_webhook(sig, raw, SECRET):
        abort(401)

    email = request.get_json()
    if email["type"] == "email.received":
        print(f"{email['from']['address']}: {email['subject']}")
    return "", 200

Go (net/http)

func inbound(w http.ResponseWriter, r *http.Request) {
    raw, _ := io.ReadAll(r.Body)
    sig := r.Header.Get("X-MailKite-Signature")
    if !mailkite.VerifyWebhook(sig, raw, secret) {
        http.Error(w, "bad signature", http.StatusUnauthorized)
        return
    }
    w.WriteHeader(http.StatusOK) // ack fast

    var email mailkite.InboundEmail
    json.Unmarshal(raw, &email)
    if email.Type == "email.received" {
        log.Printf("%s: %s", email.From.Address, email.Subject)
    }
}

PHP

<?php
$raw = file_get_contents('php://input'); // raw body
$sig = $_SERVER['HTTP_X_MAILKITE_SIGNATURE'] ?? '';

if (!MailKite::verifyWebhook($sig, $raw, getenv('MAILKITE_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit;
}
http_response_code(200); // ack fast

$email = json_decode($raw, true);
if ($email['type'] === 'email.received') {
    error_log("{$email['from']['address']}: {$email['subject']}");
}

Ruby and Java receivers follow the same shape — the SDKs all expose the same verifyWebhook and the same field names, so the payload you destructure is identical across every language.

How it compares

Inbound-as-webhook isn’t unique to MailKite. What differs is whether it’s a first-class primitive or a feature bolted onto a sending product.

Inbound approachNotes
SendGrid Inbound ParseMX → multipart form POSTThe most established; payload is multipart/form-data, not JSON, so you parse fields yourself. Send-first product.
Mailgun RoutesRules → forward/store/POSTPowerful routing DSL, but inbound is configured separately from sending and docs skew toward the sending API.
Brevo / Postmark inboundMX → JSON POSTClean JSON (Postmark especially), solid — again a companion to the send product.
inbound.new / newer entrantsMX → JSON POSTInbound-first and pleasant, but small; fewer languages, thinner surrounding platform.
MailKiteMX → signed JSON POSTInbound is a first-class primitive, unlimited domains free, and the same verified domain sends too — so a reply goes back out without a second vendor.

None of these are bad. The reason to reach for MailKite specifically is when receiving isn’t an afterthought — when your app or your agent needs to hold a two-way conversation on a domain you control, and you’d rather not run one vendor for outbound and glue a second one on for inbound.

Where to go next

Point an MX record, register a URL, and email becomes just another JSON request. Start free — unlimited domains and mailboxes, 3,000 emails a month, no daily cap.

Discuss this post: Hacker News Share on X

Related posts