Batch and scheduled sends

Up to a hundred personalised messages in one request, and mail that goes out later.

A batch: one request, many messages

POST /emails/batch takes an array of up to 100 ordinary send bodies. Each entry is its own message with its own recipient and content — useful for a nightly digest or a round of reminders where every email is slightly different.

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-10-05" \
  -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 Oct 8.</p>",
      "text": "Hi Alice, your trial ends on Oct 8."
    },
    {
      "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 Oct 8.</p>",
      "text": "Hi Bob, your trial ends on Oct 8."
    }
  ]'

The response lists one id per entry, in the same order:

{
  "object": "list",
  "data": [
    {"object": "email", "id": "c41a..."},
    {"object": "email", "id": "c41b..."}
  ]
}

How a batch behaves

  • All or nothing. Every entry is checked before anything is written or charged. One bad entry — an unverified domain, an empty body — rejects the whole batch with a 400 that names the problem.
  • 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. A batch of 100 costs the same as 100 single sends, and counts against the rate limit the same way. Splitting a large job into batches makes it faster, not cheaper.
  • No templates. Entries cannot use template or topic_id; render the body yourself, or send templated mail through POST /emails one at a time.

Sending a long list

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",
        // Stable per chunk, so re-running the job after a crash
        // does not send the first chunks twice.
        "Idempotency-Key": "digest-2026-10-05-" + 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)
  }
}
For marketing mail to a list, use broadcasts instead: they handle unsubscribe links, topics and per-contact suppression for you. Batch is for transactional mail that happens to go out in bulk.

Scheduling: send it later

Add scheduled_at in ISO 8601 to any send — single or batch entry. The message is accepted now and delivered at that time; until then its status is scheduled.

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@mail.acme.com>",
    "to": "alice@example.com",
    "subject": "How is your first week going?",
    "html": "<p>Hi Alice, any questions so far? Just reply to this email.</p>",
    "text": "Hi Alice, any questions so far? Just reply to this email.",
    "scheduled_at": "2026-10-12T09:00:00+08:00"
  }'

Include the offset. A timestamp without one is not a time; it is a different time in every zone it could be read in.

Moving or cancelling it

Both need a full_access key or a dashboard session — the sending_access key that created the message gets 401 here, so changing mail that is already queued takes a deliberate, more privileged step.

# Move it
curl -X PATCH https://api.rovela.dev/emails/<email_id> \
  -H "Authorization: Bearer re_your_full_access_key" \
  -H "Content-Type: application/json" \
  -d '{"scheduled_at": "2026-10-13T09:00:00+08:00"}'

# Cancel it
curl -X POST https://api.rovela.dev/emails/<email_id>/cancel \
  -H "Authorization: Bearer re_your_full_access_key"
# {"object": "email", "id": "<email_id>", "cancelled": true}
  • Rescheduling works only while the message is still scheduled. Cancelling also works on a message that is queued but not yet handed to the mail server.
  • Once it is on its way, both return 400 — mail that has left cannot be called back.
  • Suppressions are checked again at delivery time, so an address that bounces between scheduling and sending is skipped rather than mailed.

Every field and error: Emails API.