This guide covers the Python side of Lumbox: creating inboxes, waiting for verification codes, sending, replying, and running several waits at once. It uses two tools. The lumbox package on PyPI (version 0.3.0, whose only dependency is httpx) handles the receiving half. It has no send, reply or forward methods, so the sending half uses httpx directly against the REST API. Both talk to https://api.lumbox.co.
Install and get a key
pip install lumbox
If you do not have an API key yet, you can create an account from Python. POST /v1/orgs needs no authentication and returns a key once:
import httpx
r = httpx.post("https://api.lumbox.co/v1/orgs", json={"name": "my-scraper"})
r.raise_for_status()
print(r.json()["api_key"]) # ak_... is shown once; store it as LUMBOX_API_KEY
Account creation is limited to 3 per hour from one IP address, so do it once and keep the key in your environment rather than creating accounts in a loop.
Create an inbox and wait for a code
import os, uuid
from lumbox import Lumbox
from lumbox.client import LumboxError
client = Lumbox(api_key=os.environ["LUMBOX_API_KEY"])
inbox = client.create_inbox(name=f"signup-{uuid.uuid4().hex[:8]}")
print(inbox.id, inbox.address) # inb_... signup-3f9a1c2e@trylumbox.com
# ... submit inbox.address to the site you are signing up for ...
try:
otp = inbox.wait_for_otp(sender="github.com", timeout=90)
print(otp["code"], otp["expires"])
except LumboxError as e:
if e.status != 408:
raise
otp = None # no verification email within 90 seconds
A few details in that snippet are there for a reason.
The random suffix. The inbox name becomes the local part of the address, and new inboxes live on the shared trylumbox.com domain. If another customer already has signup-bot@trylumbox.com, the create call fails with 409. Common role names such as admin or support are reserved on shared domains, and names that look like well-known brands are refused. Omit name entirely and you get a generated address.
The import path. LumboxError is not exported from the package root, so import it from lumbox.client. It carries the HTTP status in .status and the parsed JSON body in .body.
The 408. wait_for_otp holds the request open for up to timeout seconds (the server caps it at 120) and raises with status 408 if no code arrives. The SDK's HTTP timeout is 130 seconds, which leaves room for the longest server-side wait. The Python SDK does not retry anything on its own, so a 408 reaches your code straight away.
When the code is not a plain number
wait_for_otp only looks at emails the parser categorised as verification mail, and on received mail the parser finds 4 to 8 digit codes in the subject and plain-text body. A code like K7Q2ZX, or one that only appears in the HTML part, needs the full email:
import re
email = inbox.wait_for_email(sender="example.com", timeout=90)
codes = email["parsed"]["otp_codes"]
if not codes:
pattern = r"\b(?=[A-Z0-9]*\d)(?=[A-Z0-9]*[A-Z])[A-Z0-9]{6}\b"
codes = re.findall(pattern, email["text_body"] or "")
links = email["parsed"]["verification_links"]
The pattern requires both a digit and a letter so that ordinary capitalised words do not match. Note that text_body and stripped_text come wrapped in a fence: a line of forty = characters and a note marking the text as untrusted email content, then the body, then another line of =. It is there so a language model treats the body as data rather than instructions, and a regex will skip over it. The parsed fields are not fenced.
Reading everything else that arrives
Not every email is a code. inbox.list_emails(sender=..., category=..., since=..., unread=True, limit=20) returns {"data": [...], "cursor": ..., "has_more": ...}, newest first, with parsed fields on every item and spam hidden. The SDK method takes no cursor argument, so to page past the first 100 emails call GET /v1/inboxes/{id}/emails with httpx and pass the returned cursor back. inbox.get_email(email_id) returns one email with its attachments and marks it read, which is what unread=True filters on next time. Categories come from keyword rules (verification, security, transactional, notification, newsletter, conversation, calendar and a few more), which makes them useful for routing mail to the right handler, while the decision about what an email means still comes from reading it.
Sending and replying with httpx
import os, time, uuid
import httpx
api = httpx.Client(
base_url="https://api.lumbox.co/v1",
headers={"X-API-Key": os.environ["LUMBOX_API_KEY"]},
timeout=30,
)
def post(path, body, key):
for _ in range(3):
r = api.post(path, json=body, headers={"Idempotency-Key": key})
if r.status_code == 429:
time.sleep(r.json().get("retry_after_seconds", 60))
continue
r.raise_for_status()
return r.json()
raise RuntimeError(f"still rate limited after 3 attempts: {path}")
def send(inbox_id, to, subject, text):
return post(f"/inboxes/{inbox_id}/send",
{"to": to, "subject": subject, "text": text},
key=str(uuid.uuid4()))
def reply(inbox_id, email_id, text):
return post(f"/inboxes/{inbox_id}/reply",
{"email_id": email_id, "text": text},
key=f"reply-{email_id}")
result = send(inbox.id, "ops@example.com", "Signup finished", "Account verified.")
print(result["email_id"], result["thread_id"])
The send body takes to (a string or a list), subject, text and/or html, and cc. There is no bcc, reply_to or attachment field. Adding "dry_run": true validates the request and returns what would be sent without sending it.
The Idempotency-Key is generated once per logical send and reused on every retry. A request that succeeded is stored for 24 hours, so a retry after a network error returns the original response instead of sending a second email, and failed requests release the key so the retry can go through. Generating a new key inside the retry loop would defeat this.
The errors worth handling separately are 429 (per-minute send limit, 3 a minute on the free plan, with retry_after_seconds in the body, or SEND_RAMP_LIMIT for a new free account that has sent its first 5 emails in its first hour), 402 with PLAN_LIMIT_EXCEEDED when the monthly quota is used up, and 403 with SEND_BLOCKED or INVALID_RECIPIENT. Only the per-minute 429 clears by waiting a minute; the loop above waits once per attempt and then gives up.
Waiting on several inboxes at once
The SDK is synchronous. For concurrent waits, for example several signups running in parallel, use httpx.AsyncClient against the same endpoints:
import asyncio, os
import httpx
async def wait_otp(client, inbox_id, sender):
r = await client.get(f"/inboxes/{inbox_id}/otp",
params={"timeout": 120, "from": sender}, timeout=130)
if r.status_code == 408:
return None
r.raise_for_status()
return r.json()["code"]
async def main(inbox_ids):
async with httpx.AsyncClient(
base_url="https://api.lumbox.co/v1",
headers={"X-API-Key": os.environ["LUMBOX_API_KEY"]},
) as client:
return await asyncio.gather(*(wait_otp(client, i, "example.com") for i in inbox_ids))
codes = asyncio.run(main(["inb_XXXXXXXXXXXXXXXXXXXX", "inb_YYYYYYYYYYYYYYYYYYYY"]))
Each wait is a single request however long it stays open, so ten concurrent waits use ten of the 120 requests a minute each key gets by default.
Cleaning up
Inboxes do not expire. inbox.delete() removes the inbox and its emails, and it matters on the free plan, which allows three inboxes at a time. Put it in a finally block so a failed signup does not leave an inbox behind.
Agent frameworks
The package also exports lumbox_tools(api_key), a dict of plain functions (create an inbox, wait for an email, get an OTP, list, read and search emails, add and verify a domain) that most frameworks can register as tools, and create_langchain_tools(api_key), which wraps them as LangChain tools when installed with pip install "lumbox[langchain]". Neither includes sending, for the same reason as above. The LangChain post shows the wiring, this comparison of receive methods covers webhooks and streaming from Python, and the sending docs list every field on send, reply and forward.