All posts
Receive email as a webhook in Python
Gabe 2 min read

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:

  1. request.get_data() must come before request.get_json(). The HMAC signature covers the raw bytes. If Flask parses JSON first, the re-serialized bytes won’t match.

  2. Return 200 fast. 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:

  • text and html are already decoded — no quoted-printable, no base64, no charset guessing.
  • threadId links replies to the original message. Store the message_id you send; when a reply arrives, threadId matches it.
  • auth shows SPF/DKIM/DMARC results. Check these before trusting the sender.
  • attachments[].url is a signed URL — download directly with requests or httpx.

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

Point an MX record, register a URL, and email becomes just another JSON request. Start free — unlimited domains, no credit card.

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts