Token endpoint
The token endpoint is the heart of Spelo’s security model. The widget calls it every time a visitor clicks the orb, and gets back an ephemeral OpenAI session token scoped to the next ~2 hours.
The customer’s OpenAI API key (or Spelo’s managed key) never reaches the browser. Only the ephemeral token does.
Endpoint
POST /v1/:siteId/tokenAuth
Origin-based. Unlike the Sites API, this endpoint is called by the widget in visitors’ browsers — they have no API key. Instead:
- The
Originheader must match one of the domains registered for thissite_id. - We look up the site config, verify the origin, then mint the token server-side using the customer’s stored OpenAI key.
No Authorization header is sent or required from the widget.
Request
POST /v1/ab1c2d3e/tokenOrigin: https://emberandoak.comContent-Type: application/json
{}Body is currently empty. Future versions may include session hints (preferred voice, user locale) but these are sourced from the site config today.
Response 200
{ "success": true, "data": { "client_secret": { "value": "eph_abc123...", "expires_at": 1745010000 }, "model": "gpt-4o-realtime-preview-2024-10-01", "voice": "nova", "instructions": "You are Maya, the AI host at Ember & Oak...", "signing_secret": "base64-32-bytes", "session_id": "sess_xyz" }}| Field | Meaning |
|---|---|
client_secret.value | Ephemeral token; use this as the bearer for the WebRTC offer to OpenAI |
client_secret.expires_at | Unix seconds; ~2 hours from issuance |
model | Which Realtime model to use |
voice | The pre-selected voice for this session |
instructions | The resolved system prompt (personality + pronunciations + time zone + restrictions) |
signing_secret | Used to sign subsequent /query calls via HMAC-SHA256 |
session_id | Internal session identifier for analytics and linking events |
How the widget uses it
// Pseudocodeconst { data } = await fetch(`${API}/v1/${siteId}/token`, { method: 'POST', credentials: 'omit',}).then((r) => r.json())
const pc = new RTCPeerConnection()// ...attach mic tracks...const offer = await pc.createOffer()await pc.setLocalDescription(offer)
const answer = await fetch('https://api.openai.com/v1/realtime', { method: 'POST', headers: { Authorization: `Bearer ${data.client_secret.value}`, 'Content-Type': 'application/sdp', }, body: offer.sdp,}).then((r) => r.text())
await pc.setRemoteDescription({ type: 'answer', sdp: answer })After this, audio flows browser ↔ OpenAI directly over WebRTC.
Errors
| HTTP | Code | Cause |
|---|---|---|
| 400 | invalid_site_id | site_id is malformed (must match [a-z0-9]{8,32}) |
| 403 | origin_not_allowed | Request Origin doesn’t match any registered domain |
| 404 | site_not_found | No site with this id |
| 422 | openai_key_missing | Site has no OpenAI key (and isn’t on the managed plan) |
| 429 | rate_limited | Per-IP (60/min) or per-site (200/min) limit hit |
| 500 | openai_error | OpenAI rejected our request — quote request_id in support |
Rate limits
| Scope | Limit | Window |
|---|---|---|
| Per IP | 60 | 1 minute |
| Per site_id | 200 | 1 minute |
| Burst | 20 | 1 second |
If abuse is detected, we’ll dynamically lower the per-site limit and alert you in the dashboard.
BYOK vs. managed
- Bring-your-own-key (BYOK) — Spelo decrypts your stored key, calls OpenAI
POST /realtime/sessionswith it, returns the ephemeral. - Managed — Spelo uses its own OpenAI key. Your usage metering counts against your Spelo plan minutes.
See BYOK for details on switching.
Session lifetime
- Default TTL: 2 hours.
- Max TTL (Enterprise): 6 hours.
- The token is valid only for the visitor’s WebRTC session; it can’t be reused by a third party to make additional OpenAI calls.
Security notes
See also
- Query endpoint — the signed call
- Authentication — API key auth for dashboard callers
- Billing → BYOK