Authentication
The Spelo API has two auth modes, used by different callers:
| Caller | Auth mode | Where the secret lives |
|---|---|---|
Dashboard (app.spelo.ai) | Supabase JWT cookie | Browser (HttpOnly cookie) |
| Your backend / scripts | API key (Bearer) | Your server env |
| Widget (in visitors’ browsers) | Ephemeral token + HMAC signature | Issued per-session |
Most customers only ever use API keys directly. The widget handles its own auth automatically.
API keys
API keys are per-workspace. Generate them in the dashboard:
- Settings → API keys → Create key
- Pick scopes:
sites:read— read your site configssites:write— create/update/delete sitesquery:test— call/query(for debugging)analytics:read— read usage metricswebhooks:write— configure Stripe / usage webhooks
- Create
Copy the key — it’s shown once and never again. Keys start with vk_live_ (production) or vk_test_ (test mode).
Using an API key
curl -X GET https://api.spelo.ai/v1/sites \ -H "Authorization: Bearer vk_live_xxxxxxxxxxxxxxxxxxxx"In code:
const res = await fetch('https://api.spelo.ai/v1/sites', { headers: { Authorization: `Bearer ${process.env.SPELO_API_KEY}`, },})const { data } = await res.json()import os, requests
r = requests.get( 'https://api.spelo.ai/v1/sites', headers={'Authorization': f'Bearer {os.environ["SPELO_API_KEY"]}'},)r.raise_for_status()import axios from 'axios'
const vk = axios.create({ baseURL: 'https://api.spelo.ai/v1', headers: { Authorization: `Bearer ${process.env.SPELO_API_KEY}` },})
const { data } = await vk.get('/sites')Rotating keys
Rate limits on auth
Auth endpoints are rate-limited to prevent brute force:
| Endpoint | Per-IP limit |
|---|---|
POST /v1/auth/signin | 20/min |
POST /v1/auth/signup | 5/min |
POST /v1/auth/magic-link | 5/min |
POST /v1/auth/verify | 10/min |
Exceeded: 429 Too Many Requests with a Retry-After header indicating seconds to wait.
Handling 429 — retry with exponential backoff
When you hit a 429, honor the Retry-After header (a server hint based on actual remaining quota). If absent, fall back to exponential backoff: 1s → 2s → 4s → 8s, capped at 30s, max 5 attempts.
async function speloFetch(url: string, init: RequestInit = {}) { const MAX_ATTEMPTS = 5; for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { const res = await fetch(url, { ...init, headers: { Authorization: `Bearer ${process.env.SPELO_API_KEY}`, ...init.headers, }, }); if (res.status !== 429) return res;
const retryAfter = Number(res.headers.get('Retry-After')); const wait = Number.isFinite(retryAfter) ? retryAfter * 1000 : Math.min(1000 * 2 ** attempt, 30_000); await new Promise((r) => setTimeout(r, wait)); } throw new Error('Rate limited: gave up after 5 attempts');}import os, time, requests
def spelo_request(method: str, url: str, **kwargs) -> requests.Response: headers = {'Authorization': f'Bearer {os.environ["SPELO_API_KEY"]}'} headers.update(kwargs.pop('headers', {})) for attempt in range(5): r = requests.request(method, url, headers=headers, **kwargs) if r.status_code != 429: return r wait = int(r.headers.get('Retry-After', min(2 ** attempt, 30))) time.sleep(wait) raise RuntimeError('Rate limited: gave up after 5 attempts')import axios from 'axios'
const spelo = axios.create({ baseURL: 'https://api.spelo.ai/v1', headers: { Authorization: `Bearer ${process.env.SPELO_API_KEY}` },})
spelo.interceptors.response.use(undefined, async (err) => { const cfg = err.config if (err.response?.status !== 429) throw err cfg.__retry = (cfg.__retry ?? 0) + 1 if (cfg.__retry > 5) throw err const wait = Number(err.response.headers['retry-after']) * 1000 || Math.min(1000 * 2 ** (cfg.__retry - 1), 30_000) await new Promise((r) => setTimeout(r, wait)) return spelo.request(cfg)})Response envelope
All authenticated endpoints return a consistent envelope:
{ "success": true, "data": { /* endpoint-specific payload */ }, "meta": { "request_id": "req_abc123", "ts": "2026-04-17T12:34:56.789Z" }}On error:
{ "success": false, "error": { "code": "site_not_found", "message": "No site with id abc123 in your workspace", "details": {} }, "meta": { "request_id": "req_abc123", "ts": "..." }}Always check success before reading data.
Error codes
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | invalid_request | Body failed schema validation (details has the specific field) |
| 401 | unauthorized | Missing, malformed, or revoked API key |
| 403 | forbidden | Key valid but lacks the required scope |
| 404 | not_found | Resource does not exist or isn’t yours |
| 409 | conflict | E.g. creating a site with a duplicate site_id |
| 422 | validation_failed | Business-rule rejection (e.g. unreachable DB) |
| 429 | rate_limited | Back off and retry with exponential delay |
| 500 | internal_error | Bug on our side — file at github.com/spelo/spelo/issues |
| 503 | service_unavailable | Transient — retry with backoff |
Every error includes a request_id — quote it when opening a support ticket.
Widget auth (how it really works)
You don’t implement this — the widget does. But it’s useful to understand:
- Widget loads, reads
data-site-idfrom its<script>tag - Widget calls
GET /v1/:siteId/configwith Origin header - API checks Origin against registered domains; returns config
- User clicks orb
- Widget calls
POST /v1/:siteId/token - API mints an ephemeral OpenAI session token + a 32-byte signing secret
- Widget uses the ephemeral token to open WebRTC directly with OpenAI
- Every
POST /v1/:siteId/queryincludesX-Spelo-Signature(HMAC-SHA256 of body with the signing secret) - API verifies HMAC, Origin, and rate limit
If someone copies your <script> tag to another domain, step 3 fails and the widget never loads.
See also
- Sites API — CRUD on your sites
- Token endpoint — what the widget calls
- Query endpoint — what the AI’s
search_databasefunction calls - Webhooks — subscribe to events