← Back to blog

Batch Transactional Email API (2026): Up to 100 Personalized Messages with Rovela

Use Rovela POST /emails/batch for up to 100 personalized transactional messages: validation, suppression, Idempotency-Key per chunk, and when to use broadcasts instead.

Updated November 10, 2026
Batch Transactional Email API (2026): Up to 100 Personalized Messages with Rovela

A batch transactional email API (not a marketing blast)

A batch transactional email API sends many personalized messages in one HTTP request — trial reminders, renewal notices, per-user digests — without turning them into a newsletter. Rovela’s endpoint is POST https://api.rovela.dev/emails/batch: up to 100 ordinary send bodies, each with its own recipient and content.

This is not Bulk Email Sending or Bulk Email vs Single Send (broader bulk vs single), and it is not a broadcast. For marketing list fan-out, one-click unsubscribe, and topic suppression, use broadcasts (Transactional vs Marketing Email). The field-level reference is the Send Email API; the behavior notes below follow the batch and scheduled example.

Start from the Email API product page, the Quick Start, the Send Email API, and Authentication.

Related product guides in this series:

The request

curl -X POST https://api.rovela.dev/emails/batch \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: reminders-2026-11-10" \
  -d '[
    {
      "from": "Acme <reminders@mail.acme.com>",
      "to": "alice@example.com",
      "subject": "Your trial ends in 3 days",
      "html": "<p>Hi Alice, your trial ends on Nov 13.</p>",
      "text": "Hi Alice, your trial ends on Nov 13."
    },
    {
      "from": "Acme <reminders@mail.acme.com>",
      "to": "bob@example.com",
      "subject": "Your trial ends in 3 days",
      "html": "<p>Hi Bob, your trial ends on Nov 13.</p>",
      "text": "Hi Bob, your trial ends on Nov 13."
    }
  ]'

The response is { "object": "list", "data": [ { "object": "email", "id": "…" }, … ] } in the same order as the array you sent. Send an Idempotency-Key for the chunk (for example digest-2026-11-10-0) so a crashed job can safely retry that slice (Idempotency Keys for Email APIs).

Each entry uses the same schema as POST /emails (from, to, subject, html / text, scheduled_at, attachments, tags, …) with two deliberate exceptions: template and topic_id are rejected, not ignored. A batch that silently dropped topic_id could mail people who had unsubscribed from that topic; one that dropped template would send a message with no body. Send those through POST /emails one at a time, or render HTML yourself before batching.

How a batch behaves

From the batch example:

  • Validated before anything is written or charged. One bad entry — an unverified domain, an empty body — rejects the whole request with a 400 that names the problem. Fix the entry and resubmit the same idempotency key only after you understand whether anything was accepted (a 400 means nothing was written).
  • After acceptance, entries deliver independently. A later bounce on one recipient does not retract the others.
  • Suppressed addresses are skipped, not refused. Unlike a single send, a batch does not reject an entry for a suppressed recipient. It is accepted, then skipped at delivery with status suppressed and an email.suppressed event.
  • Billed per recipient, not cheaper than 100 singles. A batch of 100 costs the same as 100 single sends and counts against the rate limit the same way (429 can appear partway through a large job; each message counts individually). Splitting a job into batches makes it faster, not cheaper.
  • scheduled_at works on each entry, same ISO 8601 rule (include the offset). Suppressions are checked again at delivery time (Scheduled Email Sending).

The 50-recipient cap still applies inside each entry (to+cc+bcc).

Sending more than 100

async function sendAll(messages) {
  for (let i = 0; i < messages.length; i += 100) {
    const chunk = messages.slice(i, i + 100);
    const res = await fetch("https://api.rovela.dev/emails/batch", {
      method: "POST",
      headers: {
        Authorization: "Bearer " + process.env.ROVELA_API_KEY,
        "Content-Type": "application/json",
        "Idempotency-Key": "digest-2026-11-10-" + i,
      },
      body: JSON.stringify(chunk),
    });
    if (res.status === 429) {
      await new Promise((r) => setTimeout(r, 1000 * Number(res.headers.get("retry-after") ?? 1)));
      i -= 100; // same chunk again
      continue;
    }
    if (!res.ok) throw new Error("chunk " + i + ": " + (await res.json()).message);
  }
}

This pattern is taken from the official example. Use a sending_access key on the worker.

When not to use batch

FAQ

Can I mix scheduled_at and immediate entries in one batch? Each entry is its own message; set scheduled_at only on those that should wait. The example documents scheduled_at on any send, single or batch entry.

How do I cancel one message from a batch? POST /emails/:id/cancel with full_access, while it is still scheduled or queued but not yet handed to the mail server — same as a single send (Send Email with curl).

Does the API reference’s “processed independently” contradict all-or-nothing validation? Read them together: the example is explicit that validation is all-or-nothing before write/charge; after a batch is accepted, delivery outcomes are per message. When in doubt, treat a 400 as “nothing queued” and inspect a 200 list of ids.

Personalize in bulk, keep it transactional

Render each body in your job, POST /emails/batch in chunks of 100, and key each chunk. That is the batch transactional email API — useful for reminders and digests, and intentionally the wrong tool for a campaign.