DevInbox

Long-Polling vs Webhooks vs Polling: Getting Email to Your AI Agent

Your agent just filled out a signup form. Somewhere between now and a few seconds from now a verification email will land in the inbox you provisioned for it, and the agent cannot take another step until it reads the code out of that email. How the agent waits for that message — the delivery model you choose — decides whether the step resolves in 800 milliseconds or 40 seconds, whether it survives a process restart, and how much of your API budget it quietly wastes.

Engineering July 15, 2026 11 min read Ado, Engineering Team

Three ways to move an email into an agent

There are only three general strategies for getting a received email from a mailbox into code that is waiting for it: poll for it, have it pushed to you over a webhook, or hold a single long-poll request open until it arrives. None of these is new — they predate agents by decades. What is new is how sharply agents expose the trade-offs, because agent steps tend to be short-lived, are frequently ephemeral, and often run with no public network identity at all.

This guide is written by the team behind DevInbox, and the code samples target the DevInbox API. The three delivery models themselves, though, are general patterns you will meet in any agent-email or email-testing stack, and the analysis below applies wherever you receive mail programmatically.

We will judge each model on four axes that actually matter in production: latency (how long after the email lands before your code sees it), operational complexity (what infrastructure the agent needs to stand up), cost (how many API calls and how much rate-limit budget it consumes), and failure modes (what goes wrong, and how loudly). Every code sample runs against the real DevInbox REST API — the same endpoints the MCP server wraps for agents.

One honest caveat up front

DevInbox receives, extracts, and forwards email — it does not send it. Outbound send is on the roadmap, not shipping today. Throughout this article the agent submits form data through its own browser or HTTP client; DevInbox only handles the email that comes back. Keep that boundary in mind whenever you read "the agent signs up."

1. Polling: simple, slow, and quietly expensive

Polling is the model everyone writes first because it needs nothing you do not already have. You ask the mailbox "anything yet?" on a fixed cadence and keep asking until the answer changes. Against DevInbox that is a loop over GET /messages/{key}/count or GET /messages/{key}/last with a sleep between iterations.

agent --> DevInbox  GET /messages/{key}/count
agent <-- DevInbox  { "count": 0 }   (sleep 3s)
agent --> DevInbox  GET /messages/{key}/count
agent <-- DevInbox  { "count": 0 }   (sleep 3s)
... repeat N times, burning one call each ...
agent <-- DevInbox  { "count": 1 }   finally

The latency problem is structural. On average you discover the email half a poll-interval after it arrives, and in the worst case a full interval late. Tighten the interval to shrink that gap and you multiply the number of calls; loosen it to save calls and you add latency. There is no setting that is both fast and cheap — the two are in direct tension.

# Naive polling: hammer the mailbox until an email shows up.
import time, requests

API_KEY = "your_api_key"
BASE    = "https://api.devinbox.io"
headers = { "X-Api-Key": API_KEY }
key     = "a1b2c3d4e5f6"   # inbox key from POST /mailboxes

# Every trip through this loop is a separate billable API call.
while True:
    r = requests.get(f"{BASE}/messages/{key}/count", headers=headers)
    if r.json()["count"] > 0:
        break
    time.sleep(3)   # 3s cadence: up to 20 calls/min, most of them wasted

msg = requests.get(f"{BASE}/messages/{key}/last", headers=headers).json()
print(msg["subject"])

Be precise about what polling actually costs. It does not burn extra received-email credit — an inbound message is counted once when it lands, no matter how many times you check for it. What it burns is your API call budget and rate limit. Hammer the endpoint hard enough and DevInbox answers with 429 Too Many Requests, at which point your "fast" poll loop is now backing off and is slower than if you had never tightened it. Multiply that by a fleet of agents each running their own tight loop and the wasted-call count stops being a rounding error.

The failure modes are mild but real: a crash mid-loop loses your place (though on restart you can simply read /last, since the mailbox is durable), and a loop with no deadline can spin forever if the email never comes. Polling is not wrong — for a one-off script that checks an inbox occasionally, it is perfectly reasonable. It just scales badly precisely where agents live: many short, latency-sensitive waits.

2. Webhooks: built for always-on services

Webhooks invert the direction. Instead of your code asking, DevInbox tells: the moment a message is received it fires an HTTP POST to a URL you registered, carrying the message's metadata — and, if you attach a template, its extracted fields — in the request body. You configure the endpoint once under Webhooks in the dashboard, and from then on delivery is push, not pull.

(email arrives at inbox)
DevInbox --> your server  POST /devinbox/hook  { webHook, message: { subject, from, ... } }
your server --> DevInbox  200 OK
your server --> agent  (wake the waiting job)

For latency this is the gold standard: there is no interval to wait out, so your code learns about the email as fast as the network round-trip allows. For cost it is equally clean — one delivery per email, zero wasted calls. If you are running an always-on backend that processes inbound mail, webhooks are almost always the right answer.

