---
title: Scheduling and Batches
path: tutorials/scheduling-and-batches
status: published
---

# Scheduling and Batches

Two related features for coordinating multi-message sends: **scheduled delivery** (send later) and **batches** (group many messages under a single ID for aggregate reporting and collective cancellation).

## Scheduled delivery

Set `send_at` to a future Unix timestamp on `/v3/mail/send`:

```bash
curl -X POST https://scaisend.scailabs.ai/v3/mail/send \
  -H "Authorization: Bearer $SCAISEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "personalizations": [{"to": [{"email": "ada@example.com"}]}],
    "from": {"email": "hello@mail.example.com"},
    "subject": "Weekly digest",
    "content": [{"type": "text/plain", "value": "..."}],
    "send_at": 1714060800
  }'
```

```python
import os, time, httpx

httpx.post(
    "https://scaisend.scailabs.ai/v3/mail/send",
    headers={"Authorization": f"Bearer {os.environ['SCAISEND_API_KEY']}"},
    json={
        "personalizations": [{"to": [{"email": "ada@example.com"}]}],
        "from": {"email": "hello@mail.example.com"},
        "subject": "Weekly digest",
        "content": [{"type": "text/plain", "value": "..."}],
        "send_at": int(time.time()) + 86400,  # 24 hours from now
    },
)
```

```typescript
await fetch("https://scaisend.scailabs.ai/v3/mail/send", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.SCAISEND_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    personalizations: [{ to: [{ email: "ada@example.com" }] }],
    from: { email: "hello@mail.example.com" },
    subject: "Weekly digest",
    content: [{ type: "text/plain", value: "..." }],
    send_at: Math.floor(Date.now() / 1000) + 86400,
  }),
});
```

**Behavior:**

- The message is accepted immediately (`202`). A `message_id` is returned.
- The message sits in status `QUEUED` until `send_at` arrives.
- At `send_at`, the message enters the normal render → DKIM → SMTP pipeline.
- **Requires the `mail.schedule` scope.** A key without it gets `403`.

**Granularity:** seconds. `send_at` values in the past (or within a few seconds of now) are treated as "send now."

**Per-personalization scheduling** is supported — you can schedule different personalizations at different times using `send_at` inside each personalization block. Top-level `send_at` applies to any personalization that doesn't override.

## Cancelling a scheduled send

Before the message leaves `QUEUED`:

```bash
curl -X POST https://scaisend.scailabs.ai/v3/messages/msg_01HXYZ/cancel \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

Requires the `mail.cancel` scope. The message transitions to status `CANCELLED` and is not sent.

If the message has already entered `PROCESSING` (the worker has started rendering it), cancellation may or may not succeed — it races with the send pipeline. The response will tell you:

```json
{"message_id": "msg_01HXYZ", "status": "cancelled", "message": "Cancelled before delivery"}
```

or:

```json
{"message_id": "msg_01HXYZ", "status": "sending", "message": "Already in flight; cancellation ignored"}
```

## Batches

A **batch** is a group of messages tagged with a shared `batch_id`. Batches let you:

- Query aggregate status across many messages with one request.
- Cancel an in-flight batch by cancelling each message (the API doesn't batch-cancel, but you can iterate by batch_id).

### Create a batch ID

```bash
curl -X POST https://scaisend.scailabs.ai/v3/mail/batch \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

```python
resp = httpx.post(
    "https://scaisend.scailabs.ai/v3/mail/batch",
    headers={"Authorization": f"Bearer {os.environ['SCAISEND_API_KEY']}"},
)
batch_id = resp.json()["batch_id"]
```

```typescript
const resp = await fetch("https://scaisend.scailabs.ai/v3/mail/batch", {
  method: "POST",
  headers: { "Authorization": `Bearer ${process.env.SCAISEND_API_KEY}` },
});
const { batch_id } = await resp.json();
```

Response:

```json
{"batch_id": "bat_01HXYZ"}
```

### Include on sends

Every subsequent `/v3/mail/send` that belongs in the batch includes the batch ID:

```json
{
  "personalizations": [{"to": [{"email": "ada@example.com"}]}],
  "from": {"email": "hello@mail.example.com"},
  "subject": "Campaign message",
  "content": [{"type": "text/plain", "value": "..."}],
  "batch_id": "bat_01HXYZ"
}
```

The batch_id is stored on each message. Multiple tenants cannot share a batch — batch_ids are tenant-scoped.

