Batch send

Send the same template to many recipients in a single API call, with per-recipient merge variables.

Ideal for digests, receipts and notifications.

POST /v3/messages.batch

The batch endpoint accepts up to 5,000 recipients per call. Each recipient renders independently and produces its own message.id — failures on one recipient never block the others.

Body parameters

ParameterTypeRequiredDescription
fromstringRequiredVerified sender. Format: "Name <email@domain.com>" or just the address.
from_namestringOptionalDisplay name for the From header, applied to every message in the batch. Overrides a name embedded in from.
subjectstringRequiredSubject line. Supports merge tags from recipients[].vars and global_vars.
htmlstringOptionalHTML body with merge tags.
textstringOptionalPlain-text alternative.
template_idstringOptionalStored template id (tpl_…) — used in place of html/text.
recipientsarray of objectsRequiredUp to 5,000 entries. See Recipient object below.
global_varsobjectOptionalMerge values shared by every recipient (e.g. campaign-wide URLs, brand name). Per-recipient vars override matching keys.
tagsarray of stringsOptionalTags applied to every message in the batch (max 10). See Tags.
headersobjectOptionalCustom X-* headers applied to every message in the batch — e.g. a correlation id like {"X-TES-MSGID": "tes-7f3a9c"}. Each header round-trips end-to-end: stamped on the delivered emails, stored with each message, echoed on every webhook payload, and returned by GET /v3/messages/{id}. Limits: 32 headers, 1,024 UTF-8 bytes per value; names must start with X-, and X-Wemail-* / X-SES-* are reserved.
webhook_urlstring (URL)OptionalPer-message webhook applied to every message in the batch — each message’s events are also POSTed to this HTTPS URL, in addition to your configured endpoints.
attachmentsarrayOptionalFile attachments applied to every message in the batch. Each: filename, content (base64), content_type. Limits: 5 MB decoded per file, 20 MB per message (all files combined, decoded) and 30 GB total per send — every recipient receives a full copy, so the total is size × recipients. See Attachments.

Recipient object

Each entry in recipients describes one rendered message. Per-recipient vars are merged on top of global_vars, so you can keep shared content in one place and only pass what differs.

Recipient object
ParameterTypeRequiredDescription
tostring or array of stringsRequiredThe destination email address(es) for THIS recipient entry — a field INSIDE each item of recipients[], not a top-level batch field. Required because every entry must have a destination. Whitespace is trimmed automatically; an invalid address rejects only this recipient (reported in rejected_recipients), never the whole batch. One entry per recipient is typical — for CC/BCC, send a separate batch entry.
namestringOptionalDisplay name for this recipient. If set, the To header becomes "Name <email>".
varsobjectOptionalPer-recipient merge variables, merged over global_vars (recipient wins on matching keys). Keys become available as {{key}} in the subject, HTML, and text.
tagsarray of stringsOptionalExtra tags for this single recipient (max 10, 128 chars each), appended after the batch-level tags. Useful for per-cohort segmentation (e.g. "locale:en", "plan:scale").
metadataobjectOptionalFree-form key/value pairs stored with this recipient’s message — your internal IDs, A/B variant, etc. Not visible to the recipient.
send_atISO 8601 timestampOptionalSchedule this single recipient for future delivery. Must be in the future and at most 1 year out; values outside that window are rejected with 400.

Responses

StatusDescription
201Created. { batch_id, accepted, rejected, message_ids, rejected_recipients? }. Invalid or unbuildable recipients are counted in rejected and detailed (first 100) in rejected_recipients as { index, to, reason } — index is the zero-based position in your recipients array. The batch is persisted asynchronously by a background worker; the Location header points at GET /v3/batches/{batch_id}. See Asynchronous batches.
400Structural validation error (more than 5,000 recipients, a recipient to array over 1,000 addresses, attachments over 5 MB per file / 20 MB per message or 30 GB total across all recipients, a send_at in the past or more than 1 year out, or every recipient invalid), or the from domain isn't registered / verified on your account. A malformed individual address is NOT a 400 — it is skipped and reported in rejected_recipients.
403Sandbox send to a recipient not on the confirmed test allow-list, or the account is suspended.
404The template_id doesn't exist on your account.
409A request with the same Idempotency-Key is still in flight.
429Monthly/daily sending quota, test-send allowance, or account request rate exceeded.

Asynchronous batches

Every batch is accepted asynchronously — there is no header to send and nothing to change on the client. The request itself validates the batch (structure, sender domain, sandbox rules, every recipient address), reserves its sending quota and assigns every message_id; the messages are then persisted by a background worker in paced chunks instead of on the request thread, so a campaign of many large batches never holds API connections or the database while your transactional traffic is served.

The 201 body is unchanged: the same { batch_id, accepted, rejected, message_ids, rejected_recipients? } with every per-recipient message_id. A Location header points at the batch status resource, GET /v3/batches/{batch_id}, which reports progress.

