---
title: Authentication
path: concepts/authentication
status: published
---

# Authentication

ScaiSend accepts two kinds of credentials: **API keys** (for servers and applications) and **JWTs** (for humans in the admin UI). Both go in the `Authorization: Bearer <token>` header. The server works out which one it is and validates accordingly.

## API keys

Use for any server-to-server integration — production sends, cron jobs, background workers. API keys are scoped to a tenant, have a configurable permission set, and don't expire unless you set an `expires_at`.

### Format

```
sg_live_<32-hex-chars>    ← production, delivers real mail
sg_test_<32-hex-chars>    ← sandbox, validates but never delivers
```

The full secret is shown exactly once — when you create the key. After that, only the first 12 characters (the "prefix") are retrievable. Rotate if you lose it.

### Creating a key

```bash
curl -X POST https://scaisend.scailabs.ai/v3/api_keys \
  -H "Authorization: Bearer $SCAISEND_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "production-sender",
    "environment": "live",
    "scopes": ["mail.send", "mail.schedule", "templates.read", "stats.read"]
  }'
```

```python
import httpx, os

resp = httpx.post(
    "https://scaisend.scailabs.ai/v3/api_keys",
    headers={"Authorization": f"Bearer {os.environ['SCAISEND_JWT']}"},
    json={
        "name": "production-sender",
        "environment": "live",
        "scopes": ["mail.send", "mail.schedule", "templates.read", "stats.read"],
    },
)
print(resp.json()["api_key"])  # sg_live_... — store this
```

```typescript
const resp = await fetch("https://scaisend.scailabs.ai/v3/api_keys", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.SCAISEND_JWT}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "production-sender",
    environment: "live",
    scopes: ["mail.send", "mail.schedule", "templates.read", "stats.read"],
  }),
});
const data = await resp.json();
console.log(data.api_key); // sg_live_...
```

Response:

```json
{
  "id": "key_01HXYZ...",
  "name": "production-sender",
  "api_key": "sg_live_a1b2c3d4e5f6...",
  "prefix": "sg_live_a1b2c3d4",
  "environment": "live",
  "scopes": ["mail.send", "mail.schedule", "templates.read", "stats.read"],
  "created_at": "2026-04-23T10:00:00Z"
}
```

Store the `api_key` value securely — a secrets manager, a Kubernetes secret, a `.env` outside version control. It gives the bearer full `mail.send` permission on your tenant.

### Using the key

```bash
curl https://scaisend.scailabs.ai/v3/templates \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

The same header format works for every `/v3/` endpoint.

### Rotating and revoking

```bash
# Rotate — issues a new secret, revokes the old one
curl -X POST https://scaisend.scailabs.ai/v3/api_keys/key_01HXYZ/regenerate \
  -H "Authorization: Bearer $SCAISEND_JWT"

# Revoke
curl -X DELETE https://scaisend.scailabs.ai/v3/api_keys/key_01HXYZ \
  -H "Authorization: Bearer $SCAISEND_JWT"
