Receiving email programmatically used to mean running your own SMTP server, fighting spam filters, and praying nothing fell over at 3 AM. An inbound email webhook collapses all of that into one HTTP POST your app receives whenever a message lands on your domain. This tutorial walks through the exact pattern in Node and Python, including the parts most docs skim over.
Inbound email processing works in three steps: point the receiving domain MX record at the inbound provider, parse the resulting webhook POST as structured JSON, and verify the signature before acting on the payload. Once those three pieces are wired, every incoming email becomes a normal JSON event your handler can route, store, or feed to an agent. Bavimail signs every webhook with HMAC-SHA256 using a per-key secret. The 5-minute timestamp tolerance prevents replay attacks while allowing legitimate retries through the 5-attempt exponential backoff schedule. Attachments arrive as pre-signed URLs rather than inline blobs, which keeps webhook payloads small and lets large attachments stream directly from object storage to your handler.
How do you point a domain at an inbound webhook?
Every inbound flow starts with MX records. The domain that will receive mail needs an MX record (priority 10) pointing at your provider's ingest cluster. With Bavimail, that record is generated for you when you add a domain through the dashboard or POST to the domains endpoint. The same flow programmatically verifies SPF, DKIM, DMARC, and MAIL FROM alignment. This matters because providers like Gmail will silently reject inbound forwards from misaligned senders. Full setup is covered in the inbound email docs.
Once the MX record propagates (usually under an hour, sometimes up to 24), point an inbound route at a webhook URL. A route is a pattern (catch-all, prefix match, or exact alias) plus the destination URL. For agent-style apps, the per-agent alias pattern is powerful: each agent gets its own address like agent-42 at yourdomain.com, and the route delivers all mail for that alias to one handler.
What does an inbound email webhook payload look like?
When a message arrives, Bavimail posts a JSON body to your endpoint. The shape is straightforward: a message_id, the parsed from and to fields, subject, plaintext and HTML bodies, headers, attachments, and a thread_id you can use to group messages in the same conversation. The body comes pre-parsed. You do not have to deal with raw MIME, base64 transfer encoding, or quoted-printable garbage. The reply text is also separated from quoted history when possible, which saves you writing a brittle quoted-content stripper.
The full event schema for inbound.received and the other 12 webhook event types lives in the webhook reference.
How do you verify an inbound webhook signature in Node and Python?
Anyone on the internet can POST JSON at your handler. Signature verification makes the request trustworthy. Bavimail attaches two headers to every webhook: a timestamp (Unix seconds) and a signature computed as HMAC-SHA256 over the timestamp concatenated with a period and the raw request body, using your per-key secret. Verification is three operations: rebuild the signing string, compute the HMAC, compare in constant time.
In Node, the pattern uses the built-in crypto module to parse incoming email. Read the raw body (Express needs the json middleware configured with a verify hook so the raw bytes survive; Hono and Fastify expose them directly). Pull the bavimail-timestamp and bavimail-signature headers off the request. Concatenate timestamp, a period, and the raw body string. Pass that through crypto.createHmac with sha256 and your secret, hex-encode the digest, then compare to the header value using crypto.timingSafeEqual on Buffer instances of equal length. Reject if the timestamp is more than 300 seconds from now to enforce the 5-minute replay window.
For inbound email python, the same pattern uses hmac and hashlib from the standard library. With FastAPI, grab the raw body via await request.body() before parsing it as JSON. Compute the HMAC over the timestamp, a period, and the raw bytes, then call hmac.compare_digest against the bavimail-signature header. With Flask, request.get_data with cache set to true returns the same raw bytes. Either way, the timestamp check is identical: the absolute difference between time.time and the header timestamp must stay under 300.
Two failure modes catch developers off guard. First, ORM middleware or body-parsing middleware sometimes rewrites the request body before your handler sees it; the recomputed HMAC will not match because whitespace and key order shifted. Always sign against the bytes you received off the wire. Second, do not use string equality to compare signatures. Use the constant-time comparator from your standard library to defeat timing attacks. This is the full pattern for hmac webhook verification, and it is identical across both runtimes.
How should you handle attachments without bloating the payload?
Many inbound webhooks inline attachments as base64 strings, which bloats payloads to tens of megabytes and forces your HTTP framework to buffer huge requests in memory. Bavimail delivers attachments as pre-signed URLs in the attachments array. Each entry carries the filename, MIME type, size in bytes, and a short-lived signed URL pointing at object storage.
Your handler decides what to do with them. For a typical inbound flow, you would stream the URL contents into your own bucket or pass the URL directly to a downstream processor. For agent flows that summarize attachments, fetch the URL with your HTTP client, pipe the body into the LLM call, and let the signed URL expire on its own. The webhook payload itself stays under a few kilobytes regardless of attachment count, so your handler can run on minimal compute.
How do you thread replies back to the original conversation?
When your app sends a transactional email and the recipient replies, you want that reply to land back on the same conversation thread, not in a brand new ticket. Two tools make that work. First, every outbound email returns a message_id you can store. Second, every inbound webhook includes both an in_reply_to field (the message_id of the email being replied to) and a thread_id that Bavimail computes by walking the References header chain. Match the inbound in_reply_to against your stored message_id, or match on thread_id directly. Either lookup gives you the original conversation in one query.
For agent use cases, the per-agent alias pattern is cleaner. Send the outbound message from agent-42 at yourdomain.com, and every reply routes to the same handler tagged with that alias. The agent's full inbox history is one database query: all messages where alias equals agent-42, ordered by received timestamp.
What happens when your handler is slow or errors out?
Network failures, deploy windows, and momentary database hiccups happen. Bavimail retries any 5xx response or connection failure with an exponential backoff schedule, up to 5 attempts spread across roughly 24 hours. A brief incident on your side will not lose mail. Return a 2xx status as soon as you have durably enqueued the work, and do the slow stuff (LLM calls, image processing, downstream API hits) in a background worker. Anything over a few seconds risks a timeout on the webhook delivery side and triggers an unnecessary retry.
Two operational notes. Log the message_id on every received webhook and use it as your idempotency key, since retries will deliver the same payload again. And if you ever need to replay history, the inbound API exposes a list endpoint that returns the same payload shape your webhook receives, so a backfill is one paginated loop.
Ready to wire this up? Create a Bavimail account, point your domain's MX at our ingest cluster, and start receiving signed, parsed inbound webhooks in under ten minutes. Inbound is bundled on every plan with no separate add-on, and the Pro tier at $4 per month covers 10,000 emails inbound or outbound combined. The full setup guide and signature reference are linked above in the inbound email docs and the webhook reference.