# Receive Email in Python with a Signed Webhook
URL: https://samva.dev/guides/receive-email-python
> Receive inbound email in Python: verify Samva's signed message.received webhook with the standard library, then read the email and attachments with requests.
- Published: 2026-10-27



To receive email in Python, enable receiving on a verified Samva domain and point it at an HTTPS endpoint that subscribes to `message.received`. Samva accepts the mail, parses it, and POSTs a signed JSON event to your app. You verify the signature over the raw request bytes with the standard library, then act on the event. Samva has no Python SDK, so this guide uses [Flask](https://flask.palletsprojects.com) for the endpoint and [`requests`](https://requests.readthedocs.io) for the REST calls, with a FastAPI variant at the end.

## Before you start [#before-you-start]

You need three things from Samva:

* A [verified domain with receiving enabled](/guides/inbound-email-parsing#configure-receiving), or a subdomain such as `reply.example.com` when your team's mailboxes already use the root domain.
* A webhook endpoint subscribed to `message.received`. Creating it returns the signing secret once; keep it in `SAMVA_WEBHOOK_SECRET`.
* An API key in `SAMVA_API_KEY`, sent as `X-API-Key`, for reading the full email after the event arrives.

```bash
pip install flask requests
```

## Verify the signature [#verify-the-signature]

Samva follows the [Standard Webhooks](https://www.standardwebhooks.com/) scheme. The signature is an HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{body}`, keyed with the base64 part of the signing secret. The check below needs only the standard library. It rejects a missing header and a timestamp more than five minutes old, then compares in constant time.

```python title="verify_webhook.py"
import base64
import hashlib
import hmac
import json
import os
import time

TOLERANCE_SECONDS = 300


def verify_webhook(body: bytes, headers) -> tuple[str, dict]:
    """Return (event id, parsed event), or raise ValueError when the request is not from Samva."""
    try:
        event_id = headers["webhook-id"]
        timestamp = headers["webhook-timestamp"]
        signature_header = headers["webhook-signature"]
    except KeyError as missing:
        raise ValueError(f"missing header {missing}") from None

    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        raise ValueError("timestamp outside the tolerance")

    secret = base64.b64decode(os.environ["SAMVA_WEBHOOK_SECRET"].removeprefix("whsec_"))
    signed = f"{event_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(secret, signed, hashlib.sha256).digest()).decode()

    candidates = [
        entry.removeprefix("v1,")
        for entry in signature_header.split()
        if entry.startswith("v1,")
    ]
    if not any(hmac.compare_digest(expected, candidate) for candidate in candidates):
        raise ValueError("signature does not match")

    return event_id, json.loads(body)
```

Pass it the exact bytes of the request body, before any JSON parsing. A signature header can hold more than one `v1` entry while a secret is rotating, which is why the check accepts any match. The [event catalog](/docs/platform/webhook-events#delivery) lists the tolerance and the retry schedule; keep `TOLERANCE_SECONDS` in step with it.

## Accept the event in Flask [#accept-the-event-in-flask]

```python title="app.py"
from flask import Flask, request

from handle_received import handle_received
from verify_webhook import verify_webhook

app = Flask(__name__)
seen: set[str] = set()  # use a database unique key in production


@app.post("/webhooks/samva")
def samva_webhook():
    try:
        event_id, event = verify_webhook(request.get_data(), request.headers)
    except ValueError:
        return "Invalid webhook", 400

    if event["type"] == "message.received" and event_id not in seen:
        handle_received(event["data"])  # an exception answers 500, so Samva retries
        seen.add(event_id)  # mark only after success
    return "", 204
```

Three behaviors matter here:

* A request that fails verification gets `400` and never reaches your code.
* Delivery is at least once. `webhook-id` is stable across retries of the same event, so record it and skip repeats. The in-memory `set` is for the example only; use a unique key in a database so a restart does not forget what you handled.
* Any non-`2xx` answer makes Samva retry, up to eight automatic attempts on the [published schedule](/docs/platform/webhook-events#retry-schedule). Let a failure in your own processing raise so the event is delivered again rather than lost.

## Read the event, then the full email [#read-the-event-then-the-full-email]

This is a real `message.received` payload; the field list is in the [event catalog](/docs/platform/webhook-events#messagereceived).

```json
{
  "type": "message.received",
  "timestamp": "2026-01-15T10:05:33.900Z",
  "data": {
    "messageId": "msg_01j8k4n6p8q2r4s6t9vw",
    "organizationId": "org_01j8k0a2b4c6d8e0f2gh",
    "channel": "email",
    "direction": "inbound",
    "from": "ada@example.com",
    "to": "support@your-domain.com",
    "toEmails": ["support@your-domain.com"],
    "subject": "Re: Welcome to Samva",
    "conversationId": "conv_01j8k5p7q9r2s4t6v8wx",
    "hasAttachments": false,
    "isAutoReply": false,
    "body": "Thanks for the welcome. How do I invite a teammate?"
  }
}
```

`body` is the HTML when the email has one and the text otherwise. When you need more, `GET /v1/messages/{id}/email` returns the addresses beyond `from` and `to`, the parsed threading headers, each attachment, and, with `includeDownloadUrls=true`, signed links to the HTML, the text, and the raw MIME.

```python title="handle_received.py"
import os
from pathlib import Path

import requests

API_URL = os.environ.get("SAMVA_API_URL", "https://api.samva.dev")
HEADERS = {"X-API-Key": os.environ["SAMVA_API_KEY"]}


def handle_received(event: dict) -> None:
    # Out-of-office and delivery-status mail is machine mail: record it, never answer it.
    if event.get("isAutoReply"):
        return

    response = requests.get(
        f"{API_URL}/v1/messages/{event['messageId']}/email",
        params={"includeDownloadUrls": "true"},
        headers=HEADERS,
        timeout=10,
    )
    response.raise_for_status()
    email = response.json()
    print(email["fromEmail"], email["subject"], email["conversationId"])

    if not event.get("hasAttachments"):
        return
    directory = Path("attachments")
    directory.mkdir(exist_ok=True)
    for attachment in email["attachments"]:
        if attachment["isInline"]:
            continue  # images embedded in the HTML body
        link = requests.get(
            f"{API_URL}/v1/attachments/{attachment['id']}/download-url",
            headers=HEADERS,
            timeout=10,
        )
        link.raise_for_status()
        file = requests.get(link.json()["url"], timeout=30)
        file.raise_for_status()
        # Name the file by its id: the sender chose `filename`, so never build a path from it.
        (directory / attachment["id"]).write_bytes(file.content)
```

The download link expires, so fetch the file when you need it and keep your own copy if you must retain it. Check the content type and size before you open a file, and treat the sender's filename as display text. Samva returns the body as the sender wrote it, including any history their mail client quoted below the reply. To answer on the same conversation, follow [how a reply finds its conversation](/guides/inbound-email-parsing#how-a-reply-finds-its-conversation).

## Use FastAPI [#use-fastapi]

FastAPI parses JSON bodies when you declare a model, which loses the exact bytes. Take the `Request` and read the body yourself:

```python title="fastapi_app.py"
import asyncio

from fastapi import FastAPI, HTTPException, Request, Response

from handle_received import handle_received
from verify_webhook import verify_webhook

app = FastAPI()
seen: set[str] = set()  # use a database unique key in production


@app.post("/webhooks/samva")
async def samva_webhook(request: Request) -> Response:
    try:
        event_id, event = verify_webhook(await request.body(), request.headers)
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid webhook")

    if event["type"] == "message.received" and event_id not in seen:
        # handle_received makes blocking requests calls, so keep it off the event loop
        await asyncio.to_thread(handle_received, event["data"])
        seen.add(event_id)
    return Response(status_code=204)
```

`handle_received` makes blocking `requests` calls, so the route hands it to a worker thread with `asyncio.to_thread`. Declaring the route with plain `def` works too, because FastAPI then runs it in its thread pool.

## Test it without sending an email [#test-it-without-sending-an-email]

Sign a fixture the way Samva does and POST it. The script below uses only the standard library, so it works with either server, and it also sends a tampered copy to prove the handler rejects it.

```python title="send_test.py"
import base64
import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.request
import uuid

URL = os.environ.get("TARGET_URL", "http://localhost:3000/webhooks/samva")

body = json.dumps(
    {
        "type": "message.received",
        "timestamp": "2026-01-15T10:05:33.900Z",
        "data": {
            "messageId": "msg_test_1",
            "organizationId": "org_test_1",
            "channel": "email",
            "direction": "inbound",
            "from": "ada@example.com",
            "to": "support@your-domain.com",
            "toEmails": ["support@your-domain.com"],
            "subject": "Re: Welcome",
            "conversationId": "conv_test_1",
            "hasAttachments": False,
            "isAutoReply": True,  # the handler returns before it calls the API
            "body": "How do I invite a teammate?",
        },
    }
).encode()

event_id = f"whevt_{uuid.uuid4()}"
timestamp = str(int(time.time()))
secret = base64.b64decode(os.environ["SAMVA_WEBHOOK_SECRET"].removeprefix("whsec_"))
digest = hmac.new(secret, f"{event_id}.{timestamp}.".encode() + body, hashlib.sha256).digest()
signature = "v1," + base64.b64encode(digest).decode()


def post(payload: bytes) -> int:
    request = urllib.request.Request(
        URL,
        data=payload,
        method="POST",
        headers={
            "content-type": "application/json",
            "webhook-id": event_id,
            "webhook-timestamp": timestamp,
            "webhook-signature": signature,
        },
    )
    try:
        return urllib.request.urlopen(request).status
    except urllib.error.HTTPError as error:
        return error.code


print("signed:", post(body))  # 204
print("tampered:", post(body.replace(b"invite", b"delete")))  # 400
```

The fixture's `messageId` does not exist in Samva, so it is marked `isAutoReply`: the handler returns before it reads the email and the test needs no account. To exercise the read and the attachment download, send a `messageId` from mail you received and set `isAutoReply` back to `False`.

Run the server with `flask --app app run --port 3000`, then `python send_test.py` in another terminal. To check the real path from Samva, expose the port with a tunnel and send a `webhook.test` event; the handler verifies it and answers `204` because it is not a `message.received`.

## What to read next [#what-to-read-next]

* [Receive, thread, and reply](/guides/inbound-email-parsing) to configure receiving and send an answer on the same conversation.
* [Receive email in Node.js](/guides/receive-email-nodejs) for the same endpoint with the TypeScript SDK.
* [Send email with Python](/guides/send-email-python) for the send side over the same REST API.
* [Email webhooks](/guides/email-webhooks) for the retry schedule, secret rotation, and redelivery.

## FAQ

### How do I receive email in Python?

Enable receiving on a verified Samva domain and point it at an HTTPS endpoint that subscribes to message.received. Samva parses each incoming email and POSTs it to your Python app as a signed JSON event. Verify the signature over the raw request body, then act on the event.

### Do I need to run an SMTP server or poll an IMAP mailbox?

No. Samva's receiving servers accept the SMTP connection and push the parsed email to your HTTPS endpoint, so your app needs only a route that accepts a POST. There is nothing to poll.

### Why does my signature check fail in Flask or FastAPI?

The signature covers the exact bytes Samva sent. Read them with request.get_data() in Flask or await request.body() in FastAPI before anything parses the JSON. Re-encoding a parsed body changes the bytes and the check fails.

### Is there a Samva Python SDK for inbound email?

No. Samva publishes a TypeScript SDK only. In Python you verify the webhook with the standard library and call the REST API with requests or httpx. The OpenAPI document at api.samva.dev/v1/openapi.json can generate a typed client.

### How do I get the attachments from an inbound email in Python?

The webhook sets hasAttachments. GET /v1/messages/{id}/email returns each attachment's id, filename, content type, size, and whether it is inline. GET /v1/attachments/{id}/download-url returns a time-limited link to fetch the file.
## Related

- [Receive email in Node.js](/guides/receive-email-nodejs): The same signed webhook handled with the TypeScript SDK.
- [Inbound email parsing: receive, thread, and reply](/guides/inbound-email-parsing): Configure receiving, then send a reply on the same conversation.
- [Inbound email API](/features/inbound-email): What the parsed webhook contains, compared with a parse webhook.
- [Send email with Python](/guides/send-email-python): The send side over the REST API with requests or httpx.
- [Email webhooks](/guides/email-webhooks): Events, retries, and idempotent handlers for every webhook type.
- [Webhook event catalog](/docs/platform/webhook-events): The message.received fields, headers, and retry schedule.
