An agent that can write one good email can write a thousand, and that is the problem with bulk sending. The API call is the easy part. What decides whether bulk mail works is who is on the list, whether they asked to be there, and whether they can leave with one click. This post covers the two bulk paths Lumbox has, the rules each one enforces, and the parts that stay your responsibility.

Start with the boundary. Bulk sending is for people who opted in: customers who asked for product updates, users who signed up for a digest, a waitlist. It is not for lists an agent scraped or bought. Mail to people who never asked for it gets reported as spam, and spam reports are what mailbox providers judge a sender on. Lumbox also suspends organizations that break its acceptable use policy, and a suspended organization's keys return 403 on every call.

Two ways to send in bulk

POST /v1/send/bulk takes up to 100 separate messages in one request. Each item has its own recipient, subject and body, so this is the path for personalized mail where the agent writes each message.

POST /v1/contact-lists/{id}/send sends one subject and one body to every active contact on a stored list. Lumbox splits the list into batches of 100 itself. This is the path for a newsletter or an announcement where everyone gets the same text.

Neither path has a template language. There is no {{name}} substitution. If each recipient should see their own name or their own numbers, generate the text per recipient and use the bulk endpoint.

The bulk endpoint

const API = "https://api.lumbox.co/v1";

async function sendDigest(inboxId, subscribers, runId) {
  for (let i = 0; i < subscribers.length; i += 100) {
    const chunk = subscribers.slice(i, i + 100);
    const res = await fetch(`${API}/send/bulk`, {
      method: "POST",
      headers: {
        "X-API-Key": process.env.LUMBOX_ORG_KEY,
        "Content-Type": "application/json",
        "Idempotency-Key": `${runId}-chunk-${i / 100}`,
      },
      body: JSON.stringify({
        inbox_id: inboxId,
        emails: chunk.map((s) => ({
          to: s.email,
          subject: `Your weekly digest, ${s.firstName}`,
          html: s.renderedHtml,
          text: s.renderedText,
        })),
      }),
    });
    if (!res.ok) throw new Error(`bulk failed: ${res.status} ${await res.text()}`);
    const { sent, skipped } = await res.json();
    console.log(`chunk ${i / 100}: sent ${sent}, skipped ${skipped}`);
  }
}

A successful call returns 201 with { ok, sent, skipped, provider: "resend", data: [{ email_id, provider_message_id }] }. The rules it enforces:

  • Organization-level key only. Project-scoped keys get a 403 on this route, and the key needs the emails:send scope.
  • 100 items per request. A 101st returns 400 with Maximum 100 emails per batch request. Chunk on your side, as above.
  • Quota is all or nothing. If the batch would push you past your monthly send quota, the whole request fails with 402. Nothing is partially sent.
  • The sender must be yours. inbox_id is required and is the default From address. An item may set its own from, but only to that inbox, another inbox in your organization, or an address on a domain you verified. Anything else returns 403.
  • Every recipient is checked. Address syntax and your allow and block lists are checked across all items before anything is sent. A malformed address fails the whole request with a 400 that names it, and an address your block list rejects fails it with a 403.
  • Delivery is always through Resend. If your organization has a default Resend configuration with its own API key, that key is used; otherwise Lumbox's own Resend account sends it.

Some fields are accepted and do less than their names suggest. bcc is accepted in an item and then dropped before the message reaches Resend. reply_to accepts an array but only the first address is used. There is no attachment field. The per-minute send limit counts a bulk request as one send, and the idempotency key works here the same way it does on single sends: a repeated chunk with the same key and body returns the stored result instead of mailing everyone in it twice.

One recipient per item

You can put several addresses in an item's to, but you should not, for two reasons. First, one-click unsubscribe headers are only added when an item has exactly one recipient, because the header identifies a single subscriber. Second, suppression is applied per item: an item addressed to several people is sent if any one of them is still deliverable. With one recipient per item, both rules behave the way you would expect.

For a single-recipient item, Lumbox adds the two headers from RFC 8058:

List-Unsubscribe: <https://api.lumbox.co/v1/unsubscribe/<signed-token>>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

The token is HMAC-signed and carries your organization and the recipient, so it cannot be edited to unsubscribe someone else. When a mail client posts to that URL, the address goes on your organization's suppression list and any contact records with that address are marked UNSUBSCRIBED. Later sends skip it, and the skipped items show up in the response's skipped count. Lumbox also suppresses addresses that hard-bounced or complained, based on Amazon SES delivery notifications. Bulk sends go out through Resend, whose bounce events do not feed that list, so drop hard-bounced addresses from the agent's source list yourself. With your own Resend key configured, they show up in your Resend account. The one-click unsubscribe post explains the header mechanics in more detail.

What the headers do not do for you

The headers give mail clients an unsubscribe button. They do not put anything in the message body, and Lumbox does not add a footer. Two rules outside Lumbox expect one:

  • Gmail's sender guidelines require senders of more than 5,000 messages a day to Gmail accounts to support one-click unsubscribe on marketing mail and to include a clearly visible unsubscribe link in the body. The same guidelines ask for SPF, DKIM and DMARC on the sending domain and a user-reported spam rate below 0.3%.
  • In the US, the CAN-SPAM Act requires commercial email to include a clear way to opt out and a valid physical postal address, and opt-outs must be honored within 10 business days, per the FTC's compliance guide.

So the body your agent writes needs a visible unsubscribe link and your postal address. The one-click URL from the header is not returned to you, so link to your own preferences page. When someone opts out there, remove them from whatever source the agent builds its list from, and set their contact status to UNSUBSCRIBED if you use contact lists. A send block list entry also stops mail to an address, but in a bulk request it fails the whole request with a 403 instead of skipping that one item.

Contact lists

POST /v1/contact-lists              { "name": "Product updates" }
POST /v1/contact-lists/{id}/contacts [{ "email": "a@example.com", "first_name": "Ana" }]
POST /v1/contact-lists/{id}/send    { "inbox_id": "inb_...", "subject": "...", "html": "...", "from_name": "Acme" }

Contacts carry email, first_name, last_name, metadata and a status of ACTIVE, UNSUBSCRIBED or BOUNCED. A list send goes to ACTIVE contacts only, one recipient per message, each with its own unsubscribe headers, in Resend batches of 100. The response is { ok, sent, failed, total }, where failed counts contacts in batches that Resend rejected.

One difference to plan around: idempotency keys only apply under /v1/inboxes/ and /v1/send/, so a list send has no replay protection. If an agent retries a list send after a timeout, the whole list can get the message twice. Treat a list send as something the agent proposes and a person triggers.

Where the agent fits

The useful split is that the agent does the writing and a person does the pressing. Let the agent segment the list, draft the copy per segment, and send the draft to an internal seed address with a single-message send. A person reads it and approves, and only then does the bulk call run. A bad prompt in a single send embarrasses you once; the same prompt in a list send reaches everyone on the list before anyone notices.

Send from your own verified domain rather than the shared trylumbox.com domain. Bulk mail on a shared domain spends reputation that other senders depend on, and on your own domain the reputation you build is yours. The posts on shared versus dedicated sending domains and on warming up a new domain cover that decision.

What is not here: scheduling, templates, A/B tests, open tracking and attachments. If you need those, a dedicated marketing platform is the better tool. Monthly send volume depends on your plan, from 100 sends on Free to 50,000 on Scale, as listed on the pricing page.