By language

Receive email in Python

Receive and process inbound email in Python with Flask or FastAPI: verify Inboundr's signed webhook, then process each message and its attachments.

This guide builds a Flask or FastAPI app that receives every email sent to your domain, verifies it came from Inboundr, and saves its attachments. It uses only the standard library for verification. Python 3.10 or later.

Before you start#

  • An address to receive on. Your account comes with an instant domain such as amber-falcon-4821.inboundr.email, so any address on it works without DNS setup. A verified domain of your own works the same way.
  • An API key, from API keys in the console:
    bash
    export INBOUNDR_API_KEY="inb_xxxxxxxxxxxxxxxxxxxxxxxx"
  • A public HTTPS URL for your handler. Inboundr only delivers to public addresses, so while developing, expose your local port with a tunnel:
    bash
    ngrok http 8000
    # or: cloudflared tunnel --url http://localhost:8000

1. Create an endpoint and route mail to it#

An endpoint is the URL Inboundr POSTs each email to. Create one pointing at your tunnel:

bash
curl https://inboundr.net/api/v1/endpoints \
  -H "Authorization: Bearer $INBOUNDR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Local handler", "url": "https://<your-tunnel>/webhooks/inboundr" }'

The response includes a secret (whsec_…). It's shown only once, so store it next to your API key:

bash
export INBOUNDR_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxxxxxx"

Then send the domain's mail to the endpoint. Find the domain's ID (the instant domain is the one with "managed": true) and set it as the catch-all:

bash
curl https://inboundr.net/api/v1/domains \
  -H "Authorization: Bearer $INBOUNDR_API_KEY"

curl -X PATCH https://inboundr.net/api/v1/domains/dom_123 \
  -H "Authorization: Bearer $INBOUNDR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "catchAllEndpointIds": ["ep_123"] }'

To route a single address instead, see addresses.

2. Write the handler#

The handler gets the parsed message as JSON: from, to, recipients, subject, text, html, attachment metadata and the spam, SPF and DKIM verdicts. See the full payload. The signature check is the same in both frameworks:

inboundr_webhook.py
import hashlib
import hmac
import os
import time
from pathlib import Path

import requests

SECRET = os.environ["INBOUNDR_WEBHOOK_SECRET"]
API_KEY = os.environ["INBOUNDR_API_KEY"]


def verify_signature(raw_body: bytes, header: str | None) -> bool:
    """Checks X-Inboundr-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body">."""
    if not header:
        return False
    parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
    t = parts.get("t", "")
    # Reject old deliveries so a captured request can't be replayed.
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(
        SECRET.encode(), t.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    # Compared as bytes: compare_digest raises on non-ASCII str input.
    return hmac.compare_digest(expected.encode(), parts.get("v1", "").encode())


def process_email(email: dict) -> None:
    print(f"{email['from']} -> {', '.join(email['recipients'])}: {email['subject']}")

    folder = Path("attachments")
    folder.mkdir(exist_ok=True)
    for i, attachment in enumerate(email["attachments"]):
        if not attachment["downloadUrl"]:
            continue  # not stored on this plan
        res = requests.get(
            attachment["downloadUrl"],
            headers={"Authorization": f"Bearer {API_KEY}"},
            timeout=60,
        )
        res.raise_for_status()
        # Inline images often have no filename, and two can share one, so the
        # index keeps them apart. .name: never trust a sender's name as a path.
        name = Path(attachment["filename"] or f"attachment-{i}").name
        target = folder / f"{email['id']}-{i}-{name}"
        target.write_bytes(res.content)
        print(f"saved {target}")

Then the route, in the framework you use. Both read the raw body, answer straight away and process in the background:

Flask
import json
import threading

from flask import Flask, abort, request

from inboundr_webhook import process_email, verify_signature

app = Flask(__name__)
handled: set[str] = set()  # use your database in production


@app.post("/webhooks/inboundr")
def inboundr_webhook():
    raw = request.get_data()  # raw bytes, before any JSON parsing
    if not verify_signature(raw, request.headers.get("X-Inboundr-Signature")):
        abort(401)

    email = json.loads(raw)["email"]

    # Retries keep their delivery ID. Recorded only after parsing succeeded,
    # so a delivery that failed here is processed when it's retried.
    delivery_id = request.headers.get("X-Inboundr-Delivery-Id")
    if delivery_id:
        if delivery_id in handled:
            return "ok"
        handled.add(delivery_id)
    # Slow handlers time out and get retried: use a job queue in production.
    threading.Thread(target=process_email, args=(email,), daemon=True).start()
    return "ok"
bash
pip install requests flask        # then: flask --app app run --port 8000
pip install requests fastapi uvicorn  # then: uvicorn app:app --port 8000

On Django, read request.body, mark the view @csrf_exempt, and hand the email to Celery or another queue. The verification function is unchanged.

3. Test it#

Send a sample delivery. It's signed with your endpoint's real secret, so it also tests your signature check:

bash
curl -X POST https://inboundr.net/api/v1/endpoints/ep_123/test \
  -H "Authorization: Bearer $INBOUNDR_API_KEY"

"ok": true means your handler answered with a 2xx. A 401 in status means the signature didn't match: see troubleshooting.

Then send a real email, with an attachment, from your own mailbox to any address on your domain, such as test@amber-falcon-4821.inboundr.email. It reaches your handler within seconds.

Read mail without a server#

Webhooks need a public URL. For scripts, notebooks and agents, read the inbox on demand instead. Mail is stored either way:

read_inbox.py
import os

import requests

res = requests.get(
    "https://inboundr.net/api/v1/emails",
    params={"direction": "inbound", "limit": 20},
    headers={"Authorization": f"Bearer {os.environ['INBOUNDR_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
for email in res.json()["data"]:
    print(email["receivedAt"], email["fromAddress"], email["subject"])

Fetch one message in full with GET /v1/emails/{id}, and pass nextCursor back as cursor for the next page. See the emails API.

Troubleshooting#

  • Every delivery fails the signature check. The HMAC is computed over the exact bytes sent. Read the raw body before any JSON parsing, and don't re-serialize it. Also check you're using the endpoint's whsec_… secret, not your inb_ API key.
  • Deliveries time out. Your handler has 30 seconds by default. Acknowledge first, then do slow work (AI calls, downloads, database writes) in the background.
  • The same email arrives twice. A delivery is retried when your handler doesn't answer 2xx in time, and a retry keeps its X-Inboundr-Delivery-Id. Store handled IDs and skip repeats.
  • The body is empty. Some hosts drop request bodies. Every delivery also carries the email's ID with its own signature, so you can fetch it instead: see when the body is missing.
  • Nothing arrives. Check the email under Emails in the console. If it's there, open its delivery to see your handler's response. If it isn't, the message never reached Inboundr: check the address and, for your own domain, its MX record.

Next steps#