Sending at volume
Batches, rate limits, and what to do when you are throttled.
One message per request is fine for a password reset. It is the wrong shape for ten thousand receipts.
Batches
POST /api/v1/messages/batch takes one shared template and a list of recipients, each with their
own variables. Up to 500 recipients per call.
curl -X POST "$MAILYTE_BASE/api/v1/messages/batch" \
-H "Authorization: Bearer $MAILYTE_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"from": "hello@yourdomain.com",
"template_id": "01JBT8XQ2M9WYC3K4F6R7S8T9V",
"messages": [
{ "to": "ada@example.com", "variables": { "first_name": "Ada" } },
{ "to": "grace@example.com", "variables": { "first_name": "Grace" } }
]
}'A partial failure is not an error
This is the thing to get right, and it is the opposite of what the status code suggests.
Every recipient is processed independently. One suppressed address does not stop the other 499 —
and the call still returns 200.
{
"data": {
"sent": 1,
"failed": 1,
"results": [
{ "to": "ada@example.com", "message_id": "<01JBT8XQ2M@yourdomain.com>" },
{ "to": "grace@example.com", "error": "Recipient is suppressed." }
]
},
"success": true,
"code": 200
}Check failed, not the status code. Code that branches on response.ok alone will silently
drop mail it believes it sent, and you will find out when a customer asks where their invoice is.
const { data } = await response.json();
if (data.failed > 0) {
const problems = data.results.filter((r) => r.error);
logger.warn({ problems }, 'some recipients were not accepted');
}Rate limits
Three separate limits apply, and they exist for different reasons:
| Limit | Scope | Why |
|---|---|---|
| Request rate | Per API key | Protects the API itself |
| Sending rate | Per mailbox and per organization | Protects deliverability — a sudden spike looks like a compromised account to receiving servers |
| Allowance | Per organization, per day | Your plan, plus any purchased credits |
Sending limits count recipients, not messages. A batch of 200 uses 200 of your allowance, not one. This catches people out when they move from single sends to batches and their daily numbers change shape overnight.
Handling a 429
A throttled request returns 429 with Retry-After in seconds. Honour it — it is the real number,
and guessing means either wasted requests or an unnecessarily slow queue.
import time, requests
def send(payload, attempt=0):
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 429:
if attempt >= 5:
raise RuntimeError("giving up after 5 attempts")
wait = int(response.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait)
return send(payload, attempt + 1)
response.raise_for_status()
return response.json()["data"]Do not retry a 422. It is a problem with the request, and sending it again unchanged produces the
same answer while spending your rate budget.
Warming up
A brand-new sending domain or dedicated IP has no reputation, and receiving servers treat unknown senders that suddenly emit large volume exactly as they treat a compromised account. So new senders ramp: the allowance rises over days as delivery stays healthy.
If your first large send is slower than you expected, that is the system working rather than failing. Plan a launch around it instead of against it.
Transactional or marketing
If you are sending to a list, use campaigns rather than batches. Campaigns add unsubscribe handling, a review step and reporting that the batch endpoint deliberately does not, and marketing mail rides a different egress path so a newsletter cannot damage the reputation carrying your password resets.
Batch sending is for transactional mail that happens to go to many people at once — a receipt run, a status notification. The distinction is not bureaucratic: filing a marketing send as transactional is how a whole domain's reputation gets spent.
Next
Handling delivery events — knowing what actually happened to all of them.