# A public endpoint DevInbox calls the instant an email lands.
# Register its URL under Webhooks in the DevInbox dashboard.
from flask import Flask, request

app = Flask(__name__)

@app.route("/devinbox/hook", methods=["POST"])
def on_email():
    payload = request.get_json()
    # The payload is nested, not a bare message object:
    #   payload["webHook"]  -> { id, name, url, template }
    #   payload["message"]  -> { id, subject, from, to, cc, bcc, received, mailboxId }
    # The raw body is NOT included; attach a template to the hook and
    # payload["bodyParameters"] / ["subjectParameters"] carry the parsed vars.
    msg = payload["message"]
    print(msg["from"], msg["subject"])
    enqueue_for_agent(payload)   # hand off to whatever is waiting
    return "", 200

The catch is right there in the first line of the sample: webhooks need a public endpoint that is always listening. That is a natural fit for a service and an awkward one for an agent. An agent running inside a CI job, a serverless function, a laptop behind NAT, or a browser-automation worker that exists for ninety seconds has nowhere to receive an inbound POST. You end up bolting on a tunnel, a queue, or a relay service just so a short-lived process can hear back — infrastructure whose entire job is to bridge the gap between "push delivery" and "my agent has no address."

Webhooks also carry the failure modes of any push system, and they are the least mild of the three. Deliveries can be missed if your endpoint is down, so you need retries and idempotency. Anyone who learns the URL can forge a delivery, so you need to authenticate the caller. And a webhook decouples receipt from the code that cares about it, which means you are now also running the plumbing that reunites them. For a durable service that is a fair price. For an ephemeral agent step it is a lot of moving parts to answer a single question: did the email arrive yet?

3. Long-poll wait: one blocking call per agent step

Long-polling keeps the pull direction of polling but collapses the loop into a single request. You call GET /messages/{key}/wait and the server holds the connection open, watching the mailbox on your behalf, and answers the instant a matching email arrives. If nothing shows up before the timeout, it returns 204 No Content and you decide whether to call again. One request in, one answer out — no interval, no wasted calls, no endpoint to host.

agent --> DevInbox  GET /messages/{key}/wait?timeout=90
              (connection held open; server watches the inbox)
(email arrives ~4s later)
agent <-- DevInbox  200 OK  { subject, body, ... }  returned immediately

The endpoint takes three query parameters, and each one earns its place. timeout is the number of seconds to hold the connection — it defaults to 60 and is capped at 120. template names an extraction template to parse the message with before returning it, so the OTP or magic link comes back as structured data instead of raw text. since is a timestamp; only messages received strictly after it are eligible, and it defaults to the moment you made the call.

The single-line version is the one your shell script wants:

# One request. Blocks until an email lands, or 204 after the timeout.
curl -H "X-Api-Key: your_api_key" \
  "https://api.devinbox.io/messages/a1b2c3d4e5f6/wait?timeout=90"

Add a template and the response arrives pre-parsed. When you pass template, the subject and body fields come back as key/value objects of the variables your template captured — no second call, no client-side regex, no LLM in the loop:

import requests

BASE    = "https://api.devinbox.io"
headers = { "X-Api-Key": "your_api_key" }
key     = "a1b2c3d4e5f6"

# Block up to 90s for the next email, parsed with the "saas-otp" template.
r = requests.get(
    f"{BASE}/messages/{key}/wait",
    params={ "timeout": 90, "template": "saas-otp" },
    headers=headers)

if r.status_code == 200:
    # with a template, body is a dict of captured variables
    code = r.json()["body"]["code"]   # -> "482913"
    print("OTP:", code)
elif r.status_code == 204:
    print("No matching email arrived within 90s")

For an agent, this is the whole point. The MCP server exposes the same operation as a single tool, so the model simply calls wait_for_email and blocks — it does not have to reason about intervals, hold open a listener, or manage any state between the request and the answer:

# The same operation, expressed as tool calls the agent makes itself.
create_inbox()                                  # agent-3f9c@devinbox.io
# ... the agent's browser submits the signup form with that address ...
wait_for_email(inbox_key, timeout_s=90)         # blocks, then returns the message
get_last_email(inbox_key, template="saas-otp")  # { "code": "482913" }

Long-poll is not magic — it inherits one honest limit. A single request cannot outlive its timeout, and the ceiling is 120 seconds. If you need to wait longer than that, you re-issue the call, which brings us to the failure modes that every serious implementation has to handle.

The three models, side by side

None of these is universally best. The right choice falls out of one question: is the thing waiting for the email a durable service or a short-lived step?

Delivery model Latency to delivery Infra the agent needs Cost profile Best fit
Polling Up to one poll interval late None — just API calls High: one billable call per poll; risks 429 Quick scripts, occasional low-frequency checks
Webhooks Near-instant (push on receipt) A public, always-on HTTPS endpoint Low: one delivery per email Always-on services, backends, queue workers
Long-poll wait Near-instant (returns when mail lands) None — one outbound request Low: one call per wait window (≤120s) Agent steps, CI jobs, ephemeral workers

