Notideus
Tutorials

Sending batches

Batch semantics: per-item results, partial failure and quota checks.

When to batch

Use POST /v1/emails/batch when you have many emails to enqueue at once — a digest run, onboarding bursts, order receipts. One call accepts up to 100 emails:

LimitValue
Emails per batch call100

The full limits table lives in Quotas and limits.

Request shape

The body is an emails array; each item has the same shape as a single send — including the optional text, headers and reply_to fields — with its own idempotency_key. With the SDK, pass an array of the same send payloads to emails.sendBatch:

const results = await notideus.emails.sendBatch([
  {
    from: 'Notideus <noreply@demo.dev>',
    to: ['jane@example.com'],
    subject: 'Your weekly digest',
    html: '<p>Hi Jane, here is your digest.</p>',
    idempotency_key: 'digest-2026-09-14-jane'
  },
  {
    from: 'Notideus <noreply@demo.dev>',
    to: ['john@example.com'],
    subject: 'Your weekly digest',
    html: '<p>Hi John, here is your digest.</p>',
    idempotency_key: 'digest-2026-09-14-john'
  }
]);

Response: per-item results

A successful call returns 200 with one result per item, in request order. This is a real three-item response:

{
  "data": [
    {
      "index": 0,
      "id": "01a0a11c-4e6e-7228-b9b3-f50fd0728a90"
    },
    {
      "index": 1,
      "id": "01a0a11c-4e75-7486-8f23-8d50dc697ebf"
    },
    {
      "index": 2,
      "error": {
        "code": "from_domain_not_verified",
        "message": "no verified domain matches the from address"
      }
    }
  ]
}

Decode:

  • index is the item's position in the request array (0-based).
  • Items 0 and 1 were accepted — id is the stored email's UUID, exactly what a single send would return.
  • Item 2 failed — error.code is from_domain_not_verified, and error.message explains why. Nothing was stored for it.

The SDK resolves with the per-item array directly — narrow each result by checking for the error key:

for (const item of results) {
  if ('error' in item) {
    console.error(item.index, item.error.code, item.error.message);
  } else {
    console.log(item.index, item.id);
  }
}

In PHP the same array is returned — narrow each item with isset($item['error']):

foreach ($results as $item) {
    if (isset($item['error'])) {
        error_log($item['index'] . ' ' . $item['error']['code'] . ' ' . $item['error']['message']);
    } else {
        error_log($item['index'] . ' ' . $item['id']);
    }
}
Partial failures never abort the batch. Every item is validated and queued independently, so one bad From address or unverified domain does not affect the other items — always check each result before reporting success.

Whole-call failures

Some errors reject the whole call before any item is processed, with the standard error envelope:

{
  "error": {
    "code": "batch_too_large",
    "message": "batch is limited to 100 emails"
  }
}
  • 400 batch_too_large — more than 100 items; split the payload and try again.
  • 402 quota_exceeded — the batch is checked against your quota up front: if it would not fit, nothing is sent. Idempotent replays of already-stored emails are exempt, so retrying with the same keys never double-counts. Otherwise back off and retry later — see Quotas and limits.

Retry strategy

Only the items with an error need retrying. Build a new batch from just those items and re-send it with the same per-item idempotency_keys: items that succeeded on the first attempt will not be duplicated, and an item that was actually stored before your client timed out will replay instead of double-sending. See Idempotency.

With the SDK the same rule applies: rebuild the batch from just the failed items and call emails.sendBatch again with the same per-item keys. Whole-call failures (401, 402 quota_exceeded, 400 batch_too_large) throw a NotideusError instead of returning items.

Copyright © 2026