This guide covers the three things most Node.js and TypeScript code needs from Lumbox: create an inbox, wait for a verification code or a specific email, and send or reply. Every example runs on Node 18 or later with the built-in fetch, so there is nothing to install for the HTTP path. The lumbox npm package is covered further down for the receiving side.
You need an API key. Create one in the dashboard, or run npx lumbox signup, which creates an org and prints a key once. Put it in LUMBOX_API_KEY. The REST base URL is https://api.lumbox.co/v1, and the key goes in an X-API-Key header (Authorization: Bearer ak_... also works).
A small fetch helper
Every call below goes through one function that adds the key, encodes JSON, and turns a non-2xx response into an error that keeps the status code. The status matters more than the message, because several Lumbox responses are expected outcomes rather than failures.
// lumbox.ts
const BASE = "https://api.lumbox.co/v1";
export class LumboxHttpError extends Error {
status: number;
body: any;
constructor(status: number, body: any) {
super(body?.error ?? `HTTP ${status}`);
this.status = status;
this.body = body;
}
}
type Opts = {
method?: string;
body?: unknown;
headers?: Record<string, string>;
signal?: AbortSignal;
};
export async function lumbox<T>(path: string, opts: Opts = {}): Promise<T> {
const res = await fetch(BASE + path, {
method: opts.method ?? "GET",
headers: {
"X-API-Key": process.env.LUMBOX_API_KEY!,
...(opts.body !== undefined ? { "Content-Type": "application/json" } : {}),
...opts.headers,
},
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
signal: opts.signal,
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new LumboxHttpError(res.status, data);
return data as T;
}
Create an inbox
type Inbox = { id: string; address: string; name: string | null; status: string; created_at: string };
const inbox = await lumbox<Inbox>("/inboxes", { method: "POST", body: {} });
console.log(inbox.id, inbox.address); // inb_..., agent_x7k2m9p4qa@trylumbox.com
With an empty body you get a generated address on trylumbox.com. Passing name turns it into the local part (lowercased, anything outside a-z0-9- becomes a hyphen), and addresses on the shared domain are global, so { name: "signup-bot" } can return 409 because another account already holds it. Generated addresses avoid that, and you can keep a readable label in metadata instead.
Two other responses show up here in practice. A 402 with code: "PLAN_LIMIT_EXCEEDED" means you are at your plan's inbox count (3 on Free), so delete finished inboxes rather than leaving them around. And the first inbox an org creates gets one welcome email from hello@lumbox.co, which you will see if you list that inbox's emails right away; later inboxes start empty.
Wait for a verification code
GET /v1/inboxes/:id/otp is a long-poll. The server holds the request, checks for new mail every second, and returns as soon as a verification email with a code arrives, or answers 408 when timeout (1 to 120 seconds, default 30) runs out. The 408 is the normal "nothing yet" answer, so the helper below maps it to null.
type Otp = {
code: string;
all_codes: string[];
from: string;
subject: string | null;
expires: string | null;
email_id: string;
};
export async function waitForOtp(inboxId: string, since: string, from?: string) {
const qs = new URLSearchParams({ timeout: "90", since });
if (from) qs.set("from", from);
try {
return await lumbox<Otp>(`/inboxes/${inboxId}/otp?${qs}`, {
signal: AbortSignal.timeout(100_000),
});
} catch (err) {
if (err instanceof LumboxHttpError && err.status === 408) return null;
throw err;
}
}
const since = new Date(Date.now() - 5_000).toISOString();
await submitSignupForm(inbox.address); // your browser or HTTP step
const otp = await waitForOtp(inbox.id, since, "github.com");
The since argument is the detail that matters. Without it, the endpoint looks back five minutes, which means a code from a previous attempt in the same inbox can come back as if it were new. Capturing the time just before you submit the form (minus a few seconds for clock skew) scopes the wait to mail caused by this attempt. The endpoint only considers emails the parser categorised as verification, returns the newest one, and does not mark it read, so calling it twice returns the same code.
Codes are found by pattern matching on the subject and plain-text body, looking for 4 to 8 digit numbers near words like "code" or "verification". If a service sends only a link, /otp will time out; wait for the whole email instead and read parsed.verification_links.
Wait for any email
GET /v1/inboxes/:id/wait works the same way but returns the full email object, filtered by from, subject, category, or has_otp=true. Its since defaults to the moment the request starts, which creates the opposite race: if the email lands between your form submit and the wait call, the wait never sees it. Pass the same pre-submit timestamp here too.
const qs = new URLSearchParams({ from: "stripe.com", timeout: "120", since });
const email = await lumbox<any>(`/inboxes/${inbox.id}/wait?${qs}`, {
signal: AbortSignal.timeout(130_000),
});
console.log(email.subject, email.parsed.verification_links);
text_body comes wrapped in a fence of = characters with an untrusted-content label, so a model reading it can tell data from instructions. The parsed fields are not fenced.
The SDK for the receiving side
npm install lumbox gives you a typed client (version 0.3.1 at the time of writing). It reads LUMBOX_API_KEY when no key is passed.
import { Lumbox } from "lumbox";
const lumbox = new Lumbox({ maxRetries: 0 });
const inbox = await lumbox.createInbox();
try {
const otp = await inbox.waitForOtp({ from: "github.com", timeout: 90 });
console.log(otp.code);
} catch (err) {
if (err instanceof Lumbox.ApiError && err.status === 408) {
// no code arrived in time
} else throw err;
}
Two SDK behaviours are worth knowing. It retries 408, 409, 429, 5xx and network errors twice by default, and a 408 from a long-poll counts, so a 90-second wait can block for close to five minutes before it throws; maxRetries: 0 gives you the timeout you asked for. Its waitForOtp does not take a since, so for retry loops the fetch version above is the safer choice.
Send and reply with fetch
This post sends with plain fetch, which hands back the API's response exactly as documented. Add an Idempotency-Key header to every send so that a retry after a network error returns the first response instead of sending the message twice.
type Sent = { ok: boolean; email_id: string; thread_id: string; message_id: string };
const sent = await lumbox<Sent>(`/inboxes/${inbox.id}/send`, {
method: "POST",
headers: { "Idempotency-Key": `report-ready-${reportId}` },
body: { to: "ops@example.com", subject: "Report ready", text: "The weekly report is attached to the dashboard." },
});
await lumbox<Sent>(`/inboxes/${inbox.id}/reply`, {
method: "POST",
headers: { "Idempotency-Key": `reply-${email.id}` },
body: { email_id: email.id, text: "Thanks, received." },
});
The send body takes to (string or array), subject, text and/or html, and cc; there is no bcc, reply_to, or attachment field. dry_run: true returns what would be sent without sending. Reply sets In-Reply-To and References from the original, so the recipient's client threads it. The Idempotency-Key header is kept for 24 hours: repeating a request with the same key and body returns the stored response instead of sending again.
Errors worth handling
| Status | Where | Meaning |
|---|---|---|
| 401 | any | Missing or wrong key |
| 402 | create, send | PLAN_LIMIT_EXCEEDED: inbox count or monthly sends |
| 408 | /otp, /wait | Nothing matched before the timeout |
| 409 | create, idempotent calls | Address taken, or an idempotency key reused with a different body |
| 429 | any, send | Rate limited; the JSON body has retry_after_seconds |
General requests are limited to 120 per minute per key. Sends have their own per-minute limit by plan (3 on Free), and a new Free account without a verified domain can send 5 emails in its first hour and 25 in its first day. There is no Retry-After header on a 429, so read retry_after_seconds from the body. The rate limits post goes through these in more detail.
Clean up
DELETE /v1/inboxes/:id removes the inbox and its emails. Inboxes never expire on their own, so a script that creates one per run should delete it in a finally block.
The SDK quickstart and the sending docs list every field, and long-polling for agents explains why the wait endpoints hold the connection instead of making you poll.