Most code that waits for an email looks the same: list the inbox, look for the message, sleep a few seconds, repeat. It works, and it has costs that are easy to ignore until there are fifty agents doing it. Every check is a request, the average message waits half an interval before anyone notices, and the OTP regex becomes yours to maintain.
Long-polling moves the loop to the server. You make one request, the server holds it open until a matching email exists, and the response is the email. This post covers what Lumbox's long-poll endpoints actually do, and the four ways a long-poll can still go wrong.
What the server does while you wait
GET /v1/inboxes/:id/wait checks the inbox once a second. It looks for the newest email with received_at at or after since that matches your filters: from (a substring of the sender address), subject (a substring), category, and has_otp=true to require an extracted code. When one exists it returns the full email object, the same shape the list endpoint returns, and marks it read. When timeout runs out (1 to 120 seconds, default 30) it returns HTTP 408 with {"error": "Timeout: no matching email received", "timeout": 60}.
GET /v1/inboxes/:id/otp is the shortcut for codes. It only considers emails categorised verification, its since defaults to five minutes ago rather than now, and it returns just the code and its context:
{
"code": "482913",
"all_codes": ["482913"],
"from": "noreply@github.com",
"subject": "Your GitHub launch code",
"expires": "2026-09-29T10:24:03.512Z",
"email_id": "eml_4kQ9xZr2LmT7vB1nW8yH"
}
A timeout on /otp is also a 408, with {"error": "No OTP found", "timeout": 60}.
Before and after
// Before: 12 requests a minute, up to 5 s late, regex is yours
let code = null;
while (!code) {
await sleep(5000);
const { data } = await listEmails(inboxId);
code = data.map((m) => m.text_body?.match(/\b\d{6}\b/)?.[0]).find(Boolean) ?? null;
}
// After: one request, answered about a second after the email is stored
import { Lumbox } from "lumbox";
const lumbox = new Lumbox({ apiKey: process.env.LUMBOX_API_KEY });
const inbox = await lumbox.createInbox({ name: `signup-${Date.now()}` }); // signup-...@trylumbox.com
// ...submit inbox.address to the signup form...
const { code: otp } = await inbox.waitForOtp({ from: "github.com", timeout: 60 });
Four ways a long-poll still goes wrong
1. The email arrives before you start waiting
/wait defaults since to the moment the request starts. If your code submits a form, does some other work, and only then calls /wait, a fast sender can deliver in between and the call waits for a second email that never comes. Record the time before triggering the email and pass it explicitly:
const since = new Date().toISOString();
await submitSignupForm(inbox.address);
const res = await fetch(
`https://api.lumbox.co/v1/inboxes/${inbox.id}/wait?timeout=60&from=github.com&since=${encodeURIComponent(since)}`,
{ headers: { "X-API-Key": process.env.LUMBOX_API_KEY } },
);
The TypeScript SDK's waitForEmail has no since option, so this call is plain HTTP. /otp has the opposite problem: its five-minute lookback absorbs the race, but on a retry it can hand back the code from the previous attempt. Pass since there too, or compare email_id with the one you already used.
2. Your HTTP client gives up first
A 60-second long-poll needs a client that will wait longer than 60 seconds. Many HTTP clients default to far less (httpx in Python defaults to 5 seconds), and serverless platforms and proxies impose their own ceilings. Set the client timeout to the server timeout plus a margin.
The TypeScript SDK does this for you, setting its client timeout to the wait timeout plus 30 seconds. It also treats a 408 as retryable and retries twice by default, so waitForOtp({ timeout: 60 }) can take around three minutes before it throws. If the timeout has to be a hard deadline, construct the client with maxRetries: 0.
3. The code is not where the endpoint looks
Codes are extracted on arrival by pattern matching, not by a model. The production parser finds 4 to 8 digit codes next to words like "code" or "verification", or alone on a line, in the subject and plain-text body. It does not extract alphanumeric codes such as K7P-29Q, digits split by a space, or a code that appears only in the HTML part of the message.
Category matters as well. /otp only reads emails whose category is verification, and category comes from weighted keyword rules. A "Your sign-in code" email that mentions a new device can score higher as security, in which case /otp times out even though otp_codes holds the code. /wait?has_otp=true requires an extracted code in any category, and a plain /wait with from set lets you read text_body yourself when no code was extracted.
4. Two emails, one answer
/wait returns the newest matching email. If a service sends a welcome message and a verification message within the same second, a call filtered only by from returns whichever landed last. Filter by subject as well, or treat the wait as a wake-up and then list the inbox with the same since to see everything that arrived.
What it costs
A long-poll is one request against the 120-per-minute limit on your API key, however long the server holds it; the once-a-second checks happen inside the server. Fifty agents each waiting up to 60 seconds make at most 50 requests a minute. The same fifty agents polling every 5 seconds would make 600, five times the limit. On your side, each wait occupies one open HTTP connection for up to the timeout.
Handling the 408
A timeout usually means the email was never sent, went to a different address, or was caught by the sender's own throttling. Waiting longer rarely helps, and re-triggering usually does:
async function getCode(inbox, trigger, attempts = 2) {
for (let i = 0; i < attempts; i++) {
const since = new Date().toISOString();
await trigger();
const res = await waitForOtp(inbox.id, { since, from: "github.com", timeout: 90 });
if (res.status === 200) return (await res.json()).code;
if (res.status !== 408) throw new Error(`otp wait failed: ${res.status}`);
}
throw new Error("no code after resending twice");
}
Here waitForOtp is a plain fetch to /otp with those query parameters, and trigger clicks the site's "resend code" button or resubmits the form. The OTP verification use case shows the full signup flow, and the OTP extraction post covers what to do with codes the parser does not catch. Query parameters for both endpoints are in the emails docs.