Email to Webhook: Turn Incoming Email Into an HTTP Event
To turn email into a webhook, point an address at a service that receives the mail and POSTs it to your URL. SendGrid, Mailgun, Postmark, Amazon SES, Resend, Cloudflare and Zapier all do some version of this. They differ in what lands on your endpoint: raw MIME or parsed JSON, a signed request or an unsigned one, a stored message or nothing at all.
If the webhook exists to wake an AI agent that has to answer, the POST is the easy part. Storage, threading, spoof checks and a reply that lands in the right thread are the actual work.
The quick answer: for a one-way pipe (an order email becomes a database row), Mailgun Routes or Postmark inbound gets you there fastest. For mail that has to be read and answered, CarlyEmail gives each agent a real inbox and fires a signed message.received webhook once the message is stored, carrying the parsed message, its thread_id and the new text with the quoted chain stripped. Forged and spam mail arrive as separate event types, so they never reach your handler, and one reply call lands in the sender’s thread. It’s free for 3 inboxes, 1,000 emails a month and 2 webhooks, and received mail never counts against the email quota.
Vendor details below were read from each provider’s live docs and pricing pages on October 2, 2026.
Email-to-webhook options compared
| What hits your endpoint | Stored copy | Is the POST signed? | Forged senders | Threads and replies | |
|---|---|---|---|---|---|
| SendGrid Inbound Parse | Multipart form POST, or the raw MIME | None | Optional security policy (ECDSA signature or OAuth token) | SPF and DKIM fields for you to check | Your code |
| Mailgun Routes | Form POST with stripped-text and attachments | None on Free and Basic, 1 to 7 days on Foundation and Scale | Yes, HMAC | Headers, if you turn the spam filter on | Your code |
| Postmark inbound | JSON with StrippedTextReply and base64 attachments | 45 days by default | No: Basic Auth and IP allowlisting instead | SpamAssassin headers | Your code |
| Amazon SES | Raw MIME in S3, or in SNS up to 150 KB | Your S3 bucket | SNS messages are signed by AWS | SPF, DKIM, DMARC, spam and virus verdicts | Your code |
| Resend | Metadata only; fetch the body by API | 30 days | Yes (Svix) | Headers via the API | You set In-Reply-To |
| Cloudflare Email Workers | No POST: your Worker runs with a raw MIME stream | None unless you write it to KV or R2 | Not applicable | reply() needs a passing DMARC result | reply() once per message, to the sender only |
| Zapier | Zap fields, then a Webhooks by Zapier POST | None you control | Only headers you add | Not in the trigger fields | Not built in |
| CarlyEmail | Signed JSON event: full message, thread_id, quote-stripped text | Kept until you delete it | Yes (Standard Webhooks, Svix-compatible) | Separate message.received.unauthenticated event | Threads built on arrival; reply endpoint sets the headers |
Every row above CarlyEmail hands you one message at a time. Linking it to the last message in the conversation, and replying so the answer lands in the same thread, is code you write.
How each inbound email webhook works
SendGrid Inbound Parse
Point a hostname’s MX record at mx.sendgrid.net (it has to be an authenticated domain), map it to one URL, and SendGrid POSTs every message as multipart form data: headers, text and HTML bodies, attachments, SPF and DKIM results, and a spam score if you enable it. A raw option sends the full MIME instead. Nothing is stored. A 5xx is retried for up to 3 days, then the message is dropped without notice. SendGrid no longer has a permanent free plan, only a 60-day trial. SendGrid vs Mailgun compares the two inbound products side by side.
Mailgun Routes
A route pairs a filter (match_recipient(), match_header() or catchall()) with actions: forward() to your URL, store(), stop(). Forwarded mail arrives HMAC-signed with body-plain, stripped-text (the new reply without the quoted chain) and attachments. Failed POSTs are retried for 8 hours. Free gets 1 route and Basic 5, and store() keeps a copy only as long as your plan’s message retention: none on Free and Basic, 1 day on Foundation, up to 7 on Scale.
Postmark inbound
Postmark POSTs JSON with TextBody, HtmlBody, StrippedTextReply, the headers (including SpamAssassin’s X-Spam-Score), and base64 attachments. Postmark doesn’t sign webhooks; its docs tell you to protect the endpoint with HTTP Basic Auth and IP allowlisting. A non-200 is retried 10 times at intervals from 1 minute up to 6 hours, and a 403 stops the retries. Inbound is only on the Pro and Platform plans, and each inbound message counts as one email against your plan.
Amazon SES receipt rules
SES charges $0.10 per 1,000 received messages plus $0.09 per 1,000 chunks of 256 KB. A receipt rule writes the raw MIME to S3 (up to 40 MB) or publishes it to SNS, where anything over 150 KB bounces. A Lambda action receives metadata and headers but not the body, so the usual chain is S3, then Lambda, then fetch and parse the MIME. To get an actual webhook, subscribe your HTTPS URL to the SNS topic, confirm the subscription and verify AWS’s message signature. Amazon SES pricing itemizes everything you build after that.
Resend
Resend receives mail on every plan and fires an email.received webhook signed with Svix. The webhook carries metadata only (sender, subject, message_id); you call the Received emails API for the body, headers and attachments. A reply stays in the thread only if you set In-Reply-To yourself. Each received email counts against the same daily and monthly quota as a send, so on the free plan an agent that receives 60 emails in a day can send 40. Received mail is kept 30 days. Resend pricing has the tiers.
Cloudflare Email Workers
There’s no webhook here: Email Routing runs your Worker’s email() handler with the envelope, the headers and a raw MIME stream, which you parse with a library like postal-mime. From the handler you forward(), setReject(), reply(), or fetch() your own endpoint and sign that request yourself. Inbound routing is free on both Workers plans (the Worker itself bills as normal Workers usage) and takes messages up to 25 MiB. The limits: the domain must use Cloudflare DNS, nothing is stored unless you write it to KV or R2, and reply() works once per message, only to the original sender, and only when the incoming mail has a valid DMARC result. Cloudflare Email Service covers the rest of the stack.
Zapier email parser
Two no-code routes. Email by Zapier gives you a zapiermail.com address with an instant “New Inbound Email” trigger. Email Parser by Zapier gives you a @robot.zapier.com mailbox where you highlight fields to build a template. Either can feed a Webhooks by Zapier POST to your URL, but Webhooks by Zapier is a Premium app, so that step needs a paid plan, and the request is only as authenticated as the headers you add. It works for a fixed-format notification and breaks on mail people write by hand. Zapier Email Parser alternatives covers the parser side.
Six things every inbound email handler has to get right
Whichever provider you pick, the route that receives the POST makes the same six decisions. Each one fails quietly when it’s missed:
- Verify the signature over the raw bytes, and reject stale timestamps. A framework that parses the JSON and re-serializes it changes the bytes, and the check fails even with the right secret. A five-minute window stops a captured delivery from being replayed later.
- Admit only the event you meant. If spam and spoofed mail reach your model, an attacker’s instructions become your agent’s instructions.
- Drop mail the inbox sent itself. An alias or a mailing list can loop your agent’s reply back in, and an agent answering itself does not stop on its own.
- Check the sender by parsing the whole address. A substring test for
emma@example.comalso passesemma@example.com.attacker.net, a domain anyone can register and sign mail from. - Deduplicate by event ID. Webhook delivery is at least once, so the same email can arrive twice.
- Answer the HTTP request before the model runs. A model turn outlasts the delivery timeout, and a timed-out delivery is retried into a second reply.
On SendGrid, Mailgun, Postmark, SES or Resend, that’s your code, plus storage and threading. CarlyEmail’s Python SDK ships all six in one function.
CarlyEmail: the inbox and the webhook in one
CarlyEmail is an email API that gives AI agents real inboxes. One API call creates an address on carlyemail.com or your own domain, with SPF, DKIM and DMARC set up for you. Mail from Gmail, Outlook or anything else is authenticated, stored and threaded, usually within a couple of seconds, and then the event fires. Nothing is acknowledged to the sending server before it’s stored.
Received mail arrives as one of four event types, so a subscription to the first never sees the other three:
| Event | Fires when |
|---|---|
message.received | Stored and clean: authenticated, not spam, sender not blocked |
message.received.spam | Stored, and classified as spam |
message.received.blocked | Stored, and the sender is on a block list |
message.received.unauthenticated | Stored, and it failed DMARC, or failed both SPF and DKIM |
Spoofed mail is kept and labelled unauthenticated instead of dropped, so you can inspect it later. It just never reaches a message.received handler. Coming from AgentMail? The event body follows the same contract as AgentMail’s webhook and WebSocket clients and is signed the same Svix way, so your parsing and verification carry over once you swap the secret and the client, and mail that fails SPF and DKIM shows up labelled where AgentMail drops it. CarlyEmail vs AgentMail covers the rest.
Set up an inbox and a webhook from the CLI:
npx carlyemail signup --human-email you@example.com --username agent
npx carlyemail verify 123456
npx carlyemail webhook https://your-app.example/hooks/carlyemail --events message.received
The last command prints a whsec_ signing secret once. Pass --events message.received deliberately: a webhook created without event types receives every event, including the spam and unauthenticated variants and the delivery events. You can also narrow a webhook to specific inboxes or pods, which is how per-customer agents each get their own endpoint. Free includes 2 webhooks, Startup ($20 a month) 10, and Business ($200 a month) 50.
Here’s what a delivery carries (trimmed):
{
"type": "event",
"event_type": "message.received",
"event_id": "evt_00ms93lsavnm83rw519vzpgw",
"message": {
"inbox_id": "agent@carlyemail.com",
"message_id": "<CAJ2x9k@mail.gmail.com>",
"thread_id": "thd_...",
"from": "Josh Miller <josh@yourcompany.com>",
"subject": "Re: Invoice 1042",
"text": "Can you resend it as a PDF?\n\nOn Tue, Claire wrote: ...",
"extracted_text": "Can you resend it as a PDF?"
},
"thread": { "thread_id": "thd_...", "message_count": 3 }
}
The details that matter for a handler, from the webhooks guide:
- Signing. Three headers,
webhook-id,webhook-timestampandwebhook-signature, withsvix-*spellings sent too. The signature is HMAC-SHA256 overid.timestamp.body, so an existing Svix verifier works unchanged. - Acknowledging. Any 2xx within 10 seconds counts as delivered.
- Retries. Seven attempts over about 18 hours: retries after roughly 1, 5 and 30 minutes, then 2, 5 and 10 hours. A
410 Goneturns the webhook off. After 3 failures in a row the endpoint pauses instead of being hammered. - Large mail. Above 1 MB the
textandhtmlfields are dropped and the payload is flaggedtruncated. Fetch the message by ID. - Nothing lost. Every event is written to an event log before delivery is attempted, so anything your endpoint missed can be read back.
A webhook handler that verifies and replies in-thread (Python)
pip install carlyemail fastapi uvicorn
export CARLYEMAIL_API_KEY=ce_us_...
export CARLYEMAIL_INBOX=agent@carlyemail.com
export CARLYEMAIL_WEBHOOK_SECRET=whsec_...
# app.py
import asyncio
from carlyemail import CarlyEmail
from carlyemail.inbound import create_email_router
from fastapi import FastAPI
carly = CarlyEmail() # reads CARLYEMAIL_API_KEY
app = FastAPI()
def answer_and_reply(email):
# Deliveries over 1 MB arrive without a body: fetch it by id.
text = email.text or (
carly.messages.get(email.inbox_id, email.message_id).get("extracted_text") or ""
)
answer = run_agent(email.thread_id, text) # your model, memory keyed by thread
carly.messages.reply(email.inbox_id, email.message_id, {"text": answer})
async def on_email(email):
# The SDK client is synchronous; keep it off the event loop.
await asyncio.to_thread(answer_and_reply, email)
app.include_router(
create_email_router(
on_email,
path="/hooks/carlyemail",
allow_from=["@yourcompany.com"], # who may give the agent work; omit for anyone
)
)
on_email only runs for mail worth answering. Before it’s called, create_email_router has already made all six decisions:
| Check | What the router does |
|---|---|
| Signature | Verified over the unmodified body; anything that fails, including a malformed header, gets 401 instead of a 500 |
| Replay | Timestamps older than 5 minutes are rejected |
| Event type | Exact match on message.received; spam, blocked and unauthenticated never get through |
| Loops | Mail from CARLYEMAIL_INBOX itself is dropped with 204 |
| Sender | Parsed whole and compared against allow_from (an address or @domain) |
| Redelivery | A repeated event_id gets 204 |
| Timing | Answers 202 first, then runs your handler in the background |
email.text is the new writing with the quoted chain stripped, so a long thread isn’t resent to the model on every turn. email.thread_id is the key for your agent’s memory. carly.messages.reply sets In-Reply-To and References, so the answer lands inside the sender’s existing conversation in Gmail or Outlook. Not on FastAPI? InboundReceiver(...).decide(body, headers) is the same logic with no framework attached, for Flask, Django or a Lambda handler (receiving guide).
The dedupe memory is in-process and resets on restart, which covers the redeliveries that cluster after a failure. If acting twice on one email is costly, record event_id in your database too.
The same handler in TypeScript
The npm package includes a dependency-free verifier built on Web Crypto, so the same code runs in Node, Cloudflare Workers and other edge runtimes. Here it is as a Next.js route:
// app/hooks/carlyemail/route.ts
import { after } from "next/server";
import { CarlyEmail } from "carlyemail";
import {
verifyWebhook,
WebhookVerificationError,
type CarlyEmailEvent,
} from "carlyemail/webhooks";
const carly = new CarlyEmail(); // reads CARLYEMAIL_API_KEY
const ALLOW = ["@yourcompany.com"];
const seen = new Set<string>(); // use your database in production
type Inbound = {
inbox_id: string; message_id: string; thread_id: string; from: string;
extracted_text?: string | null; text?: string | null;
};
export async function POST(request: Request) {
const body = new Uint8Array(await request.arrayBuffer()); // raw bytes, never request.json()
let event: CarlyEmailEvent;
try {
event = await verifyWebhook(process.env.CARLYEMAIL_WEBHOOK_SECRET!, body, request.headers);
} catch (err) {
if (err instanceof WebhookVerificationError) return new Response(null, { status: 401 });
throw err;
}
// Exact match: .spam, .blocked and .unauthenticated are separate event types.
if (event.event_type !== "message.received") return new Response(null, { status: 204 });
const msg = event.message as Inbound;
const sender = (msg.from.match(/<([^>]+)>/)?.[1] ?? msg.from).trim().toLowerCase();
const domain = sender.slice(sender.lastIndexOf("@"));
if (sender === msg.inbox_id.toLowerCase()) return new Response(null, { status: 204 });
if (!ALLOW.includes(sender) && !ALLOW.includes(domain)) return new Response(null, { status: 204 });
if (seen.has(event.event_id)) return new Response(null, { status: 204 });
seen.add(event.event_id);
after(async () => {
const text = msg.extracted_text || msg.text ||
(await carly.messages.get(msg.inbox_id, msg.message_id)).extracted_text || "";
const answer = await runAgent(msg.thread_id, text); // your model
await carly.messages.reply(msg.inbox_id, msg.message_id, { text: answer });
});
return new Response(null, { status: 202 });
}
In a Cloudflare Worker, swap after() for ctx.waitUntil(), read the secret from env, and create the client with new CarlyEmail({ apiKey: env.CARLYEMAIL_API_KEY }). That’s also the shortest path if you like Email Workers but want stored inboxes and threads: CarlyEmail holds the mail and your Worker only handles the signed event. The agent email guide has versions for LangChain, the OpenAI Agents SDK, the Claude Agent SDK, Vercel AI SDK, Mastra and Cloudflare Agents.
Testing an inbound email webhook locally
1. Tunnel to your machine
uvicorn app:app --port 8000
ngrok http 8000 # or:
cloudflared tunnel --url http://localhost:8000
CarlyEmail only accepts https:// webhook URLs, and both tunnels give you one. A webhook’s URL can’t be edited after creation, so a tunnel that keeps its hostname across restarts saves you deleting and re-registering the webhook (and saving a new secret) every session.
2. Register the tunnel and send a real email
npx carlyemail webhook https://<your-tunnel-host>/hooks/carlyemail --events message.received
Put the printed secret in CARLYEMAIL_WEBHOOK_SECRET, restart the server, and email the agent’s address from your own Gmail or Outlook (an address that allow_from admits). The reply should show up in the same thread in your mail client.
3. Read the delivery log
When nothing happens, ask CarlyEmail what it sent and what your endpoint answered:
curl "https://api.carlyemail.com/v0/webhooks/attempts?limit=20" \
-H "Authorization: Bearer $CARLYEMAIL_API_KEY"
Each attempt lists the event type, URL, status code, duration and, for failures, the start of your response body. The SDKs have it as carly.webhooks.list_all_attempts(limit=20) and carly.webhooks.listAllAttempts({ limit: 20 }), and GET /v0/webhooks/{webhook_id}/attempts narrows it to one endpoint. What the log usually tells you:
| What you see | Usual cause |
|---|---|
401 on every attempt | The body was parsed before verifying, or the secret belongs to a different webhook |
| Timeouts, then two replies to one email | The model ran inside the request. Acknowledge first, work after |
| No attempts at all | The webhook isn’t subscribed to message.received, is filtered to other inboxes, or the mail was labelled spam or unauthenticated |
Webhook shows enabled: false | It failed for 24 hours or answered 410. PATCH /v0/webhooks/{webhook_id} with {"enabled": true} turns it back on |
For the third row, list the inbox with include_unauthenticated and include_spam set to see whether the message arrived and how it was labelled.
4. Replay signed deliveries without sending email
You don’t need a real email to test admission logic. This script signs a delivery with your secret exactly the way CarlyEmail does:
# replay.py: python replay.py [event_type] [event_id]
import base64, hashlib, hmac, json, os, sys, time, uuid
import httpx
secret = os.environ["CARLYEMAIL_WEBHOOK_SECRET"]
event_type = sys.argv[1] if len(sys.argv) > 1 else "message.received"
event_id = sys.argv[2] if len(sys.argv) > 2 else f"evt_test_{uuid.uuid4().hex}"
body = json.dumps({
"type": "event",
"event_type": event_type,
"event_id": event_id,
"message": {
"inbox_id": os.environ["CARLYEMAIL_INBOX"],
"message_id": "<test-1@yourcompany.com>",
"thread_id": "thd_test",
"from": "Josh Miller <josh@yourcompany.com>",
"subject": "Invoice 1042",
"extracted_text": "Can you resend the invoice as a PDF?",
},
}).encode()
timestamp = str(int(time.time()))
key = base64.b64decode(secret.removeprefix("whsec_"))
digest = hmac.new(key, f"{event_id}.{timestamp}.".encode() + body, hashlib.sha256).digest()
r = httpx.post(
"http://localhost:8000/hooks/carlyemail",
content=body,
headers={
"content-type": "application/json",
"webhook-id": event_id,
"webhook-timestamp": timestamp,
"webhook-signature": "v1," + base64.b64encode(digest).decode(),
},
)
print(r.status_code) # 202 runs the agent, 204 ignored on purpose, 401 failed verification
Then run the failure cases on purpose before production does:
| Run | Expect |
|---|---|
python replay.py | 202 |
python replay.py message.received evt_same twice | 202, then 204 |
python replay.py message.received.unauthenticated | 204 |
Change the from to josh@yourcompany.com.attacker.net | 204 |
Set CARLYEMAIL_WEBHOOK_SECRET to another webhook’s secret | 401 |
| Subtract 600 from the timestamp | 401 |
The agent’s reply call will fail on the made-up message_id, which is fine for testing the gate. To run the whole loop, paste a real message_id and thread_id from npx carlyemail messages agent@carlyemail.com --json.
5. No tunnel? Use the WebSocket
The same events stream over a WebSocket, which needs no public URL. That suits local development and long-running agents; keep the webhook for serverless, because a delivery wakes a sleeping function and retries on its own.
const ws = new WebSocket(
`wss://ws.carlyemail.com/v0?api_key=${encodeURIComponent(process.env.CARLYEMAIL_API_KEY)}`,
);
ws.addEventListener("open", () =>
ws.send(JSON.stringify({
type: "subscribe",
event_types: ["message.received"],
inbox_ids: ["agent@carlyemail.com"],
})),
);
ws.addEventListener("message", ({ data }) => {
const frame = JSON.parse(data);
if (frame.type === "event" && frame.event_type === "message.received") handle(frame);
});
Reconnect with backoff when the socket closes, and don’t log the URL: it contains your API key.
6. Catch up on anything you missed
When the tunnel was down for an afternoon, the event log still has every arrival:
curl "https://api.carlyemail.com/v0/events?event_types=message.received&limit=50" \
-H "Authorization: Bearer $CARLYEMAIL_API_KEY"
The window defaults to the last 7 days; pass start to go further back.
When you don’t need an inbox
If the email is a machine-generated notification and the job ends at “extract three fields,” a parser is the right size: Mailgun Routes, Postmark inbound or a Zapier parser will do it for less. If mail has to come back and be answered, by an agent or a support desk or anything with a conversation, that’s what CarlyEmail is for. It’s free for 3 inboxes and 1,000 emails a month, then $20 a month for 25 inboxes and 10,000 emails, and every plan gets the whole API. Get started, or read email APIs for AI agents and the best email APIs for the wider field.
FAQ
What is an email-to-webhook service?
A service that receives mail for an address or domain and sends each message to your URL as an HTTP POST. SendGrid Inbound Parse, Mailgun Routes, Postmark inbound, Resend and Amazon SES (through SNS) all work this way. CarlyEmail also stores and threads the mail, and its webhook fires with the thread_id so an agent can pick up the right conversation.
How do I parse incoming email into JSON?
Use a provider that parses for you. Postmark POSTs JSON with TextBody, HtmlBody and StrippedTextReply. Mailgun sends form fields, including stripped-text. CarlyEmail sends a JSON event with the full message and extracted_text, the new writing without the quoted chain. Amazon SES and Cloudflare Email Workers hand you raw MIME, which you parse with a MIME library yourself.
Which inbound email webhooks are signed?
Mailgun signs with HMAC, Resend and CarlyEmail use the Svix scheme, SendGrid can sign with an ECDSA security policy, and SES’s SNS notifications carry an AWS signature. Postmark doesn’t sign inbound webhooks and recommends Basic Auth plus IP allowlisting. Zapier’s webhook step carries only the headers you add.
How do I test an inbound email webhook locally?
Run your handler, expose it with ngrok or cloudflared tunnel, register the HTTPS URL, and send a real email. On CarlyEmail, GET /v0/webhooks/attempts shows every delivery with its status code and response, and a WebSocket subscription skips the tunnel entirely. Replaying signed fixtures lets you test bad signatures, redeliveries and spoofed event types without sending mail.
Does received email count against my sending quota?
It depends on the provider. On Resend, every received email counts against the same daily and monthly quota as a send, and on Postmark each inbound message counts as one email. On CarlyEmail, only sent mail counts toward the email caps; received mail is free of them and kept until you delete it.
Can a webhook reply to the email in the same thread?
Only if the reply carries the right In-Reply-To and References headers. With SendGrid, Mailgun, Postmark, SES and Resend you set those yourself on a separate send. Cloudflare’s reply() answers in the same SMTP session, but only once per message, and you still build the MIME with In-Reply-To yourself. CarlyEmail’s messages.reply(inbox, message_id, ...) sets the headers for you, and the reply lands in the sender’s existing conversation.
Give your agent a real inbox
Your agent gets its own email address. People can email it, it answers in the same thread, and your personal inbox stays out of it. Start with 3 inboxes, no card needed.
Get startedSee the prompt
Read https://docs.carlyemail.com/llms.txt and set yourself up with an email address.


