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

Your First Integration

A complete walk-through: from zero to a sending service that validates requests, sends through a template, handles errors, and reacts to delivery events. This is the integration you'd put in production.

The shape of the work:

  1. Create a sender domain and verify DNS.
  2. Create an API key scoped to mail.send.
  3. Write a send helper that retries safely.
  4. Wire up a webhook endpoint to receive delivery events.
  5. Query the message on demand for status.

The examples use Python and TypeScript. curl is included for the critical setup step.

1. Verify your sender domain#

Live keys need a verified From: domain. If you haven't done this yet:

bash
1
2
3
4
curl -X POST https://scaisend.scailabs.ai/api/admin/domains \
  -H "Authorization: Bearer $SCAISEND_JWT" \
  -H "Content-Type: application/json" \
  -d '{"domain": "mail.example.com", "dmarc_policy": "quarantine", "dmarc_rua_email": "dmarc@example.com"}'

Publish the returned DNS records, then:

bash
1
2
curl -X POST https://scaisend.scailabs.ai/api/admin/domains/{id}/verify \
  -H "Authorization: Bearer $SCAISEND_JWT"

A verified: true response means you're good. See Sender Domains for the DNS record details.

2. Create a minimally scoped API key#

bash
1
2
3
4
curl -X POST https://scaisend.scailabs.ai/v3/api_keys \
  -H "Authorization: Bearer $SCAISEND_JWT" \
  -H "Content-Type: application/json" \
  -d '{"name": "transactional-prod", "environment": "live", "scopes": ["mail.send"]}'

Store the returned api_key in your secrets manager.

For CI, create a second key with environment: "test" and the same scope. Keep it in a different secret — test keys go to test environments, live keys go nowhere else.

3. A safe send helper#

Below is a send function that:

  • Authenticates with an API key.
  • Builds a dynamic-template send.
  • Retries on 429 and 5xx with exponential backoff.
  • Surfaces the message_id for later tracking.
  • Returns structured errors on 4xx validation failures.
python
 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import os
import time
import random
from typing import Any

import httpx

BASE_URL = "https://scaisend.scailabs.ai"
API_KEY = os.environ["SCAISEND_API_KEY"]


class ScaiSendError(Exception):
    def __init__(self, status: int, errors: list[dict[str, Any]] | str):
        self.status = status
        self.errors = errors
        super().__init__(f"ScaiSend {status}: {errors}")


def send_templated(
    *,
    template_id: str,
    from_email: str,
    from_name: str | None,
    to_email: str,
    dynamic_data: dict[str, Any],
    categories: list[str] | None = None,
    max_attempts: int = 4,
) -> str:
    body = {
        "personalizations": [
            {
                "to": [{"email": to_email}],
                "dynamic_template_data": dynamic_data,
            }
        ],
        "from": {"email": from_email, "name": from_name} if from_name else {"email": from_email},
        "template_id": template_id,
    }
    if categories:
        body["categories"] = categories

    for attempt in range(max_attempts):
        resp = httpx.post(
            f"{BASE_URL}/v3/mail/send",
            headers={
                "Authorization": f"Bearer {API_KEY}",
                "Content-Type": "application/json",
            },
            json=body,
            timeout=30,
        )
        if resp.status_code == 202:
            return resp.json()["message_id"]
        if resp.status_code == 429 or resp.status_code >= 500:
            if attempt == max_attempts - 1:
                break
            retry_after = int(resp.headers.get("Retry-After", "0"))
            delay = retry_after or (2 ** attempt) + random.random()
            time.sleep(delay)
            continue
        # 4xx — don't retry
        body_json = resp.json()
        raise ScaiSendError(resp.status_code, body_json.get("errors") or body_json.get("detail"))

    raise ScaiSendError(resp.status_code, resp.text)


if __name__ == "__main__":
    message_id = send_templated(
        template_id="d-welcome-email",
        from_email="hello@mail.example.com",
        from_name="Acme",
        to_email="ada@example.com",
        dynamic_data={"name": "Ada", "plan": "Pro"},
        categories=["welcome", "onboarding"],
    )
    print(f"sent {message_id}")
typescript
 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const BASE_URL = "https://scaisend.scailabs.ai";
const API_KEY = process.env.SCAISEND_API_KEY!;

class ScaiSendError extends Error {
  constructor(public status: number, public errors: unknown) {
    super(`ScaiSend ${status}: ${JSON.stringify(errors)}`);
  }
}

async function sendTemplated(args: {
  templateId: string;
  from: { email: string; name?: string };
  toEmail: string;
  dynamicData: Record<string, unknown>;
  categories?: string[];
  maxAttempts?: number;
}): Promise<string> {
  const maxAttempts = args.maxAttempts ?? 4;
  const body = {
    personalizations: [{ to: [{ email: args.toEmail }], dynamic_template_data: args.dynamicData }],
    from: args.from,
    template_id: args.templateId,
    ...(args.categories ? { categories: args.categories } : {}),
  };

  let last: Response | null = null;
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const resp = await fetch(`${BASE_URL}/v3/mail/send`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });
    last = resp;
    if (resp.status === 202) {
      const { message_id } = await resp.json();
      return message_id;
    }
    if (resp.status === 429 || resp.status >= 500) {
      if (attempt === maxAttempts - 1) break;
      const retryAfter = Number(resp.headers.get("Retry-After") ?? 0);
      const delay = (retryAfter || Math.pow(2, attempt) + Math.random()) * 1000;
      await new Promise((r) => setTimeout(r, delay));
      continue;
    }
    const data = await resp.json();
    throw new ScaiSendError(resp.status, data.errors ?? data.detail);
  }
  throw new ScaiSendError(last!.status, await last!.text());
}

