Docs/Sending email

Batch sending

POST /v1/email/batch submits up to 100 independent messages in a single request. Each one has its own recipients, subject, and body. Reach for it when you have many distinct messages to send, not one message going to many recipients.

A batch is not the same as a multi-recipient send. POST /v1/email delivers one message to a shared recipient list; see Send a single email. A batch delivers a different message to each entry.

The request

The body is an emails array of 1 to 100 entries. Each entry takes the same fields as a POST /v1/email body.

curl https://api.anypost.com/v1/email/batch \
  -H "Authorization: Bearer $ANYPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "emails": [
      {
        "from": "Acme <[email protected]>",
        "to": ["[email protected]"],
        "subject": "Your invoice is ready",
        "html": "<p>Invoice #1190 is attached.</p>"
      },
      {
        "from": "Acme <[email protected]>",
        "to": ["[email protected]"],
        "subject": "Welcome to Acme",
        "html": "<p>Glad you are here.</p>"
      }
    ]
  }'

Shared defaults

Repeating from on every entry is noise. An optional defaults object holds fields shared across the batch; an entry inherits each one it does not set for itself.

curl https://api.anypost.com/v1/email/batch \
  -H "Authorization: Bearer $ANYPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "defaults": {
      "from": "Acme <[email protected]>",
      "tags": ["onboarding"]
    },
    "emails": [
      { "to": ["[email protected]"], "subject": "Welcome, Alex", "html": "<p>Hello.</p>" },
      { "to": ["[email protected]"], "subject": "Welcome, Sam", "html": "<p>Hello.</p>" }
    ]
  }'

Not every field merges the same way:

FieldsHow a default and an entry combine
from, subject, reply_to, text, html, campaign, topic, unsubscribeThe entry's value replaces the default.
cc, bcc, attachmentsThe entry's list is appended to the default list.
headers, variablesShallow-merged by key; the entry's value wins on a collision.
tagsConcatenated, default tags first, then de-duplicated.
template_idThe entry's template_id wins. An entry that inlines html or text drops the default template_id.

Every per-message limit is checked against the merged result, not against defaults or the entry alone. A batch cannot, for example, slip past the 50-recipient cap by splitting to between defaults and an entry.

Validation is all-or-nothing

Before any message is sent, every merged entry is validated. If one entry fails, the whole batch is rejected with 400 and nothing is sent. Error paths are prefixed with the entry index: a missing subject on the fourth entry reports as emails.3.subject.

Quota is enforced the same way. If the batch as a whole would exceed your daily limit (or, on the free plan, your monthly quota), the entire request is rejected with 429 before any entry is sent. Split it into smaller batches and retry after the limit resets. On a paid plan, sending past your monthly quota is billed as overage rather than rejected. See Sending limits.

The response

Once validation passes, each entry proceeds on its own. The response reports every entry's outcome under data, in request order, with a summary of the totals.

{
    "summary": { "total": 2, "queued": 1, "failed": 1 },
    "data": [
        {
            "status": "queued",
            "index": 0,
            "id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d",
            "created_at": "2026-04-30T12:00:00.123000Z"
        },
        {
            "status": "failed",
            "index": 1,
            "error": {
                "type": "permission_error",
                "message": "domain_not_verified"
            }
        }
    ]
}

A queued entry carries the id and created_at you would get from a single send. A failed entry carries an error with a type and a message. The index is the entry's zero-based position in the request emails array.

error.typeMeaning
validation_errorThe entry referenced a template that does not exist or is unpublished, or supplied no subject of its own.
permission_errorThe sender domain is not on the API key's allowlist, or is not verified for your team.
internal_errorAnypost could not accept the entry. Retry that entry.

HTTP status

The top-level status reflects the mix of outcomes:

StatusMeaning
202Every entry queued.
207Mixed: some entries queued, some failed. Inspect data.
400The batch failed validation, or every entry failed for a reason you can correct (unverified domain, missing template).
502Every entry failed and at least one failure was a temporary server-side error. Retry the whole batch with backoff.

A 207 is a success envelope, not an error: per-entry failures live inside data[i].error, not in a top-level error object. See API conventions for the canonical error shape a 400 uses.

The SDK resolves a 207 rather than throwing. Inspect each entry's status:

for (const entry of result.data) {
    if (entry.status === 'queued') {
        console.log(entry.index, entry.id);
    } else {
        console.error(entry.index, entry.error.type, entry.error.message);
    }
}

Idempotency

A batch retry is made safe the same way a single send is: include an Idempotency-Key header. A replay returns the original data array verbatim, so entries that queued on the first attempt are never sent twice, even when the first response was a 207. See API conventions.

Sizing a batch

The hard cap is 100 entries, but the request body is also capped at 5 MB.

  • Text-only entries reach 100 long before the body cap.
  • Entries with attachments hit 5 MB well before 100. Aim for 25 entries or fewer when attaching files. When every entry carries the same file, put it in defaults.attachments so its bytes are sent once, not once per entry.

An over-large body is rejected with 413 before any entry is processed.

Per-recipient content

A batch is how you send genuinely different content to each recipient. Set a template as a batch default and give each entry its own variables:

{
    "defaults": {
        "from": "Acme <[email protected]>",
        "template_id": "template_550e8400-e29b-41d4-a716-446655440000"
    },
    "emails": [
        {
            "to": ["[email protected]"],
            "variables": { "first_name": "Alex", "balance": "$40" }
        },
        {
            "to": ["[email protected]"],
            "variables": { "first_name": "Sam", "balance": "$0" }
        }
    ]
}

Within a single POST /v1/email, variables is one object shared by all recipients. A batch lifts that limit. See Variables & personalization for placeholder syntax.

Where to go next