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

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#

scdoc
1
2
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
1
2
3
4
5
6
7
8
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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
1
2
3
4
5
6
7
8
9
{
  "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
1
2
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
1
2
3
4
5
6
7
# 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 via OAuth. The admin UI handles the flow; for programmatic access, the /v3/auth/* endpoints expose it:

bash
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "access_token": "eyJhbGc...",
  "refresh_token": "rtk_...",
  "expires_in": 3600,
  "user": {
    "id": "usr_...",
    "email": "you@example.com",
    "tenant_id": "tnt_..."
  }
}

Refreshing#

bash
1
2
3
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
1
2
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 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
1
2
3
4
5
{
  "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 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#

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