json201 Created (same body as always)
{
  "batch_id": "batch_7f3a2b1c9d8e4f50",
  "accepted": 840,
  "rejected": 0,
  "message_ids": ["msg_…", "msg_…"]
}
  • Errors stay synchronous. Structural validation (400), sandbox/allow-list (403), an unknown template (404), quota or rate limits (402/429) and a paused account (503) are answered on the request itself — a 201 means the batch is accepted, its quota reserved and every message_id assigned.
  • Optionally poll the batch. You already have every message_id in the 201, but you can also follow the Location header — GET /v3/batches/{batch_id} moves accepted → processing → completed (or partial / failed) and lists each message_id with its recipient and delivery status. See Get batch status. GET /v3/messages/{id} answers for every accepted id right away (as queued), even before the worker has written the row.
  • Same semantics as before. Recipient validation, rejected_recipients accounting, suppression checks, partial-fill, idempotency (Idempotency-Key replays the same 201, batch_id and headers) and per-message webhooks behave exactly as they did when batches were persisted on the request — only *where* the rows are written changed.
  • `Prefer: respond-async` is a no-op. The RFC 7240 header that used to opt a batch into this mode is still accepted for compatibility and echoed as Preference-Applied: respond-async; it changes nothing else. Keep sending it or drop it — both are fine.
  • Back-pressure. If the platform is momentarily saturated, any request may receive 503 with error code service_busy and a Retry-After header — wait the indicated seconds and retry; nothing was accepted.

What is a tag?

A tag is a short label you attach to a sent message — think hashtag, not category. Tags are how you slice your sending data later: every event, dashboard chart, suppression filter, and webhook payload carries the tags of the originating message, so you can ask questions like "what's the bounce rate of password-reset emails this week?" without scanning the body or subject.

ParameterTypeRequiredDescription
What they look likestringOptionalLowercase, alphanumeric + - _ :, max 128 chars. Examples: welcome, order-receipt, locale:en, variant:b.
Where you set themplacementOptionalOn a single send, on a batch (applies to all), or per-recipient inside recipients[].tags.
Where they show upsurfacesOptionalActivity log filters, the Metrics dashboard, every webhook event under tags[], and the GET /v3/events?tag=… query.
Tags vs. metadatawhen to use whichOptionalUse tags for things you'll group or filter by ("did this campaign perform?"). Use metadata for IDs you'll look up later ("show me the message for order #482910"). Tags are indexed and faceted; metadata is opaque.
LimitsquotasOptionalMax 10 tags per message. The first 1,000 distinct tags per account become facets in the dashboard; beyond that they're still searchable, just not pre-aggregated.

Code examples

cURL
curl -X POST https://api.wemail.io/v3/messages.batch \
  -H "Authorization: Bearer afn_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <noreply@updates.acme.io>",
    "subject": "Hi {{first_name}}, your {{month}} statement is ready",
    "html": "<p>Hi {{first_name}}, your balance is {{balance}}.</p>",
    "global_vars": { "month": "May", "portal_url": "https://acme.io/billing" },
    "tags": ["statement", "may-2026"],
    "headers": { "X-TES-MSGID": "tes-batch-may26" },
    "recipients": [
      { "to": "alex@example.com", "vars": { "first_name": "Alex", "balance": "$42.10" } },
      { "to": "ben@example.com", "vars": { "first_name": "Ben", "balance": "$11.00" } }
    ]
  }'
Node.js
await wemail.messages.batch({
  from: "Acme <noreply@updates.acme.io>",
  subject: "Hi {{first_name}}, your {{month}} statement is ready",
  html: "<p>Hi {{first_name}}, your balance is {{balance}}.</p>",
  global_vars: { month: "May", portal_url: "https://acme.io/billing" },
  tags: ["statement", "may-2026"],
  // applied to every message in the batch; echoed on each message's webhooks
  headers: { "X-TES-MSGID": "tes-batch-may26" },
  recipients: users.map(u => ({
    to: u.email,
    name: u.fullName,
    vars: { first_name: u.firstName, balance: u.balance },
    tags: [`plan:${u.plan}`, `locale:${u.locale}`],
    metadata: { user_id: u.id, variant: u.experimentVariant },
  })),
});
Python
wemail.messages.batch(
    from_="Acme <noreply@updates.acme.io>",
    subject="Hi {{first_name}}, your {{month}} statement is ready",
    html="<p>Hi {{first_name}}, your balance is {{balance}}.</p>",
    global_vars={"month": "May", "portal_url": "https://acme.io/billing"},
    tags=["statement", "may-2026"],
    recipients=[
      { "to": u.email, "vars": {"first_name": u.first_name, "balance": u.balance} }
      for u in users
    ],
)
PHP
$wemail->messages->batch([
  'from' => 'noreply@updates.acme.io',
  'subject' => 'Hi {{first_name}}',
  'html' => '<p>Your balance is {{balance}}</p>',
  'recipients' => $recipients,
]);
Ruby
wemail.messages.batch(
  from: "noreply@updates.acme.io",
  subject: "Hi {{first_name}}",
  html: "<p>Your balance is {{balance}}</p>",
  recipients: recipients
)
Go
_, err := client.Messages.Batch(ctx, &wemail.BatchParams{
  From: "noreply@updates.acme.io",
  Subject: "Hi {{first_name}}",
  HTML: "<p>Your balance is {{balance}}</p>",
  Recipients: recipients,
})
Java
wemail.messages().batch(BatchParams.builder()
  .from("noreply@updates.acme.io")
  .subject("Hi {{first_name}}")
  .html("<p>Your balance is {{balance}}</p>")
  .recipients(recipients).build());
.NET
await wemail.Messages.BatchAsync(new BatchParams {
  From = "noreply@updates.acme.io",
  Subject = "Hi {{first_name}}",
  Html = "<p>Your balance is {{balance}}</p>",
  Recipients = recipients,
});
ResponseExample response
{
  "batch_id": "batch_01HBZK22N4DQ",
  "accepted": 2,
  "rejected": 0,
  "message_ids": [
    "msg_01HBZK22NB3Q…",
    "msg_01HBZK22NB4R…"
  ]
}