---
title: Your First Integration
path: tutorials/first-integration
status: published
---

# 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
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
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](../concepts/sender-domains) for the DNS record details.

## 2. Create a minimally scoped API key

```bash
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
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
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
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
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
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](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
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
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](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

- [Sending Mail](sending-mail) — every field of `/v3/mail/send`.
- [Webhooks Deep Dive](webhooks-deep-dive) — retries, signatures, failure modes.
- [Errors](../concepts/errors) — the full error shape reference.