Failure modes worth designing for

The interesting engineering is in what happens when things do not go to plan. Three edge cases catch almost everyone, and all three are easy once you have seen them.

The email that arrived before you waited. By default wait only returns messages received strictly after the moment you called it — the since parameter defaults to call time. If your service already delivered the email in the window between creating the inbox and issuing the wait, a naive wait will sit there until it times out on a message that is already sitting in the mailbox. The fix is ordering: create the inbox, then trigger the action that sends mail, then wait. If you cannot guarantee that order, check /last or /count first, or pass an explicit earlier since so a message that just landed is still eligible.

Waiting longer than 120 seconds. A single request is capped, but you are not — chain the calls. After each wait, advance since to the received timestamp of the last message you saw, and issue the next wait. A loop of 120-second waits is still radically cheaper than tight polling: you make one call every two minutes instead of one every few seconds, and you still get the email the instant it arrives inside the current window. A 204 is not an error — it just means "nothing yet," and re-issuing is the correct response.

Duplicates and idempotency. Webhooks retry on failure, and a wait loop with an off-by-one since can hand you the same message twice. Every delivered message carries a stable id — uniqueId on the REST and wait responses, the numeric message.id in a webhook payload — so dedupe on whichever your path hands you and both problems disappear. This matters most if you run webhooks and long-poll together — a legitimate belt-and-suspenders setup where the webhook feeds a durable queue and an agent long-polls the same inbox for its own step. The two are independent delivery paths to the same mailbox, so treat every message as possibly-seen and let its stable id be the arbiter.

Why long-poll beats a tight loop even on cost

A three-second poll makes roughly twenty calls a minute whether or not the email ever shows up. A long-poll makes one call and then goes quiet until either the email lands or the window closes. For the common "wait for the verification email" step, that is the difference between dozens of requests and one — with lower latency, not higher.

So which one should your agent use?

Let the shape of the waiter decide, not fashion. If a durable, always-on service is consuming inbound mail — a backend that files receipts, a pipeline that ingests replies, anything with a stable address and an operations team — webhooks win. Push delivery is the lowest-latency, lowest-waste option, and a real service can absorb the retries, authentication, and idempotency that push demands. Do not reach for long-poll just because it is newer; for a service, a webhook is the honest best answer.

If the waiter is an agent step — a browser worker, a CI stage, a serverless function, anything that exists to complete one flow and then disappears — long-poll wins, and it is not close. The agent has no public address to receive a webhook, and it cannot afford the latency or the wasted calls of tight polling. A single blocking wait call maps exactly onto how the step already thinks: do the thing, wait for the email, read the code, continue. That is why the DevInbox MCP server hands agents wait_for_email as a first-class tool.

Polling remains the right tool for exactly one situation: a quick, low-frequency check where standing up anything fancier is not worth it — a cron job that glances at an inbox once a minute, a throwaway script. Outside that narrow band, it is almost always the wrong default: slower than webhooks, more wasteful than long-poll, and the model most likely to trip a rate limit. Most real systems end up using two of the three — webhooks for the durable path, long-poll for the interactive steps — and reserving plain polling for the odd script that does not deserve better.

Frequently asked questions

How long can a single long-poll request stay open?

Up to 120 seconds; the default is 60. Set it with the timeout query parameter on GET /messages/{key}/wait. To wait longer, re-issue the call and advance since to the timestamp of the last message you saw, so you never miss or double-count one.

What happens if no email arrives before the timeout?

The endpoint returns 204 No Content. That is not an error — it means "nothing yet." Call again to keep waiting, optionally passing since so a message that lands between calls is still eligible.

Does wait return emails that arrived before I called it?

No. By default only messages received strictly after the call are returned, because since defaults to call time. Create the inbox, trigger the action that sends mail, then wait — or pass an earlier since to include a message that just landed.

Can an agent behind a firewall or in CI use webhooks?

Not easily. Webhooks need a public endpoint that is always listening, which an ephemeral or NAT-bound worker does not have. Long-poll only makes an outbound request, so it works from anywhere with internet access — no tunnel, no relay, no inbound port.

Can I use webhooks and long-poll on the same inbox?

Yes. They are independent delivery paths to the same mailbox — a common setup is a webhook feeding a durable queue while an agent long-polls the inbox for its own step. Dedupe on a stable message id — uniqueId on the wait response, message.id in the webhook payload — so the same email is only acted on once.

Why not just poll every second? It is simpler.

Because it is both wasteful and slower than it looks. A one-second poll makes up to sixty calls a minute, risks a 429 that forces you to back off, and still, on average, discovers the email half an interval late. A single long-poll makes one call and returns the instant the message arrives — simpler in the ways that count.

Give your agent an inbox it can wait on

Provision a persistent or throwaway inbox, block on the verification email with a single call, and extract the code as structured data. The free tier includes 3 persistent inboxes, unlimited temporary ones, and 1,000 emails a month — no credit card required.