Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

Sending Mail

The POST /v3/mail/send endpoint is ScaiSend's send API. It takes the same request shape SendGrid's /v3/mail/send takes — personalizations, from, subject, content, attachments, templates, tracking settings, suppression groups — and returns 202 Accepted with a message ID.

Endpoint: POST /v3/mail/send Auth: API key or JWT, scope mail.send. Response: 202 with {"message_id": "..."} (single personalization) or {"message_ids": [...]} (multiple).

Basic send#

bash
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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", "name": "Ada"}]}
    ],
    "from": {"email": "hello@mail.example.com", "name": "Acme"},
    "subject": "Welcome to Acme",
    "content": [
      {"type": "text/plain", "value": "Welcome, Ada!"},
      {"type": "text/html", "value": "<h1>Welcome, Ada!</h1>"}
    ]
  }'
python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import os
import httpx

resp = 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", "name": "Ada"}]}
        ],
        "from": {"email": "hello@mail.example.com", "name": "Acme"},
        "subject": "Welcome to Acme",
        "content": [
            {"type": "text/plain", "value": "Welcome, Ada!"},
            {"type": "text/html", "value": "<h1>Welcome, Ada!</h1>"},
        ],
    },
)
resp.raise_for_status()
print(resp.json())
typescript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const resp = 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", name: "Ada" }] }
    ],
    from: { email: "hello@mail.example.com", name: "Acme" },
    subject: "Welcome to Acme",
    content: [
      { type: "text/plain", value: "Welcome, Ada!" },
      { type: "text/html", value: "<h1>Welcome, Ada!</h1>" }
    ],
  }),
});
const data = await resp.json();

Request fields (top-level)#

Field Type Required Notes
personalizations array Yes 1–1000 entries; each creates one message
from object Yes {email, name?}; email must use a verified sender domain (live mode)
reply_to object No {email, name?}
reply_to_list array No Multiple reply-to addresses
subject string Conditional Required unless each personalization sets one, or a template supplies it
content array Conditional Required unless template_id is set. Array of {type, value}; types are text/plain, text/html
template_id string Conditional Must start with d-. Mutually exclusive with content
attachments array No Max 10; total body size ≤ 20 MB
headers object No Custom headers; string → string
categories array No Up to 10 tags, each ≤ 255 chars
custom_args object No Arbitrary key-value metadata, preserved in message record and events
send_at integer No Unix timestamp; scheduled delivery. Requires mail.schedule scope
batch_id string No Group messages for GET /v3/mail/batch/{id} status aggregation
asm object No {group_id, groups_to_display?} — unsubscribe group membership
ip_pool_name string No Named outbound IP pool (if configured)
mail_settings object No sandbox_mode, bypass_*, footer
tracking_settings object No Open / click / subscription / GA tracking overrides

Personalizations#

Each entry in personalizations becomes exactly one message. If you have two entries, ScaiSend sends two emails (one per entry) and returns {"message_ids": [...]}.

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
  "personalizations": [
    {
      "to": [{"email": "ada@example.com", "name": "Ada"}],
      "cc": [{"email": "boss@example.com"}],
      "subject": "Welcome, Ada",
      "dynamic_template_data": {"name": "Ada", "plan": "Pro"},
      "custom_args": {"user_id": "u_123"}
    },
    {
      "to": [{"email": "bert@example.com", "name": "Bert"}],
      "dynamic_template_data": {"name": "Bert", "plan": "Free"}
    }
  ],
  "from": {"email": "hello@mail.example.com"},
  "template_id": "d-welcome"
}

Per-personalization fields:

Field Type Notes
to array Required, 1–1000 recipients
cc array Optional
bcc array Optional
subject string Overrides top-level subject
headers object Overrides top-level headers
substitutions object Legacy SendGrid key-value substitutions (use dynamic_template_data for modern templates)
dynamic_template_data object Data passed to the template engine
custom_args object Overrides top-level custom_args
send_at integer Per-personalization schedule override

Use one personalization per recipient when each recipient should get different data. Use multiple recipients in one personalization's to when they're all getting the same message.

Templates#

Skip content and set template_id to send a dynamic-template email:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "personalizations": [
    {
      "to": [{"email": "ada@example.com"}],
      "dynamic_template_data": {"name": "Ada", "order": {"total": 42.99}}
    }
  ],
  "from": {"email": "hello@mail.example.com"},
  "template_id": "d-receipt"
}

The active version of d-receipt renders with dynamic_template_data as its context. See Templates for the full template workflow.

Attachments#

Attachments are base64-encoded in the JSON body. Max 10 per message; total body size ≤ 20 MB.

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "personalizations": [{"to": [{"email": "ada@example.com"}]}],
  "from": {"email": "hello@mail.example.com"},
  "subject": "Your invoice",
  "content": [{"type": "text/plain", "value": "See attached."}],
  "attachments": [
    {
      "content": "JVBERi0xLjQK...",
      "filename": "invoice-042.pdf",
      "type": "application/pdf",
      "disposition": "attachment"
    }
  ]
}
Attachment field Required Notes
content Yes Base64-encoded file bytes
filename Yes 1–255 chars
type No MIME type; defaults to application/octet-stream
disposition No attachment (default) or inline
content_id No Required for disposition: inline; used to reference from HTML (<img src="cid:...">)

