Skip to content
GitHub
Get started →

Authentication

The Spelo API has two auth modes, used by different callers:

CallerAuth modeWhere the secret lives
Dashboard (app.spelo.ai)Supabase JWT cookieBrowser (HttpOnly cookie)
Your backend / scriptsAPI key (Bearer)Your server env
Widget (in visitors’ browsers)Ephemeral token + HMAC signatureIssued 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:

  1. SettingsAPI keysCreate key
  2. Pick scopes:
    • sites:read — read your site configs
    • sites:write — create/update/delete sites
    • query:test — call /query (for debugging)
    • analytics:read — read usage metrics
    • webhooks:write — configure Stripe / usage webhooks
  3. 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

Terminal window
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()

Rotating keys

Rate limits on auth

Auth endpoints are rate-limited to prevent brute force:

EndpointPer-IP limit
POST /v1/auth/signin20/min
POST /v1/auth/signup5/min
POST /v1/auth/magic-link5/min
POST /v1/auth/verify10/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');
}

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

HTTPerror.codeMeaning
400invalid_requestBody failed schema validation (details has the specific field)
401unauthorizedMissing, malformed, or revoked API key
403forbiddenKey valid but lacks the required scope
404not_foundResource does not exist or isn’t yours
409conflictE.g. creating a site with a duplicate site_id
422validation_failedBusiness-rule rejection (e.g. unreachable DB)
429rate_limitedBack off and retry with exponential delay
500internal_errorBug on our side — file at github.com/spelo/spelo/issues
503service_unavailableTransient — 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:

  1. Widget loads, reads data-site-id from its <script> tag
  2. Widget calls GET /v1/:siteId/config with Origin header
  3. API checks Origin against registered domains; returns config
  4. User clicks orb
  5. Widget calls POST /v1/:siteId/token
  6. API mints an ephemeral OpenAI session token + a 32-byte signing secret
  7. Widget uses the ephemeral token to open WebRTC directly with OpenAI
  8. Every POST /v1/:siteId/query includes X-Spelo-Signature (HMAC-SHA256 of body with the signing secret)
  9. 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