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:
- Create a sender domain and verify DNS.
- Create an API key scoped to
mail.send.
- Write a send helper that retries safely.
- Wire up a webhook endpoint to receive delivery events.
- 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:
| 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:
| 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
| 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.
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}")
|
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
| 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:
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}
|
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?":
| 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:
| 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:
What's next