Receive email as a webhook in Python
Build a Python webhook handler that receives inbound email from MailKite: Flask setup, HMAC signature verification, JSON payload parsing, and attachment handling. Complete tutorial.
MailKite turns inbound email into a JSON POST to your Python endpoint — no IMAP polling, no MIME parsing, no mail server. This tutorial covers the full flow in Python: Flask setup, HMAC verification, payload handling, and attachment processing.
For the multi-language overview (Node, Python, Go, PHP side by side), see the complete email-to-webhook guide. This post goes deep on the Python-specific details.
Prerequisites
- A MailKite account (sign up free — no credit card)
- Python 3.10+
- A domain you control (for MX records)
1. Add your domain
In the MailKite dashboard, add your domain. MailKite generates an MX record:
Type: MX
Host: @
Priority: 10
Value: mx.mailkite.dev
Add this to your DNS. Once it propagates (usually under 5 minutes), MailKite activates the domain and starts receiving email.
2. Install the SDK
pip install mailkite flask
3. Build the handler
import os
from flask import Flask, request, abort
from mailkite import verify_webhook
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["MAILKITE_WEBHOOK_SECRET"]
@app.post("/inbound")
def inbound():
# 1. Get raw bytes BEFORE any JSON parsing
raw = request.get_data()
# 2. Verify the HMAC signature
sig = request.headers.get("X-MailKite-Signature", "")
if not verify_webhook(sig, raw, WEBHOOK_SECRET):
print("Bad signature — rejecting")
abort(401)
# 3. Parse the JSON
email = request.get_json()
if email["type"] != "email.received":
return "", 200
# 4. Handle the email
print(f"From: {email['from']['address']}")
print(f"Subject: {email['subject']}")
print(f"Body: {email['text']}")
return "", 200
if __name__ == "__main__":
app.run(port=3000)
Two things that trip people up:
-
request.get_data()must come beforerequest.get_json(). The HMAC signature covers the raw bytes. If Flask parses JSON first, the re-serialized bytes won’t match. -
Return
200fast. MailKite retries on timeout. Do heavy processing (database writes, attachment downloads) after the response.
4. Verify the signature
verify_webhook() recomputes HMAC-SHA256 of the raw body using your webhook secret, compares in constant time, and checks the timestamp to prevent replay attacks.
If you’re not using the SDK, here’s the manual verification:
import hmac
import hashlib
import time
def verify_signature(sig_header: str, raw_body: bytes, secret: str) -> bool:
"""Verify MailKite webhook signature."""
parts = dict(p.split("=", 1) for p in sig_header.split(","))
timestamp = parts.get("t", "")
v1 = parts.get("v1", "")
# Check timestamp freshness (5 minutes; t is milliseconds, so the window is 300,000 ms)
if abs(time.time() * 1000 - int(timestamp)) > 300_000:
return False
# Recompute: HMAC-SHA256(secret, timestamp + "." + body)
message = f"{timestamp}.{raw_body.decode('utf-8')}"
expected = hmac.new(
secret.encode(), message.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)
The SDK does this for you — but understanding it helps when debugging.
5. The payload
Every inbound message arrives as this shape:
{
"type": "email.received",
"id": "msg_2Hk9…",
"from": { "address": "ada@example.com", "name": "Ada Lovelace" },
"to": [{ "address": "support@myapp.ai" }],
"subject": "Re: invoice #1042",
"text": "Looks good — approved!",
"html": "<p>Looks good — approved!</p>",
"threadId": "<a1b2c3@mail.example.com>",
"receivedAt": 1785196800000,
"receivedAtIso": "2026-07-28T00:00:00.000Z",
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
"attachments": [
{
"filename": "po.pdf",
"contentType": "application/pdf",
"size": 18213,
"url": "https://api.mailkite.dev/att/2Hk9…/0?sig=…"
}
]
}
Key fields:
textandhtmlare already decoded — no quoted-printable, no base64, no charset guessing.threadIdlinks replies to the original message. Store themessage_idyou send; when a reply arrives,threadIdmatches it.authshows SPF/DKIM/DMARC results. Check these before trusting the sender.attachments[].urlis a signed URL — download directly withrequestsorhttpx.
6. Handle attachments
import requests
@app.post("/inbound")
def inbound():
raw = request.get_data()
sig = request.headers.get("X-MailKite-Signature", "")
if not verify_webhook(sig, raw, WEBHOOK_SECRET):
abort(401)
email = request.get_json()
if email["type"] != "email.received":
return "", 200
# Process attachments
for att in email.get("attachments", []):
print(f"Attachment: {att['filename']} ({att['size']} bytes)")
# Download via signed URL
response = requests.get(att["url"])
content = response.content
# Or use base64 inline content if provided
# import base64
# content = base64.b64decode(att["content"])
return "", 200
7. Deploy
This works on any Python hosting — Railway, Fly.io, Render, a VPS, or AWS Lambda (with a small adapter for the raw body). The only requirement: your endpoint must be reachable from the internet on port 443 (HTTPS).
Set these environment variables:
MAILKITE_API_KEY=your-api-key
MAILKITE_WEBHOOK_SECRET=your-webhook-secret
Both are in the MailKite dashboard under your domain’s webhook settings.
What’s next
- Parse inbound email to JSON in Node.js — same flow, Express version
- The complete email-to-webhook guide — multi-language reference
- Reply by email in your app — send replies back through the same domain
- SendGrid Inbound Parse alternative — why MailKite’s JSON beats multipart
Point an MX record, register a URL, and email becomes just another JSON request. Start free — unlimited domains, no credit card.