### Aggregate status

```bash
curl https://scaisend.scailabs.ai/v3/mail/batch/bat_01HXYZ \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

Response:

```json
{
  "batch_id": "bat_01HXYZ",
  "total_messages": 4872,
  "status_counts": {
    "queued": 12,
    "processing": 8,
    "sent": 3200,
    "delivered": 1600,
    "bounced": 50,
    "failed": 2,
    "cancelled": 0,
    "sandbox": 0
  },
  "created_at": "2026-04-23T09:00:00Z",
  "completed_at": null,
  "is_complete": false
}
```

`is_complete` flips to `true` when every message has reached a terminal state (`delivered`, `bounced`, `failed`, `cancelled`, or `sandbox`).

### Listing messages in a batch

Batch status is an aggregate view. To see individual messages:

```bash
curl "https://scaisend.scailabs.ai/v3/messages?batch_id=bat_01HXYZ&page_size=100" \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

Paginate through the full list. Use `status` to filter — e.g., `?batch_id=bat_01HXYZ&status=bounced` to see only bounced messages.

### Cancelling a batch

There's no single "cancel batch" endpoint. Cancel each message in the batch that's still `QUEUED` or `PROCESSING`:

```python
import os, httpx

api_key = os.environ["SCAISEND_API_KEY"]
base = "https://scaisend.scailabs.ai"
headers = {"Authorization": f"Bearer {api_key}"}

# List still-cancellable messages in the batch
page = 1
cancelled = 0
while True:
    resp = httpx.get(
        f"{base}/v3/messages",
        headers=headers,
        params={"batch_id": "bat_01HXYZ", "status": "queued", "page": page, "page_size": 100},
    )
    messages = resp.json()["messages"]
    if not messages:
        break
    for msg in messages:
        cancel = httpx.post(f"{base}/v3/messages/{msg['id']}/cancel", headers=headers)
        if cancel.status_code == 200:
            cancelled += 1
    if not resp.json()["messages"]:
        break
    page += 1
print(f"Cancelled {cancelled} messages.")
```

Messages already in flight (`SENDING`) can't be cancelled. This is physics: the SMTP conversation has started.

## Common patterns

### Send a daily digest to all subscribers

```python
import os, time, httpx

api_key = os.environ["SCAISEND_API_KEY"]
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}

# 1. Create a batch
batch_id = httpx.post(
    "https://scaisend.scailabs.ai/v3/mail/batch", headers=headers
).json()["batch_id"]

# 2. Send to each subscriber; all tagged with batch_id
for user in subscribers:
    httpx.post(
        "https://scaisend.scailabs.ai/v3/mail/send",
        headers=headers,
        json={
            "personalizations": [
                {
                    "to": [{"email": user.email}],
                    "dynamic_template_data": {"name": user.name, "digest": user.digest_data},
                }
            ],
            "from": {"email": "digest@mail.example.com"},
            "template_id": "d-daily-digest",
            "batch_id": batch_id,
            "categories": ["digest", "daily"],
        },
    )

# 3. Check status
resp = httpx.get(
    f"https://scaisend.scailabs.ai/v3/mail/batch/{batch_id}", headers=headers
)
print(resp.json()["status_counts"])
```

### Schedule a time-zone-respecting marketing send

Iterate users, calculate each user's 10 AM local time, and send each with a per-personalization `send_at`:

```python
import os, httpx
from datetime import datetime
import zoneinfo

api_key = os.environ["SCAISEND_API_KEY"]

for user in subscribers:
    local = zoneinfo.ZoneInfo(user.timezone)
    next_ten_am = datetime.now(local).replace(hour=10, minute=0, second=0, microsecond=0)
    send_at = int(next_ten_am.timestamp())

    httpx.post(
        "https://scaisend.scailabs.ai/v3/mail/send",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        json={
            "personalizations": [{"to": [{"email": user.email}]}],
            "from": {"email": "hello@mail.example.com"},
            "template_id": "d-promo",
            "send_at": send_at,
        },
    )
```

Every recipient gets the message at 10 AM their time. All scheduled at once; delivery staggered by ScaiSend's scheduler.

## What's next

- [Sending Mail](sending-mail) — the full `/v3/mail/send` contract.
- [Messages and Events](messages-and-events) — querying what happened to batched messages.
- [Mail Send Reference](../reference/mail-send) — endpoint-level field reference.
