A single registration script is easy to get working once. The hard part starts when it runs unattended: the browser crashes after the form was submitted, the verification email takes three minutes, a retry creates a second inbox, or a worker dies holding an inbox nobody will ever delete. This guide treats account registration as a pipeline of seven steps with a record that survives each one, so any step can fail and be resumed or cleaned up.
For the straight-line version of the code, one registration from start to finish, see automating the full signup flow. This post is about running it repeatedly without leaving a mess.
Scope: which registrations belong in a pipeline
The legitimate reasons to run registration as a pipeline are mostly about your own product: signup smoke tests against staging and production, onboarding checks across locales and plans, seeding demo tenants. A second group is provisioning a small number of accounts for your own agents on services whose terms allow accounts created by software. Creating accounts in bulk on someone else's service, to stack free trials or spread load across identities, is outside both groups, and this guide does not help with it. Lumbox throttles new-org signups, blocks brand-impersonation names on shared domains, and suspends organisations for abuse.
The seven steps and what each leaves behind
| Step | Persist after it succeeds | Typical failure |
|---|---|---|
| 1. Create inbox | inbox_id, address, created_at | 402 plan limit, network error mid-request |
| 2. Open signup page | nothing | page moved, bot wall |
| 3. Fill form | the generated password, before submitting | field labels changed |
| 4. Submit | state submitted | captcha, validation error on the address |
| 5. Wait for email | email_id | 408 after the timeout, email in another category |
| 6. Verify | state verified | code expired, link already used |
| 7. Store session | storage state path | disk or secret store unavailable |
Two entries in that table do most of the work. The password is persisted before step 4, because after a crash the only way back into a half-verified account is to log in with it. The inbox fields are persisted immediately after step 1, because an inbox you have no record of is an inbox you will never delete.
The record and the create call
type State = "new" | "inbox_ready" | "submitted" | "verified" | "stored" | "failed";
interface Registration {
key: string; // your own id; also used as the Idempotency-Key
site: string; // sender domain to filter on
signupUrl: string;
state: State;
inboxId?: string;
address?: string;
since?: string; // inbox created_at, used as the /wait lower bound
attempts: number;
error?: string;
}
const API = "https://api.lumbox.co/v1";
async function lumbox(path: string, init: RequestInit = {}) {
const res = await fetch(`${API}${path}`, {
...init,
headers: {
"X-API-Key": process.env.LUMBOX_API_KEY!,
"Content-Type": "application/json",
...(init.headers as Record<string, string>),
},
});
if (res.status === 408) return null;
if (!res.ok) throw new Error(`Lumbox ${res.status}: ${await res.text()}`);
return res.json();
}
async function createInbox(r: Registration): Promise<Registration> {
const inbox = await lumbox("/inboxes", {
method: "POST",
headers: { "Idempotency-Key": `registration-${r.key}` },
body: JSON.stringify({ metadata: { registration: r.key } }),
});
return { ...r, state: "inbox_ready", inboxId: inbox.id,
address: inbox.address, since: inbox.created_at };
}
The Idempotency-Key header is what makes step 1 safe to retry. If the request reached Lumbox but the response was lost, sending it again with the same key and the same body returns the stored response (with an Idempotent-Replayed: true header) instead of creating a second inbox. Keys last 24 hours. Reusing a key with a different body returns 409, which is the signal that two different registrations were given the same key.
Steps 5 and 6: waiting and verifying
async function waitForVerification(r: Registration) {
const q = new URLSearchParams({ from: r.site, since: r.since!, timeout: "90" });
const email = await lumbox(`/inboxes/${r.inboxId}/wait?${q}`);
if (!email) return null;
const { otp_codes, verification_links, magic_links, code_expiry } = email.parsed;
return { emailId: email.id, code: otp_codes[0], link: verification_links[0] ?? magic_links[0], code_expiry };
}
Using the inbox's created_at as since is what makes step 5 resumable. After a crash, the resumed worker asks for "anything from this sender since the inbox existed" and finds the email that arrived while it was down. Without since, /wait only sees mail that arrives after the new request starts, and the resumed worker waits for an email that is already there.
A 408 is not automatically a failure. Many sites have a "resend code" button; click it and wait again with the same since, up to a fixed number of attempts. The resend case is different when the first email did arrive but its code expired: that email still matches, so a wait with the old since returns it instantly. Record the time just before clicking resend and use that as the new since, which leaves only the fresh email in range.
For step 6, codes and links behave differently on resume. A verification link works from any browser, so a resumed worker can just open it. A code usually has to be typed into the page that asked for it; after a crash, log in with the stored password and most sites show the code prompt again. When the email says how long the code lasts, parsed.code_expiry holds that time, and a code past it is not worth submitting.
Running registrations in parallel
Each in-flight registration holds one inbox, and plans cap inboxes that exist at the same time: 3 on Free, 10 on Starter, 50 on Pro, 250 on Scale. The pool size for your workers is that cap minus the inboxes you keep for long-lived accounts. Exceed it and step 1 returns 402 with code: "PLAN_LIMIT_EXCEEDED"; treat that as backpressure and wait for a slot, not as a failure.
The API allows 120 requests per minute per key. A registration costs three or four calls, and a long-poll counts once however long it blocks, so the limit rarely binds. When it does, Lumbox returns 429 with retry_after_seconds in the JSON body and X-RateLimit-Remaining and X-RateLimit-Reset headers. There is no Retry-After header, so an HTTP client that only honours that header will retry immediately; read the body.
If the workers only touch inboxes they create, give them a project key. Inboxes created with it are pinned to that project, the key cannot reach other inboxes, and GET /v1/inboxes?project_id=... lists exactly the pipeline's inboxes for auditing.
Cleanup
Lumbox inboxes do not expire, so nothing gets cleaned up unless you do it. Two rules cover it. Registrations that reach stored keep their inbox, because resets and login codes for that account will arrive there. Registrations that end in failed get DELETE /v1/inboxes/:id, and a periodic job sweeps any record stuck in an intermediate state for longer than your longest retry window. The inbox list endpoint does not return metadata, so the sweep works from your records, not from Lumbox's list.
What the pipeline cannot do
It cannot answer a CAPTCHA, receive an SMS, or pass a KYC check, and it should not try: those steps exist to confirm a human is present. It also does not make a registration allowed where the service's terms say otherwise. The inbox-count caps per plan are on the pricing page, and the full set of /wait filters is in the inboxes docs.