If you are building an agent product where each customer runs their own agents (a recruiting agent, a shopping agent, an SDR), each agent eventually needs an email address. One shared inbox filtered by customer works for a demo and turns into a data-leak risk the first time a filter is wrong. The structure that holds up gives each customer a sandbox and each agent its own inbox inside it.
On Lumbox that maps to three levels: your organisation, a project per tenant, and an inbox per agent. This post covers provisioning, routing inbound mail, the limits tenants share, custom domains, usage for billing, and offboarding.
Org, project, inbox
Your backend holds an org-level API key. For each tenant it creates a project with POST /v1/projects and mints a project key with POST /v1/projects/:id/keys. A project key is sandboxed: it only sees inboxes in its project, every inbox it creates is placed in its project, and it gets a 403 from org-level routes, including webhooks, domains, threads, search and bulk send. Inside that boundary it can create, read, pause and delete inboxes, list and read email, send, reply and forward, and read metrics.
Project keys are the tenant boundary Lumbox enforces. Keys minted per inbox with POST /v1/inboxes/:id/api-keys are not a substitute, because they are not blocked from org-level routes.
Provisioning
import { randomBytes } from "node:crypto";
const API = "https://api.lumbox.co/v1";
const ORG_KEY = process.env.LUMBOX_ORG_KEY;
async function lumbox(key, method, path, body) {
const res = await fetch(`${API}${path}`, {
method,
headers: { "X-API-Key": key, "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.error), { status: res.status, code: data.code });
return data;
}
export async function provisionTenant(tenantId) {
const project = await lumbox(ORG_KEY, "POST", "/projects", { name: `Tenant ${tenantId}`, slug: `t-${tenantId}` });
const key = await lumbox(ORG_KEY, "POST", `/projects/${project.id}/keys`, { name: `${tenantId}-agents` });
await db.tenants.update(tenantId, { lumboxProjectId: project.id, lumboxKey: encrypt(key.api_key) });
}
export async function provisionAgent(tenantId, agentId) {
const tenant = await db.tenants.get(tenantId);
const inbox = await lumbox(decrypt(tenant.lumboxKey), "POST", "/inboxes", {
local_part: `agent-${randomBytes(5).toString("hex")}`,
metadata: { tenant_id: tenantId, agent_id: agentId },
});
await db.agents.update(agentId, { inboxId: inbox.id, address: inbox.address });
return inbox.address;
}
The project key's api_key is returned once, so it is encrypted and stored before anything else. A second project with the same slug returns 409, which makes provisionTenant safe to retry once you look the project up on conflict. Your own database is the source of truth for which inbox belongs to which tenant; metadata (up to 256 keys, 1,024 bytes per value) is a copy for debugging and for GET /v1/inboxes/:id.
Choosing addresses
New inboxes land on @trylumbox.com by default, a domain shared by every Lumbox customer. That has three consequences. Addresses are unique across all customers, so a readable name like acme-recruiter may already be taken (409). Reserved names such as support, billing or admin, and names that imitate known brands, are refused on shared domains. And once an inbox is deleted its address is free for anyone to create again, so mail from a service that still has the old address would reach the new owner. Random local parts, as above, avoid all three and keep tenant names out of addresses your agents hand to third parties.
Routing inbound mail
Webhooks are org-level, so you register one with the org key and it fires for every inbox in every project. The email.received payload identifies the inbox by data.inbox_id; it does not include the inbox's metadata. The handler looks the inbox up in your database, and drops and logs anything it does not recognise:
const agent = await db.agents.findByInboxId(event.data.inbox_id);
if (!agent) return log.warn("email for unknown inbox", event.data.inbox_id);
await queues.forTenant(agent.tenantId).enqueue({ agentId: agent.id, emailId: event.data.email_id });
A worker for that tenant then reads the email with the tenant's project key, so even a routing bug cannot make it read another tenant's mail. Deliveries are not retried, so pair the webhook with a periodic sweep; the webhook guide shows both.
Limits tenants share
Some limits are per key and some are per org, and the per-org ones are where one tenant can hurt another.
- Per key: 120 requests a minute. Each tenant's project key has its own counter, so a tenant whose agents poll too hard gets 429s without slowing anyone else.
- Per org: the inbox count (3, 10, 50 or 250 by plan, paused inboxes included), sends per minute (3, 30, 60 or 120), and the monthly send quota. Every tenant draws from the same pool.
Lumbox does not split the per-org limits by project, so give each tenant its own inbox and send allowance in your application and check it before calling the API. Otherwise one tenant's outreach campaign can use the whole org's sends for the minute, or the month.
A custom domain per tenant
Larger customers usually want agents on their own domain. Your backend adds it with POST /v1/domains using the org key, passes the MX, SPF and DKIM records the API returns to the customer, and calls POST /v1/domains/:id/verify until the domain is ACTIVE, which requires the verification TXT record, MX and SPF to resolve. Inboxes are then created with "domain": "theirco.com".
Domains belong to the org, not to a project. Create custom-domain inboxes from your backend with the org key and pass project_id, so each inbox lands in the right tenant's project and the decision about which tenant may use which domain stays in your code.
Usage for billing
GET /v1/metrics called with a tenant's project key covers only that project's inboxes. With bucket=day and a from/to range of up to 366 days it returns daily series and totals for received and sent mail, plus delivered, bounced and complained where delivery events exist. Called with the org key and inbox_id, it gives the same numbers for one agent.
Pausing, deleting, offboarding
POST /v1/inboxes/:id/pause stops an agent's mail at the SMTP level: senders get a permanent "Mailbox is paused" rejection rather than a queued retry, so use it for suspended accounts rather than short maintenance. /resume reverses it. A paused inbox still counts toward the org's inbox limit.
DELETE /v1/inboxes/:id removes the inbox and its email records. Order matters when a tenant leaves: deleting a project deletes its keys but does not delete its inboxes, which move to the org root and keep counting against the limit. Delete the tenant's inboxes with its project key first, then the project with the org key:
export async function offboardTenant(tenantId) {
const tenant = await db.tenants.get(tenantId);
const key = decrypt(tenant.lumboxKey);
const ids = [];
let cursor = null;
do {
const page = await lumbox(key, "GET", `/inboxes?limit=200${cursor ? `&cursor=${cursor}` : ""}`);
ids.push(...page.data.map((i) => i.id));
cursor = page.has_more ? page.cursor : null;
} while (cursor);
for (const id of ids) await lumbox(key, "DELETE", `/inboxes/${id}`);
await lumbox(ORG_KEY, "DELETE", `/projects/${tenant.lumboxProjectId}`);
}
Listing with the project key returns exactly that tenant's inboxes, including any created outside your provisioning code. Email isolation in multi-agent systems covers the same boundary from the agent's side, and plan inbox counts are on the pricing page.