Introducing Polling Webhooks
Polling webhooks let an AI agent fetch its email events from a queue whenever it runs, with no public URL or open connection. Built in partnership with Svix.
- webhooks
- polling-webhooks
- svix
- ai-agents
- serverless
TL;DR
- Polling webhooks let an AI agent fetch its email events from a queue whenever it runs, with no public URL and no open connection.
- If the agent is offline, events wait for up to 90 days and it picks up where it left off. That fits agents in sandboxes, serverless functions, cron jobs and laptops.
- We built polling webhooks in partnership with Svix, who already deliver AgentMail's webhooks. They're available now in the Python and TypeScript SDKs, in the US region.
An agent that handles email needs to know when a message arrives. Until now, AgentMail gave you two ways to find out: a push webhook, where we send a POST to a URL you host, or a WebSocket, where you keep a connection open and events stream in.
Both assume your agent runs on infrastructure that's always there. A lot of agents don't. They run in a Claude Code or Codex sandbox, inside a Lambda or Modal function, as a cron job, on a laptop, or behind a corporate firewall. Some of those have no public address at all, and most of them aren't running most of the time.
Polling webhooks are for those agents. You subscribe to the same event types as a regular webhook, and AgentMail queues the matching events. Your agent fetches them when it runs, marks what it handled, and the next fetch starts from there. We built it in partnership with Svix, the team that already delivers our push webhooks.
Why do agents need polling webhooks?
A push webhook needs a public endpoint that returns a 2xx within 15 seconds. If your code isn't up, we retry eight times over about 27.5 hours. If every delivery fails for five days, the endpoint is disabled. You don't get a notification, and events from the gap are not redelivered.
That works for a server that's always up. An agent that wakes for twenty minutes a day has no URL to give us, and nothing listening when the retries come.
A WebSocket gets rid of the URL and replaces it with an uptime requirement. The connection has to stay open, and an event that arrives while the agent isn't connected never reaches it.
Both options were designed around a process that keeps running. Agents are often short-lived processes that start, do some work and exit.
How is a polling webhook different from a regular webhook?
A polling webhook is a webhook you read instead of one we deliver. Nothing in your setup has to be reachable from the internet. It covers the same events a regular webhook does, from message.received to the calendar events.
| Push webhook | WebSocket | Polling webhook | |
|---|---|---|---|
| Public URL | Required | Not required | Not required |
| Open connection | Not required | Required | Not required |
| While the agent is offline | Retried, then the endpoint is disabled | Events are missed | Events wait for up to 90 days |
| When events arrive | As they happen | As they happen | On the agent's next fetch |
| Fits | Servers | Long-running processes that need low latency | Sandboxes, serverless functions, cron jobs, laptops |
Svix runs the queue, on the same infrastructure that already delivers our push webhooks. Your agent reads it with the AutoConfigConsumer from the Svix SDK.
What can you build with polling webhooks?
- A coding agent that checks its inbox when it starts. A Claude Code or Codex session drains the queue on startup, handles every message that arrived since its last run, and commits before it exits. Nothing has to run between sessions.
- A serverless support agent. A Lambda or Modal function runs every few minutes, fetches new
message.receivedevents and replies. Each reply goes out on the first run after the email arrives. - An agent behind a corporate firewall. The agent only makes outbound requests to read the queue, so it works on a network that blocks inbound traffic. On a laptop, it picks up the backlog when the machine wakes.
- A second reader for the same events. Give an audit log or an analytics job its own consumer ID on the same token. It sees every event without moving the main worker's position.
- A scheduled job that reacts to calendar events. Subscribe to
calendar.event.starting, and the job picks up the meetings that started since its last run. It acts up to one interval late, so anything that has to happen on the minute belongs on a push webhook or a WebSocket.
How do you set up a polling webhook?
Install the AgentMail SDK and the Svix SDK. You need both: AgentMail creates the subscription, and Svix reads the queue.
Bash
pip install agentmail svix# ornpm install agentmail svixCreate the polling webhook. client_id is required.
Python
from agentmail import AgentMailclient = AgentMail()subscription = client.webhooks.polling.create( client_id="support-agent", event_types=["message.received"], inbox_ids=["support@yourdomain.com"],)token = subscription.tokenTypeScript
import { AgentMailClient } from "agentmail";const client = new AgentMailClient();const subscription = await client.webhooks.polling.create({ clientId: "support-agent", eventTypes: ["message.received"], inboxIds: ["support@yourdomain.com"],});const token = subscription.token;Leave out inbox_ids to cover the whole organization. To scope it to one pod or inbox, create it through client.pods.webhooks.polling or client.inboxes.webhooks.polling instead.
The token only comes back from create. get and list never return it, so store it like an API key. If you lose it, call create again with the same client_id. You get the same subscription with a new token, and the old token stops working right away. That's also why each running worker should have its own client_id.
Then read events with the Svix consumer. Pass it an empty configuration, because AgentMail already set up the subscription.
Python
import timefrom svix import AutoConfigConsumerfrom svix.models import SinkInCommonconsumer = AutoConfigConsumer(token, SinkInCommon())while True: page = consumer.receive("worker-1") for event in page.data: if event.event_type == "message.received": handle(event.payload["message"]) consumer.commit("worker-1", event.offset) if page.done: time.sleep(5)TypeScript
import { AutoConfigConsumer } from "svix";const consumer = new AutoConfigConsumer(token, {});while (true) { const page = await consumer.receive("worker-1", { limit: 50 }); for (const event of page.data) { if (event.eventType === "message.received") { handle(event.payload.message); } await consumer.commit("worker-1", event.offset); } if (page.done) await new Promise((r) => setTimeout(r, 5000));}event.payload is the same body a push webhook would have sent, so you can reuse the handlers you wrote for push webhooks. Don't call subscribe() on the consumer. It replaces the event types and inboxes AgentMail configured.
An agent that runs on a schedule doesn't need the loop. It calls receive until page.done is true, commits what it handled, and exits.
What happens when the agent goes offline?
Events queue. When the agent starts again, receive returns the backlog until page.done is true.
commit acknowledges an event and everything before it. Anything you haven't committed comes back on the next receive, so if the agent crashes between handling an event and committing it, the event is still there next time. Delivery is at least once, which means a handler can see the same event twice. Make handlers idempotent.
The "worker-1" in the code is a consumer ID, and each consumer ID keeps its own position in the queue. Two consumer IDs on one token each see every event, which is how you add a second worker that needs the full stream, or replay events without a second subscription. Don't run two processes on the same consumer ID at the same time.
Queued events are kept for 90 days. The queue only holds events from after the polling webhook was created, so create it before the agent needs it.
Which delivery method should you use?
If your agent runs intermittently, or anywhere that can't take an inbound request, use a polling webhook. That covers most agents in sandboxes, serverless functions and cron jobs.
WebSockets are the better choice when you need an event within a second and can keep a process connected. Push webhooks still make sense if you already run a server with a public URL.
All three carry the same event types and the same payloads. Switching later changes how events reach the agent, and the handlers stay the same.
What are the limits?
- Polling webhooks are available in the US region only for now. They aren't in the EU yet.
- Each organization, pod and inbox can have up to 10 polling webhooks. Creating an eleventh returns a 403. Calling
createagain with an existingclient_iddoesn't count toward the limit. - One polling webhook can cover up to 10 pods and inboxes.
- A token can only read the event types the API key could read when the token was created. If the key loses a permission, delete the polling webhook and create it again.
message.received.spam,message.received.blockedandmessage.received.unauthenticatedneed the matching permission on the API key, the same as for push webhooks.- Events arrive on the agent's next fetch, so latency is higher than with push webhooks or WebSockets.
Hand this to your coding agent
Paste this into Claude Code, Codex or Cursor to set up a polling webhook in your project.
Code
Set up an AgentMail polling webhook so this agent can receive email events without a public URL.Read https://docs.agentmail.to/webhook-polling before writing any code.1. Install the AgentMail and Svix SDKs (pip install agentmail svix, or npm install agentmail svix).2. Create the polling webhook with client.webhooks.polling.create. Use client_id "<AGENT_NAME>", event_types ["message.received"] and inbox_ids ["<INBOX_ADDRESS>"].3. Store the returned token as a secret, never in code. It is only returned once.4. Read events with the Svix SDK's AutoConfigConsumer, passing the token and an empty config. Call receive with consumer ID "worker-1", handle each event, then commit its offset. Do not call subscribe().5. Make the handler idempotent, because delivery is at least once.6. If this agent runs on a schedule, drain the queue until page.done is true, then exit.


