An agent that needs to know when an email arrives has three ways to find out: ask repeatedly, ask once and let the server hold the answer until there is one, or register a URL and be told. On Lumbox those are GET /v1/inboxes/:id/emails in a loop, the long-poll endpoints /wait and /otp, and POST /v1/webhooks. There is also a Server-Sent Events stream that sits between the last two.
The choice comes down to three questions: who is waiting, how many inboxes are involved, and what should happen when your side is down.
One fact that frames the decision
Every inbound message is parsed and written to the database before any notification goes out. Polling, long-poll and the stream all read from that stored copy, so a busy or restarting process loses nothing: a message that arrived meanwhile is still there on the next read. A webhook is a notification about the stored copy, and in production each one is attempted once with a 10-second timeout and no retries. If your endpoint misses it, the email is still in the inbox, but nothing tells you about it again.
Polling
const API = "https://api.lumbox.co/v1";
const headers = { "X-API-Key": process.env.LUMBOX_API_KEY };
async function pollForNew(inboxId, since, seen) {
for (;;) {
const url = `${API}/inboxes/${inboxId}/emails?since=${encodeURIComponent(since)}&limit=100`;
const { data } = await (await fetch(url, { headers })).json();
const fresh = data.filter((e) => !seen.has(e.id));
if (fresh.length) return fresh;
await new Promise((r) => setTimeout(r, 5000));
}
}
Polling needs nothing beyond an API key and works anywhere. It costs requests and delay. The general limit is 120 requests per minute per API key, and a 5-second loop spends 12 of those every minute whether mail arrives or not. Ten agents polling on one key use all 120, and the next inbox creation or send from that key gets a 429. On average a message waits half an interval, 2.5 seconds here, before the loop notices it.
Polling still fits batch work: a job that runs every hour and processes whatever arrived since the last run. The since filter is inclusive, so keep a set of processed ids and skip anything already in it.
Long-poll
const since = new Date().toISOString();
await submitSignupForm(inboxAddress);
const url = `${API}/inboxes/${inboxId}/wait?timeout=60&from=github.com&since=${encodeURIComponent(since)}`;
const res = await fetch(url, { headers });
if (res.status === 408) throw new Error("no email from github.com within 60 seconds");
const email = await res.json();
The server holds the request open, checks the inbox once a second, and answers as soon as a matching email exists, or with a 408 when the timeout runs out. The timeout can be 1 to 120 seconds and defaults to 30. One request covers the whole wait, and the answer arrives about a second after the message is stored.
Capture since before triggering the email. /wait otherwise only looks at messages that arrive after the call starts, and a fast sender can beat a slow call. The long-poll guide covers that race and the other edges in detail.
Server-Sent Events
GET /v1/inboxes/:id/stream keeps one connection open for up to 900 seconds (300 by default) and emits an email event for every new message, oldest first. It checks for new mail every 2 seconds, sends a heartbeat event on each check, and closes with a done event, after which you reconnect with since set to the last received_at you handled. It suits one long-running consumer that wants every message from one inbox without exposing a URL.
Webhooks
curl -X POST https://api.lumbox.co/v1/webhooks \
-H "X-API-Key: $LUMBOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://agents.example.com/hooks/lumbox", "events": ["email.received"]}'
A webhook costs no requests while you wait and covers every inbox in the organisation with one registration. It needs a public HTTPS endpoint, code to verify the X-Lumbox-Signature HMAC, and deduplication on email_id. The payload is a summary (sender, subject, category, OTP codes, spam flags), so workflows that need the body make one more call. Because of the no-retry behaviour above, a production webhook consumer also runs a periodic sweep over recent email to catch anything it missed. The webhook workflow guide builds that receiver and sweep.
Side by side
| Polling | Long-poll | SSE stream | Webhook | |
|---|---|---|---|---|
| Public URL needed | No | No | No | Yes, HTTPS |
| Requests while idle | One per interval | One per timeout, up to 120 s | One per stream, up to 900 s | None |
| Delay after the email is stored | Up to one interval | About 1 s | About 2 s | One outbound HTTP call |
| If your side is down | Next poll finds it | Next call with since finds it | Reconnect with since | Notification lost, sweep finds it |
| Inboxes per connection | One | One | One | All in the org |
Which to use
An agent step inside a task. Sign up, wait for the code, carry on. Use /otp or /wait, since the agent has nothing else to do until the email arrives and blocking is the natural shape.
Test suites. Each test creates its own inbox and long-polls it. No shared endpoint, no cross-test interference, and a 408 turns directly into a test failure with a clear message.
A bot watching a few inboxes from a laptop or behind a firewall. A long-poll loop or the stream. Neither needs inbound connectivity, and both pick up where they left off after a restart if you store the last received_at.
A product with many inboxes. One webhook for the org, a queue behind it, and a sweep every minute or two. Holding one long-poll per inbox for hundreds of inboxes also works, but each held request counts against the 120-per-minute key limit every time it returns, and the process spends its life managing connections.
Mixing is normal. The same agent can long-poll during a signup flow and rely on the webhook for mail that arrives later with nobody waiting. The rate limits post has the numbers for sizing either approach, and the webhooks docs have the registration fields.