For inline images (a logo embedded in an HTML signature, for example):

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "attachments": [
    {
      "content": "iVBORw0KGgo...",
      "filename": "logo.png",
      "type": "image/png",
      "disposition": "inline",
      "content_id": "logo"
    }
  ]
}

Then reference in HTML: <img src="cid:logo" />.

See Attachments and Images for the full discussion including the image library approach (preferred when reusing images across sends).

Scheduled delivery#

Set send_at to a future Unix timestamp:

json
1
2
3
4
5
6
7
{
  "personalizations": [{"to": [{"email": "ada@example.com"}]}],
  "from": {"email": "hello@mail.example.com"},
  "subject": "Weekly digest",
  "content": [{"type": "text/plain", "value": "..."}],
  "send_at": 1714060800
}

The message is accepted immediately (202), sits in QUEUED state until the send_at time, then proceeds through rendering and delivery. Requires mail.schedule scope. Cancel before it sends with POST /v3/messages/{id}/cancel (requires mail.cancel).

Categories#

Categories are arbitrary tags you attach for reporting. A message can have up to 10.

json
1
2
3
{
  "categories": ["transactional", "receipt", "onboarding"]
}

Then query stats by category:

bash
1
2
curl "https://scaisend.scailabs.ai/v3/stats/categories?start_date=2026-04-01&categories=receipt" \
  -H "Authorization: Bearer $SCAISEND_API_KEY"

Keep the category vocabulary small and stable — ad-hoc tags per send make stats useless.

Custom args#

custom_args (top-level or per-personalization) is arbitrary JSON metadata. It's stored on the message record, returned on GET /v3/messages/{id}, and included in every event webhook that references the message. Use it for your own IDs (user ID, order ID, campaign ID) so you can correlate ScaiSend events back to your system.

json
1
2
3
4
5
6
7
{
  "custom_args": {
    "user_id": "u_12345",
    "order_id": "ord_67890",
    "experiment": "subject_line_a"
  }
}

Tracking settings#

Per-request override of tenant defaults. See Tracking for the full discussion.

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "tracking_settings": {
    "click_tracking": {"enable": true, "enable_text": false},
    "open_tracking": {"enable": true},
    "subscription_tracking": {"enable": true, "substitution_tag": "[unsubscribe_url]"},
    "ganalytics": {
      "enable": true,
      "utm_source": "scaisend",
      "utm_medium": "email",
      "utm_campaign": "april_welcome"
    }
  }
}

Mail settings#

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "mail_settings": {
    "sandbox_mode": {"enable": true},
    "bypass_list_management": {"enable": false},
    "bypass_unsubscribe_management": {"enable": false},
    "bypass_bounce_management": {"enable": false},
    "bypass_spam_management": {"enable": false},
    "footer": {
      "enable": true,
      "text": "Acme Inc., 123 Main St.",
      "html": "<p><small>Acme Inc., 123 Main St.</small></p>"
    }
  }
}

See Sandbox vs Live for sandbox_mode and Suppressions for the bypass flags.

Response#

Single personalization#

HTTP 202 Accepted:

json
1
{"message_id": "msg_01HXYZABC123"}

Multiple personalizations#

HTTP 202 Accepted:

json
1
{"message_ids": ["msg_01HXYZABC123", "msg_01HXYZABC124", "msg_01HXYZABC125"]}

Track each with GET /v3/messages/{id}.

Error cases#

Code Response Cause
400 errors[] with field: "template_id" Template ID doesn't start with d-
400 errors[] with field: "personalizations" Empty, too many, or missing required nested fields
400 errors[] with field: "from" Invalid from address
400 errors[] with field: "content" or field: "template_id" Both supplied (mutually exclusive), or neither
400 errors[] with field: "attachments[N]" Base64 decode failed, attachment too large, wrong MIME
401 {"detail": "Invalid API key"} Key revoked, deleted, or typo'd
403 {"detail": "Missing required scope: mail.send"} Key or user role lacks the scope
403 {"detail": "Sender domain not verified"} from.email domain isn't a verified sender for this tenant
413 {"detail": "Request body exceeds maximum size of 20 MB"} JSON body over 20 MB (usually attachments)
422 Pydantic validation detail Schema mismatch
429 {"detail": "Rate limit exceeded"} Per-tenant rate limit hit; honor Retry-After

Batch status#

When sending many messages with the same batch_id:

bash
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Create a batch ID
curl -X POST https://scaisend.scailabs.ai/v3/mail/batch \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
# -> {"batch_id": "bat_01HXYZ"}

# Include batch_id in subsequent sends
# ...

# Aggregate status
curl https://scaisend.scailabs.ai/v3/mail/batch/bat_01HXYZ \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "batch_id": "bat_01HXYZ",
  "total_messages": 4872,
  "status_counts": {
    "queued": 12,
    "processing": 8,
    "sent": 3200,
    "delivered": 1600,
    "bounced": 50,
    "failed": 2,
    "cancelled": 0
  },
  "created_at": "2026-04-23T09:00:00Z",
  "completed_at": null,
  "is_complete": false
}

See Scheduling and Batches for the batching workflow.

What's next#

Updated 2026-05-17 01:33:26 View source (.md) rev 1