Webhooks or polling: how your agent should learn that mail arrived
An agent that can send email is half an integration. The other half is knowing that something arrived, and the choice you make there sets your latency, your cost, and how much state you have to keep. There are two options and one sensible combination.
5 min read
The comparison
| Polling | Webhooks | |
|---|---|---|
| Latency | Half your interval, on average | Seconds |
| Cost when idle | Every check, forever | Nothing |
| Infrastructure | A scheduler | A public endpoint |
| Failure mode | Silent lag | Failed delivery, retried |
| State you keep | A cursor, and what you have seen | A record of handled message ids |
| Good for | Local development, batch work | Anything a person is waiting on |
The asymmetry that decides it: with polling you pay most when nothing is happening, which is most of the time. A five minute interval on a quiet inbox is thousands of requests a month to learn nothing, and it still leaves an average two and a half minute delay before a customer gets an answer.
Webhooks, done properly
Register an endpoint and MCPmailer posts signed events to it: message.received, message.bounced, message.complained, and message.filtered, with an HMAC-SHA256 signature and five retries on failure. Full details are in webhooks.
Three rules make the difference between a webhook handler that works and one that pages you.
Verify the signature before you parse. An endpoint that trusts an unsigned POST is an endpoint anyone can use to start agent runs, which is both a cost problem and an injection channel. The check has two halves: the HMAC over the raw body has to match, and the timestamp in the header has to be recent, or a delivery captured once stays valid forever. The SDK does both.
const raw = await request.text(); // raw body, not parsed JSON
const event = await verifyWebhook(raw, request.headers, secret);
if (!event) return new Response('bad signature', { status: 401 });Return 200 fast, work afterwards. The delivery is telling you something happened, not asking you to handle it while it waits. Acknowledge, queue, and let the agent run outside the request. A handler that runs a model call inline will eventually exceed the delivery timeout, get retried, and run twice.
Be idempotent. Retries are a feature and duplicates are a certainty. Idempotency alone does not stop two runs racing on one thread, which needs the per-thread serialisation in what happens when forty messages arrive at once. Key on the message id, record that you have handled it, and check that record before doing anything with a side effect. This is the same discipline described in long-running email conversations, for the same reason.
if (await seen(event.message_id)) return new Response('ok'); // already handled
await queue.send({ type: 'answer', message_id: event.message_id });
await markSeen(event.message_id);
return new Response('ok');
Polling, when you have to
Polling is the right answer in exactly three situations: local development where you have no public URL, an environment that cannot accept inbound connections, and batch work where latency genuinely does not matter, such as a nightly sweep that archives resolved threads.
Do it politely. list_messages with a filter and a sensible page size, on an interval measured in minutes rather than seconds, with a cursor so you are not re-reading the same page. Rate limits are 300 requests per minute per key across REST and MCP, and a 429 carries retry-after in seconds; the correct response is to wait rather than retry harder.
The thing to avoid is polling from inside the agent loop, where the model decides when to check. That produces a run that burns tokens waiting, which is what wait_for_reply exists to prevent.
The hybrid nearly everyone lands on
Webhooks for anything arriving cold, a blocking wait for the conversation the agent is already in, and no polling at all.
message.received webhook -> run: get_thread, lookup_contact, reply
wait_for_reply { timeout_seconds: 86400 }
-> continue in the same run, or exit and let the
next webhook start a fresh oneThat combination gives seconds of latency on first contact, coherent multi-turn conversations without a scheduler, and no cost when the inbox is quiet. It is also the shape that works identically over MCP and the REST API, because the transport does not change the pattern.
Where each event actually matters
message.received is the one people register first, and the other three are the ones that keep you out of trouble.
- `message.bounced`. Suppress the address in your own system, stop the agent retrying, and if it is permanent, never send there again.
- `message.complained`. Treat as an immediate stop for that recipient and a signal to look at what the agent has been sending. One is noise, a pattern is a verdict.
- `message.filtered`. Inbound mail that a rule kept out. Worth watching when an agent is in whitelist mode, because it tells you who is trying to reach it and cannot.
Ignoring the last three is the most common gap in otherwise well-built integrations, and it is the reason a deliverability problem is usually discovered a fortnight after it started. See deliverability for agent senders.
If you would rather not run a service at all, the same shape works in an automation tool, per an email agent without writing a service.
Local development without a public URL
You need the events on your laptop, and the usual answer is a tunnelling tool. MCPmailer gives every identity a stable hostname at <handle>.mcpmailerwire.com that forwards inbound HTTP to wherever you are running, over a connection you hold open, so a webhook can reach a handler on localhost without a third-party tunnel or a new URL every restart. Only the key belonging to that handle can open it. The SDK covers the client side in SDKs.
Questions
- Should my agent poll for new email or use webhooks?
- Webhooks for anything a person is waiting on. Polling only for local development, environments that cannot accept inbound connections, or batch work where latency does not matter.
- How do I verify a webhook is really from MCPmailer?
- Call
verifyWebhookfrom the SDK, or compute an HMAC-SHA256 over"<timestamp>.<raw body>"with your signing secret, compare it to the signature header in constant time, and reject a timestamp far from now. Do all of it before parsing the body. - What happens if my endpoint is down?
- Delivery is retried five times, waiting about 30 seconds, then 2, 8, and 30 minutes, so a restart or a short outage does not lose the event. Answer 429 or 503 with a
Retry-Afterand that wait is used instead. Because retries happen, your handler must be idempotent, keyed on the delivery id, which is the same across every retry of one event. - Why does my agent sometimes reply twice?
- Almost always a non-idempotent webhook handler combined with a retry, or a handler doing slow work inline and exceeding the delivery timeout. Acknowledge fast, queue the work, and record handled message ids.
- Can I use webhooks and `wait_for_reply` together?
- Yes, and that is the recommended shape: webhooks start a run when mail arrives cold, and the blocking wait carries the conversation the agent is already having.
- Do webhooks work with the REST API as well as MCP?
- Yes. Webhooks are a property of the workspace and identity, not of the transport, so the same events arrive whichever way the agent sends.
Give your agent an address it can answer from.
Create an inbox