Abstract flat illustration of an envelope sliding into a funnel that turns into a cable plugged into a small server block, with a lightning bolt above it

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 endpointStored copyIs the POST signed?Forged sendersThreads and replies
SendGrid Inbound ParseMultipart form POST, or the raw MIMENoneOptional security policy (ECDSA signature or OAuth token)SPF and DKIM fields for you to checkYour code
Mailgun RoutesForm POST with stripped-text and attachmentsNone on Free and Basic, 1 to 7 days on Foundation and ScaleYes, HMACHeaders, if you turn the spam filter onYour code
Postmark inboundJSON with StrippedTextReply and base64 attachments45 days by defaultNo: Basic Auth and IP allowlisting insteadSpamAssassin headersYour code
Amazon SESRaw MIME in S3, or in SNS up to 150 KBYour S3 bucketSNS messages are signed by AWSSPF, DKIM, DMARC, spam and virus verdictsYour code
ResendMetadata only; fetch the body by API30 daysYes (Svix)Headers via the APIYou set In-Reply-To
Cloudflare Email WorkersNo POST: your Worker runs with a raw MIME streamNone unless you write it to KV or R2Not applicablereply() needs a passing DMARC resultreply() once per message, to the sender only
ZapierZap fields, then a Webhooks by Zapier POSTNone you controlOnly headers you addNot in the trigger fieldsNot built in
CarlyEmailSigned JSON event: full message, thread_id, quote-stripped textKept until you delete itYes (Standard Webhooks, Svix-compatible)Separate message.received.unauthenticated eventThreads 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:

  1. 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.
  2. Admit only the event you meant. If spam and spoofed mail reach your model, an attacker’s instructions become your agent’s instructions.
  3. 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.
  4. Check the sender by parsing the whole address. A substring test for emma@example.com also passes emma@example.com.attacker.net, a domain anyone can register and sign mail from.
  5. Deduplicate by event ID. Webhook delivery is at least once, so the same email can arrive twice.
  6. 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:

EventFires when
message.receivedStored and clean: authenticated, not spam, sender not blocked
message.received.spamStored, and classified as spam
message.received.blockedStored, and the sender is on a block list
message.received.unauthenticatedStored, 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-timestamp and webhook-signature, with svix-* spellings sent too. The signature is HMAC-SHA256 over id.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 Gone turns the webhook off. After 3 failures in a row the endpoint pauses instead of being hammered.
  • Large mail. Above 1 MB the text and html fields are dropped and the payload is flagged truncated. 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:

CheckWhat the router does
SignatureVerified over the unmodified body; anything that fails, including a malformed header, gets 401 instead of a 500
ReplayTimestamps older than 5 minutes are rejected
Event typeExact match on message.received; spam, blocked and unauthenticated never get through
LoopsMail from CARLYEMAIL_INBOX itself is dropped with 204
SenderParsed whole and compared against allow_from (an address or @domain)
RedeliveryA repeated event_id gets 204
TimingAnswers 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 seeUsual cause
401 on every attemptThe body was parsed before verifying, or the secret belongs to a different webhook
Timeouts, then two replies to one emailThe model ran inside the request. Acknowledge first, work after
No attempts at allThe webhook isn’t subscribed to message.received, is filtered to other inboxes, or the mail was labelled spam or unauthenticated
Webhook shows enabled: falseIt 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:

RunExpect
python replay.py202
python replay.py message.received evt_same twice202, then 204
python replay.py message.received.unauthenticated204
Change the from to josh@yourcompany.com.attacker.net204
Set CARLYEMAIL_WEBHOOK_SECRET to another webhook’s secret401
Subtract 600 from the timestamp401

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 started
See the prompt
Read https://docs.carlyemail.com/llms.txt and set yourself up with an email address.