```

Rotations and revocations take effect immediately. Any in-flight requests using the old key complete; subsequent requests with the old key return `401`.

## JWTs

Use for anything that's driven by a human — the admin UI, `curl` calls during development, scripts run by operators. JWTs carry the user's identity and their permissions; they expire (typically after an hour) and are refreshed out of band.

### Getting a JWT

JWTs come from [ScaiKey](architecture#scaikey-integration) via OAuth. The admin UI handles the flow; for programmatic access, the `/v3/auth/*` endpoints expose it:

```bash
# 1. Initiate OAuth — returns a redirect URL
curl -X POST https://scaisend.scailabs.ai/v3/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com"}'

# 2. User completes OAuth in browser, gets redirected with ?code=...

# 3. Exchange the code for tokens
curl -X POST https://scaisend.scailabs.ai/v3/auth/callback \
  -H "Content-Type: application/json" \
  -d '{"code": "...", "state": "..."}'
```

Response:

```json
{
  "access_token": "eyJhbGc...",
  "refresh_token": "rtk_...",
  "expires_in": 3600,
  "user": {
    "id": "usr_...",
    "email": "you@example.com",
    "tenant_id": "tnt_..."
  }
}
```

### Refreshing

```bash
curl -X POST https://scaisend.scailabs.ai/v3/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "rtk_..."}'
```

Refresh before `access_token` expires. ScaiSend validates JWTs against `SCAIKEY_JWKS_URL` on every request — there's no local cache that will hold stale tokens for you.

### Current user info

```bash
curl https://scaisend.scailabs.ai/v3/auth/me \
  -H "Authorization: Bearer $SCAISEND_JWT"
```

Returns the user, their permissions, their tenant, and their role.

## API key vs JWT — when to use which

| Use case | Credential |
|----------|-----------|
| Production email sends from your app | API key (`sg_live_*`) |
| CI/CD integration tests | API key (`sg_test_*`) |
| Cron jobs that call the API | API key |
| Admin UI (web) | JWT |
| Your developer running `curl` at a terminal | JWT |
| A user managing their own API keys | JWT |
| A user managing their own webhook endpoints | JWT |
| Anything creating API keys | JWT (API keys can't create other keys) |

**Never put a JWT in an always-on server process.** They expire hourly; you don't want a refresh loop in your send code. Use an API key.

**Never put an API key in browser-delivered JavaScript.** It leaks to anyone who opens DevTools. If you need a browser to call ScaiSend, proxy the call through your backend.

## Permission scopes

Every API key and every user role carries a set of permission scopes. Each endpoint declares the scope it requires. If your credential doesn't have it, you get `403 Forbidden`.

The full list:

| Scope | What it allows |
|-------|----------------|
| `mail.send` | `POST /v3/mail/send` |
| `mail.schedule` | `send_at` in the past or future; batch operations |
| `mail.cancel` | Cancel a queued or processing message |
| `templates.read` | `GET /v3/templates*` |
| `templates.write` | `POST` / `PATCH` on templates and versions |
| `templates.delete` | Delete templates or versions |
| `suppressions.read` | List bounces, spam reports, unsubscribes, groups |
| `suppressions.write` | Add/remove suppressions, create/edit groups |
| `stats.read` | `/v3/stats*` |
| `stats.export` | Export stats data |
| `webhooks.read` | List webhook endpoints and event settings |
| `webhooks.write` | Create/update/delete webhook endpoints |
| `domains.read` | List sender domains |
| `domains.write` | Add domains, verify DNS, rotate DKIM |
| `admin.api_keys` | Create, update, revoke API keys |
| `admin.users` | Manage user-to-role assignments |
| `admin.settings` | Edit tenant settings (tracking, defaults) |

Keep API keys minimally scoped. A production send key only needs `mail.send`; a reporting job only needs `stats.read`. See [Roles and Permissions](roles-and-permissions) for the role-based view.

## Test-key sandbox

Any request made with a test key (`sg_test_*`) is automatically in sandbox mode — the request is fully validated, the template is rendered, the message is written to the database with status `sandbox`, but nothing is handed to the SMTP service. The response looks identical to a live send.

You can also force sandbox on a live key per-request:

```json
{
  "mail_settings": {
    "sandbox_mode": {"enable": true}
  }
}
```

Both mechanisms exist deliberately. Use test keys in environments that should never send real mail (staging, CI). Use `sandbox_mode` when testing an integration against a live key.

See [Sandbox vs Live](sandbox-vs-live) for the full semantics.

## Common failure modes

| Response | Likely cause |
|----------|--------------|
| `401 Unauthorized` with `{"detail": "Missing Authorization header"}` | No `Authorization` header. |
| `401 Unauthorized` with `{"detail": "Invalid API key"}` | Key was revoked, deleted, or typo'd. Check with `GET /v3/api_keys`. |
| `401 Unauthorized` with `{"detail": "JWT expired"}` | Refresh your token. |
| `403 Forbidden` with `{"detail": "Missing required scope: mail.send"}` | Add the scope to the key or use a different key. |
| `403 Forbidden` with tenant-mismatch detail | You're calling an endpoint for a tenant your credential doesn't belong to. |

## What's next

- [Your First Integration](../tutorials/first-integration) — a real send loop with retries and event handling.
- [Roles and Permissions](roles-and-permissions) — the full RBAC model.
- [API Keys Reference](../reference/api-keys) — complete endpoint list.