Key choices:

  • Retry on 429 and 5xx only. 4xx errors mean your request is malformed or you don't have permission; retrying won't help.
  • Honor Retry-After if present. Falls back to exponential backoff + jitter.
  • Cap retries. Four attempts gives you roughly 15 seconds of backoff total; beyond that, fail loudly.
  • Treat 202 as the success signal. Delivery is async — 202 means the API accepted the message, not that it was delivered.

4. Receive delivery events#

The 202 response tells you "accepted." To learn whether the mail was actually delivered, bounced, opened, or clicked, subscribe a webhook endpoint.

Create the endpoint#

bash
1
2
3
4
5
6
7
curl -X POST https://scaisend.scailabs.ai/v3/user/webhooks \
  -H "Authorization: Bearer $SCAISEND_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/webhooks/scaisend",
    "enabled_events": ["processed", "delivered", "bounce", "spam_report", "unsubscribe"]
  }'

The response includes a signing_secret — save it. You'll use it to verify webhook signatures.

Handle incoming webhooks#

Every webhook request from ScaiSend carries three headers:

Header Purpose
X-ScaiSend-Signature HMAC-SHA256 over {timestamp}.{body}
X-ScaiSend-Timestamp Unix timestamp when the signature was computed
X-ScaiSend-Event The event type (delivered, bounce, etc.)

Verify the signature before trusting the payload:

python
 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
35
36
37
38
39
40
41
42
43
44
45
import hmac
import hashlib
import os
import time

from fastapi import FastAPI, Header, HTTPException, Request

SIGNING_SECRET = os.environ["SCAISEND_WEBHOOK_SECRET"]
app = FastAPI()


def verify(body: bytes, timestamp: str, signature: str) -> bool:
    # Reject requests older than 5 minutes
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = hmac.new(
        SIGNING_SECRET.encode(),
        f"{timestamp}.{body.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/webhooks/scaisend")
async def handle(
    request: Request,
    x_scaisend_signature: str = Header(...),
    x_scaisend_timestamp: str = Header(...),
    x_scaisend_event: str = Header(...),
):
    body = await request.body()
    if not verify(body, x_scaisend_timestamp, x_scaisend_signature):
        raise HTTPException(401, "Invalid signature")

    payload = await request.json()
    if x_scaisend_event == "delivered":
        # Mark as sent in your DB
        ...
    elif x_scaisend_event == "bounce":
        # Record bounce; ScaiSend already added the address to the suppression list
        ...
    elif x_scaisend_event in ("spam_report", "unsubscribe"):
        # Respect the opt-out
        ...
    return {"ok": True}
typescript
 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
35
36
import crypto from "node:crypto";
import express from "express";

const SIGNING_SECRET = process.env.SCAISEND_WEBHOOK_SECRET!;
const app = express();
app.use(express.raw({ type: "application/json" }));

function verify(body: Buffer, timestamp: string, signature: string): boolean {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(`${timestamp}.${body.toString()}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

app.post("/webhooks/scaisend", (req, res) => {
  const timestamp = req.header("X-ScaiSend-Timestamp")!;
  const signature = req.header("X-ScaiSend-Signature")!;
  const event = req.header("X-ScaiSend-Event")!;
  if (!verify(req.body, timestamp, signature)) {
    return res.status(401).json({ error: "bad signature" });
  }
  const payload = JSON.parse(req.body.toString());
  switch (event) {
    case "delivered":
      // ...
      break;
    case "bounce":
    case "spam_report":
    case "unsubscribe":
      // ...
      break;
  }
  res.json({ ok: true });
});

Return 2xx within 30 seconds or ScaiSend will retry with exponential backoff (1m, 5m, 15m, 1h, 2h). See Webhooks Deep Dive for retry policy details.

5. Query a single message#

Webhooks push; this endpoint pulls. Useful when a user opens a support ticket asking "did my receipt email arrive?":

python
1
2
3
4
5
6
7
8
resp = httpx.get(
    f"{BASE_URL}/v3/messages/{message_id}",
    headers={"Authorization": f"Bearer {API_KEY}"},
)
msg = resp.json()
print(msg["status"], len(msg["events"]), "events")
for e in msg["events"]:
    print(f"  {e['timestamp']} {e['event_type']}")

Or list all recent messages to a given address:

python
1
2
3
4
5
6
7
resp = httpx.get(
    f"{BASE_URL}/v3/messages",
    headers={"Authorization": f"Bearer {API_KEY}"},
    params={"to_email": "ada@example.com", "page_size": 50},
)
for msg in resp.json()["messages"]:
    print(msg["id"], msg["status"], msg["subject"])

See Messages and Events for the full filter list.

Production checklist#

Before pointing production traffic at ScaiSend:

  • Sender domain verified; DKIM, SPF, DMARC published.
  • API key scoped to mail.send only; stored in a secrets manager.
  • Separate test key for staging and CI.
  • Send helper retries 429 / 5xx, surfaces 4xx as errors.
  • Webhook endpoint receives delivered, bounce, spam_report, unsubscribe at minimum.
  • Webhook signature verification in place.
  • Webhook handler returns 2xx within 30s.
  • A dashboard or alert on daily bounce rate from GET /v3/stats.
  • Runbook entry for "user says email didn't arrive" (query GET /v3/messages).

What's next#

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