How to Parse Inbound Email Webhook Payloads
How to parse inbound email webhook payloads: required fields, MIME edge cases, attachments, threading ids, and idempotent retry handling.
John Joubert
Founder, Robotomail

Table of contents
To parse inbound email webhook payloads reliably, treat the webhook as an already-decoded message: read the plain text body, the from address, the thread id, and the message id, deduplicate on that message id, acknowledge with a 2xx within a couple of seconds, and do the real work in a queue. The hard part of inbound email is MIME decoding, and a good provider does that before the request reaches you. If your provider hands you raw RFC 5322 bytes instead, you own every edge case below.
This post covers what belongs in a parsed payload, which MIME cases break naive parsers, how attachments and threading ids should arrive, and how to make your handler idempotent under retries. If you are still choosing a provider, we compared the options in email APIs that support receiving.
What a good parsed payload contains
A useful inbound webhook gives you decoded, UTF-8 normalized fields. Robotomail posts { event, timestamp, data } with snake_case data fields:
{
"event": "message.received",
"data": {
"message_id": "...", "mailbox_id": "...", "mailbox_address": "shopping-agent@robotomail.co",
"from": "vendor@example.com", "subject": "Re: Order #A-4521 delivery update?", "body_text": "...",
"thread_id": "...", "received_at": "2026-04-17T10:00:00.000Z"
}
}
The minimum set an agent needs:
| Field | Why it matters |
|---|---|
message_id |
Stable idempotency key for dedupe across retries |
mailbox_id / mailbox_address |
Routes the message to the right agent when you run many mailboxes |
from |
Sender identity, already unfolded and decoded |
subject |
Decoded from RFC 2047 encoded-words, not =?UTF-8?B?...?= |
body_text |
Decoded, charset-converted plain text your model can read |
thread_id |
Groups the message with prior turns without you rebuilding the reference chain |
received_at |
Ordering and staleness checks |
Anything beyond that is a bonus: HTML body, attachment metadata, headers, spam and authentication results. What matters is that the text is already text. An LLM handling a support reply should never receive base64.
How to parse inbound email webhook payloads in your handler
- Verify the request before parsing. Check the signature or shared secret on the request. Never trust a webhook body just because it hit the right path.
- Read the idempotency key first. Pull
data.message_idand check your store. If you have seen it, return 200 immediately. - Persist the raw payload. Write the JSON body to durable storage before processing. Parsing bugs are recoverable if you kept the input.
- Return 2xx fast. Acknowledge, then process asynchronously. Model calls, tool use, and outbound sends all take longer than a webhook timeout allows.
- Do the work in a queue. Classify, look up context, draft, send. If the job fails, retry from your queue rather than depending on the provider retrying.
- Reply on the thread. Send from the same mailbox with
inReplyToset to the inbound message id.
A minimal Express handler:
app.post("/inbound", async (req, res) => {
const { event, data } = req.body;
if (event !== "message.received") return res.sendStatus(200);
const seen = await store.claim(data.message_id); // returns false if already claimed
if (!seen) return res.sendStatus(200);
await store.saveRaw(data.message_id, req.body);
await queue.enqueue("handle-email", { messageId: data.message_id });
res.sendStatus(200);
});
And the reply, once the job runs:
curl -X POST https://api.robotomail.com/v1/mailboxes/mbx_shopping/messages \
-H "Authorization: Bearer rm_your_api_key" \
-H "Content-Type: application/json" \
-d '{"to": ["vendor@example.com"], "subject": "Re: Order #A-4521 delivery update?", "bodyText": "Thanks, noting the new delivery date.", "inReplyTo": "msg_inbound_id"}'
MIME edge cases you avoid with a parsed payload
These are the cases that turn a weekend MIME parser into a six month project. We run mail infrastructure, so this list is not hypothetical.
Nested multipart trees. Real mail is rarely a flat multipart/alternative. Outlook and many mobile clients send multipart/mixed containing multipart/related containing multipart/alternative, with inline images referenced by cid: from the HTML part. Picking "the text body" means walking the tree, not taking part index 0.
Transfer encodings. Bodies arrive as quoted-printable or base64. Quoted-printable soft line breaks (a trailing =) must be joined before you interpret the text, or paragraphs come apart mid-word.
Charsets that are not UTF-8. ISO-8859-1, Windows-1252, Shift_JIS, GB2312, KOI8-R all still show up. Worse, senders lie: a part labeled us-ascii containing Windows-1252 smart quotes is common. You need charset detection fallbacks, not just a lookup table.
Encoded-word headers. Subjects and display names use RFC 2047 (=?UTF-8?B?...?=), sometimes split across multiple encoded words, sometimes mixing encodings within a single header, sometimes folded across lines. Decoding requires unfolding first.
Malformed boundaries. Missing final boundaries, duplicated boundary strings, boundaries containing characters that need quoting. A strict parser throws; a useful parser recovers and returns whatever text it can find.
HTML-only messages. Plenty of senders omit the plain text alternative entirely. You need HTML to text conversion that keeps link targets and list structure, because that text is what your agent reasons over. Our note on plain text email covers why the text part still matters.
message/rfc822 parts. Forwarded mail nests a complete message inside the outer one, with its own headers, its own MIME tree, and its own attachments. If you are extracting "the attachment", you now have to decide how deep to go.
TNEF. Some Exchange configurations send application/ms-tnef, the winmail.dat blob, which hides the real body and attachments inside a proprietary container.
Header injection attempts and oversized parts. Untrusted inbound mail includes messages designed to break parsers: multi-megabyte header blocks, thousands of parts, deeply recursive nesting. Limits belong in the parser, not in your handler.
When the provider decodes all of this, your handler reads data.body_text and moves on.
Attachments in an inbound webhook
Do not expect attachment bytes inline. A 20 MB base64 payload inside a webhook body means slow requests, timeouts, and log pollution. The right pattern is metadata in the webhook plus a fetch by id when you actually want the file:
- Filename, content type, and size arrive with the message.
- Bytes are retrieved on demand from the attachments endpoint.
- Inline images referenced by
cid:are distinguished from real attachments, so your agent does not "receive" a tracking pixel and a signature logo as documents.
Two things to enforce in your own code. First, never trust the declared content type or the file extension; sniff before you hand a file to a parser. Second, cap total bytes per message before processing, especially if a model or an OCR step is downstream. See attachments for the retrieval shape, and email virus scanning for handling untrusted files.
Threading ids: use the provider's, not your own
The RFC 5322 way to thread is Message-ID, In-Reply-To, and References, walking the reference chain to find the root. It works until it does not: clients that drop References, clients that reply with a fresh Message-ID and no In-Reply-To, mailing lists that rewrite headers, and top-posting clients that only preserve the subject.
A provider that assigns a thread_id has already reconciled the header chain plus subject normalization plus participant matching. Use it as your conversation key and store your own state keyed to it. Reply with inReplyTo set to the inbound message_id so the sender's client threads it correctly on their side too. There is more on the matching rules in email threading.
Threading matters more for agents than for humans. An agent that loses the thread starts a fresh conversation with a vendor who is mid negotiation, which reads as broken. If you are wiring an agent up for the first time, the receive and reply guide is the shortest path.
Retries and idempotency
Webhook delivery is at-least-once. Assume duplicates.
Dedupe on the provider's message id, not on content. Two different messages can have identical bodies. The same message will be delivered twice with the same id.
Claim atomically. A conditional insert on a unique index, or a Redis SET NX with a TTL, is enough. Checking then inserting in two steps races with a concurrent retry, and you send two replies.
Make the claim outlive the retry window. If your provider retries for 24 hours with backoff, your dedupe records need to live longer than that.
Separate acknowledgement from success. Returning 200 means "I have durably accepted this message". It does not mean the agent finished. If you return 500 after already enqueuing work, the retry will enqueue it again.
Non-2xx should be reserved for real failures. Return 4xx for a payload you will never process, 5xx when you want the retry, 200 for everything you have safely stored. Unknown event types get a 200.
Track ordering separately. Retries and backoff mean a later message can arrive before an earlier one. Sort by received_at when reconstructing thread history rather than trusting arrival order.
Robotomail retries failed deliveries with backoff and exposes delivery attempts, so you can see what your endpoint returned instead of guessing. Details are in webhooks.
FAQ
Should I parse raw MIME myself?
Only if you have a reason to, such as archival requirements or forensic header analysis. For agent workloads, a decoded payload removes an entire category of bugs. If you do parse raw mail, use a maintained library rather than writing a boundary splitter.
How do I stop my agent replying twice to one email?
Claim the message_id atomically before doing any work, and keep the claim longer than the provider's retry window. Send the reply from inside the same job that holds the claim, and record the outbound message id against the inbound one.
What if the webhook payload has no plain text body?
The provider should derive text from HTML when no text part exists. If you are handling that yourself, convert HTML to text while preserving link URLs and list markers, then strip quoted history and signatures before passing the result to a model.
How do I test inbound parsing without waiting for real mail?
Send yourself messages from several different clients: Gmail web, Outlook desktop, an iOS client, and a script that sends raw MIME with odd charsets and nested parts. Then replay saved payloads against your handler. Our notes on testing email delivery cover the setup.
Robotomail gives every agent a real mailbox with decoded inbound webhooks, thread ids, and attachment retrieval, so your handler is a few lines instead of a MIME project. Start at robotomail.com or read the docs.
Give your AI agent a real email address
One API call creates a mailbox with full send and receive. Webhooks for inbound, automatic threading, deliverability handled. 30-day money-back guarantee.
Related posts

OpenAI Agents SDK Email: Give Your Agent an Inbox
Wire an OpenAI Agents SDK agent to a real mailbox: send and reply as function tools, inbound webhook driving the loop, threading handled. Working code.
Read post
Hermes Agent Email: Give Your Nous Agent an Inbox
Step-by-step guide to wiring a real email inbox into a Hermes agent: mailbox creation, tool schemas, inbound webhooks, and threaded replies.
Read post
Email for OpenClaw Agents: Full Integration Guide
Wire real email into OpenClaw agents: provision a mailbox, expose send and reply tools, handle inbound webhooks, and keep threading correct.
Read post