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
| Parameter | Type | Required | Description |
|---|---|---|---|
from | string | Required | Verified sender. Format: "Name <email@domain.com>" or just the address. |
from_name | string | Optional | Display name for the From header, applied to every message in the batch. Overrides a name embedded in from. |
subject | string | Required | Subject line. Supports merge tags from recipients[].vars and global_vars. |
html | string | Optional | HTML body with merge tags. |
text | string | Optional | Plain-text alternative. |
template_id | string | Optional | Stored template id (tpl_…) — used in place of html/text. |
recipients | array of objects | Required | Up to 5,000 entries. See Recipient object below. |
global_vars | object | Optional | Merge values shared by every recipient (e.g. campaign-wide URLs, brand name). Per-recipient vars override matching keys. |
tags | array of strings | Optional | Tags applied to every message in the batch (max 10). See Tags. |
headers | object | Optional | Custom 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_url | string (URL) | Optional | Per-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. |
attachments | array | Optional | File 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
to | string or array of strings | Required | The 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. |
name | string | Optional | Display name for this recipient. If set, the To header becomes "Name <email>". |
vars | object | Optional | Per-recipient merge variables, merged over global_vars (recipient wins on matching keys). Keys become available as {{key}} in the subject, HTML, and text. |
tags | array of strings | Optional | Extra 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"). |
metadata | object | Optional | Free-form key/value pairs stored with this recipient’s message — your internal IDs, A/B variant, etc. Not visible to the recipient. |
send_at | ISO 8601 timestamp | Optional | Schedule 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
| Status | Description |
|---|---|
201 | Created. { 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. |
400 | Structural 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. |
403 | Sandbox send to a recipient not on the confirmed test allow-list, or the account is suspended. |
404 | The template_id doesn't exist on your account. |
409 | A request with the same Idempotency-Key is still in flight. |
429 | Monthly/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.
{
"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 — a201means the batch is accepted, its quota reserved and everymessage_idassigned. - Optionally poll the batch. You already have every
message_idin the201, but you can also follow theLocationheader —GET /v3/batches/{batch_id}movesaccepted→processing→completed(orpartial/failed) and lists eachmessage_idwith its recipient and delivery status. See Get batch status.GET /v3/messages/{id}answers for every accepted id right away (asqueued), even before the worker has written the row. - Same semantics as before. Recipient validation,
rejected_recipientsaccounting, suppression checks, partial-fill, idempotency (Idempotency-Keyreplays the same201,batch_idand 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
503with error codeservice_busyand aRetry-Afterheader — 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
What they look like | string | Optional | Lowercase, alphanumeric + - _ :, max 128 chars. Examples: welcome, order-receipt, locale:en, variant:b. |
Where you set them | placement | Optional | On a single send, on a batch (applies to all), or per-recipient inside recipients[].tags. |
Where they show up | surfaces | Optional | Activity log filters, the Metrics dashboard, every webhook event under tags[], and the GET /v3/events?tag=… query. |
Tags vs. metadata | when to use which | Optional | Use 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. |
Limits | quotas | Optional | Max 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 -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" } }
]
}'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 },
})),
});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
],
)$wemail->messages->batch([
'from' => 'noreply@updates.acme.io',
'subject' => 'Hi {{first_name}}',
'html' => '<p>Your balance is {{balance}}</p>',
'recipients' => $recipients,
]);wemail.messages.batch(
from: "noreply@updates.acme.io",
subject: "Hi {{first_name}}",
html: "<p>Your balance is {{balance}}</p>",
recipients: recipients
)_, 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,
})wemail.messages().batch(BatchParams.builder()
.from("noreply@updates.acme.io")
.subject("Hi {{first_name}}")
.html("<p>Your balance is {{balance}}</p>")
.recipients(recipients).build());await wemail.Messages.BatchAsync(new BatchParams {
From = "noreply@updates.acme.io",
Subject = "Hi {{first_name}}",
Html = "<p>Your balance is {{balance}}</p>",
Recipients = recipients,
});{
"batch_id": "batch_01HBZK22N4DQ",
"accepted": 2,
"rejected": 0,
"message_ids": [
"msg_01HBZK22NB3Q…",
"msg_01HBZK22NB4R…"
]
}