Guides

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.