Skip to content
GitHub
Get started →

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/token

Auth

Origin-based. Unlike the Sites API, this endpoint is called by the widget in visitors’ browsers — they have no API key. Instead:

  • The Origin header must match one of the domains registered for this site_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/token
Origin: https://emberandoak.com
Content-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"
}
}
FieldMeaning
client_secret.valueEphemeral token; use this as the bearer for the WebRTC offer to OpenAI
client_secret.expires_atUnix seconds; ~2 hours from issuance
modelWhich Realtime model to use
voiceThe pre-selected voice for this session
instructionsThe resolved system prompt (personality + pronunciations + time zone + restrictions)
signing_secretUsed to sign subsequent /query calls via HMAC-SHA256
session_idInternal session identifier for analytics and linking events

How the widget uses it

// Pseudocode
const { 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

HTTPCodeCause
400invalid_site_idsite_id is malformed (must match [a-z0-9]{8,32})
403origin_not_allowedRequest Origin doesn’t match any registered domain
404site_not_foundNo site with this id
422openai_key_missingSite has no OpenAI key (and isn’t on the managed plan)
429rate_limitedPer-IP (60/min) or per-site (200/min) limit hit
500openai_errorOpenAI rejected our request — quote request_id in support

Rate limits

ScopeLimitWindow
Per IP601 minute
Per site_id2001 minute
Burst201 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/sessions with 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