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:
| 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
}'
|
1
2
3
4
5
6
7
8
9
10
11
12
13 | 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
},
)
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14 | 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:
| 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:
| {"message_id": "msg_01HXYZ", "status": "cancelled", "message": "Cancelled before delivery"}
|
or:
| {"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
| curl -X POST https://scaisend.scailabs.ai/v3/mail/batch \
-H "Authorization: Bearer $SCAISEND_API_KEY"
|
| 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"]
|
| 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:
| {"batch_id": "bat_01HXYZ"}
|
Include on sends
Every subsequent /v3/mail/send that belongs in the batch includes the batch ID:
| {
"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
| curl https://scaisend.scailabs.ai/v3/mail/batch/bat_01HXYZ \
-H "Authorization: Bearer $SCAISEND_API_KEY"
|
Response:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 | {
"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:
| 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 | 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 | 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 | 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