Prefer Swagger UI? Click hereThe same API in the classic OpenAPI explorer.
// how-to · 10 min read

Embed a browser chatbot

Call Ringside straight from a web page without shipping your ko_ developer key to the browser. Your server mints a short-lived, customer-pinned client token; the browser presents it as Authorization: Client <jwt> and streams chat completions; you revoke it on logout. This page walks the full mint-use-revoke loop with server and browser code, the exact origin and TTL controls and every failure you can hit.

The problem with a key in the browser

A Ringside developer key is ko_ followed by 64 hex characters, sent as Authorization: Bearer ko_<key>. It carries your whole account: every customer, every wallet, every scope the key was granted. Put that string in a script tag or a fetch call and anyone with devtools open has it. There is no test-vs-live split to soften the blow, so a leaked key is a leaked account.

A client token is the answer. It is a signed JWT your server mints on demand, pinned to one customer, scoped to a tiny allowlist of browser-safe endpoints, time-boxed to minutes and rate-limited per token. The browser holds it; the browser never holds the ko_ key. When the user logs out you revoke the token and any copy that leaked dies on the next request.

Two schemes, one parser

The header scheme is what tells Ringside which credential you sent. Bearer means a ko_ developer key. Client means a client-token JWT. Anything else returns 401 with the message "Authorization header must start with 'Bearer ' or 'Client '". The Client scheme is the whole distinction; there is no separate host or query flag.

The mental model

  your server (holds ko_ key)                browser (holds JWT)
  ---------------------------                --------------------
  POST /v1/customers/cus_42/client_tokens
    Authorization: Bearer ko_...      --->   201 { token, jti, expires_at }
                                                   |
                                      hand JWT to the page (memory)
                                                   |
                                                   v
                                   POST /v1/chat/completions
                                     Authorization: Client <jwt>
                                     Origin: https://app.example.com
                                                   |
                                          stream tokens via SSE
                                                   |
  on logout:                                       v
  DELETE .../client_tokens/ctok_...  <---   (JWT now dead next request)
Mint server-side, present client-side, revoke server-side.
  • ·The JWT is never stored server-side. Ringside keeps only a revocation registry row keyed by the token jti (prefix ctok_), holding its expiry, scopes, rpm limit, origin allowlist and optional IP-binding hash.
  • ·Verification is stateless crypto (EdDSA / Ed25519 signature, plus exp, nbf, iss, aud checks) followed by one registry lookup that collapses unknown, revoked and expired into the same answer.
  • ·Client tokens are un-refreshable by design. You mint a new one per session rather than renewing an old one.

Step 1: mint a client token on your server

Call POST /v1/customers/{id}/client_tokens with your developer key. The key must hold the api:client_tokens scope; without it you get 403 insufficient_scope. The {id} is a customer id (cus_...), or you can address the customer by external id with ext:<external_id>. The customer must exist and be active, or the mint returns 404 (cross-tenant and unknown ids both 404 so they are not enumerable).

POST /v1/customers/{id}/client_tokens request body (all fields optional)
FieldTypeDefaultNotes
scopestring[]["chat"]Subset of chat, conversations, embeddings, moderations, assistants_threads. Empty array or unknown value is a 400.
ttl_secondsinteger900Lifetime in seconds. Min 60, max 3600. Out of range or non-integer returns 400 invalid_ttl.
rpm_limitinteger60Per-token requests per minute. Max 600, and may not exceed the customer's own rpm_limit (returns 400 rpm_exceeds_customer_cap).
origin_allowliststring[]noneExact Origin header values this token may call from. Each entry <=512 chars, non-empty. Duplicates are stripped.
bind_ipbooleanfalseWhen true, the token is pinned to the IP your server saw on the mint request (hashed). A later call from a different IP returns 403 ip_binding_violation.

A successful mint is 201 with the JWT and its metadata. The token string is the only place you ever see the JWT; persist nothing but the jti if you want to revoke it later, and you can also list active tokens with a GET to the same path.

curl -sS -X POST https://api.fightclub.pro/v1/customers/cus_42/client_tokens \
  -H 'Authorization: Bearer ko_7f3c...<64 hex>' \
  -H 'Content-Type: application/json' \
  -d '{
    "scope": ["chat"],
    "ttl_seconds": 600,
    "rpm_limit": 30,
    "origin_allowlist": ["https://app.example.com"]
  }'

Doing it from your backend

In practice this lives behind your own session check: when a logged-in user opens the chat widget, your endpoint authenticates the user, maps them to a Ringside customer and mints a token scoped to that customer alone. The browser gets a JWT that can only ever spend against cus_42, never the rest of your account.

import express from 'express';

const app = express();
const RINGSIDE = 'https://api.fightclub.pro/v1';

app.post('/chat-token', requireLogin, async (req, res) => {
  // map your user -> a Ringside customer id you created earlier
  const customerId = req.user.ringsideCustomerId; // e.g. "cus_42"

  const r = await fetch(`${RINGSIDE}/customers/${customerId}/client_tokens`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.RINGSIDE_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      scope: ['chat'],
      ttl_seconds: 600,
      rpm_limit: 30,
      origin_allowlist: ['https://app.example.com'],
    }),
  });

  if (!r.ok) {
    const body = await r.json();
    // branch on body.error.code, never the human message
    return res.status(502).json({ error: body.error?.code ?? 'mint_failed' });
  }

  const { token, jti, expires_at } = await r.json();
  // store jti against the session so logout can revoke it
  req.session.chatTokenJti = jti;
  res.json({ token, expires_at });
});
Mint rate limit

The mint endpoint is capped at 100 requests per minute per customer. Past that you get 429 mint_rate_limit with Retry-After: 60. Mint one token per session and reuse it for the session's lifetime; do not mint per chat message.

Step 2: call chat completions from the browser

Hand the JWT to the page (keep it in memory, not localStorage) and call POST /v1/chat/completions with Authorization: Client <jwt>. Set stream: true to read the response as Server-Sent Events. The model reference still needs a Ringside prefix; a bare name like gpt-4o returns 400 invalid_model_ref. Use fc:openai/gpt-4o-mini to pin, or match:anthropic/sonnet>=4 to resolve.

// token came from POST /chat-token above; never the ko_ key
async function streamReply(token, userText, onDelta) {
  const res = await fetch('https://api.fightclub.pro/v1/chat/completions', {
    method: 'POST',
    headers: {
      Authorization: `Client ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'fc:openai/gpt-4o-mini',
      stream: true,
      messages: [{ role: 'user', content: userText }],
    }),
  });

  if (!res.ok) {
    const { error } = await res.json();
    throw new Error(error.code); // e.g. client_token_rate_limit_exceeded
  }

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buf = '';
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });
    const lines = buf.split('\n');
    buf = lines.pop() ?? '';
    for (const line of lines) {
      if (!line.startsWith('data: ')) continue;
      const data = line.slice(6);
      if (data === '[DONE]') return;
      const chunk = JSON.parse(data);
      const delta = chunk.choices?.[0]?.delta?.content;
      if (delta) onDelta(delta);
    }
  }
}

The browser sends the Origin header automatically on a cross-origin fetch, which is what the origin allowlist checks against. You do not set Origin yourself; the browser does, and the user agent will not let a page forge it.

What a client token can and cannot reach

Client tokens fail closed. They reach only a small allowlist of browser-safe endpoints, gated by the scope you minted. Every other /v1/* route returns 403 endpoint_not_allowed_for_client_token when called with a Client header, including the token-management routes themselves, so a leaked browser token can never mint or revoke tokens.

Endpoints reachable with a client token, by scope
Method + pathRequired scope
POST /v1/chat/completionschat
POST /v1/embeddingsembeddings
POST /v1/moderationsmoderations
GET /v1/customers/{id}/conversations/{cid}conversations
POST /v1/customers/{id}/conversations/{cid}/messagesconversations
GET /v1/customers/{id}/conversations/{cid}/messagesconversations
POST /v1/threads, GET/POST thread messages, POST/GET runs, submit_tool_outputs, cancel, stepsassistants_threads
Scope vs reachability

A token can hold chat and still get 403 on /v1/embeddings, because the allowlist entry for embeddings requires the embeddings scope, not chat. Mint exactly the scopes the widget needs. For a plain chat box, ["chat"] is enough and is the default.

Origin enforcement

When a token pins an origin_allowlist, the incoming Origin header must exactly match one entry on every call. This is CORS-lite enforcement at the credential layer: the comparison is a literal string match against the allowlist, so https://app.example.com and https://app.example.com/ (trailing slash) and http://app.example.com (wrong scheme) are three different values.

  • ·No Origin header at all is rejected when an allowlist is set. That is deliberate: a curl call with no origin is not trusted to spend a browser token, so the token is only useful from a real browser on an allowlisted page.
  • ·Match the value your browser actually sends. It is scheme + host + port with no path and no trailing slash, e.g. https://app.example.com or https://app.example.com:8443.
  • ·Leave origin_allowlist unset only if the token is otherwise constrained (short TTL, low rpm, IP-bound). An unpinned token works from any origin that holds it.
{
  "error": {
    "type": "permission_error",
    "code": "origin_not_allowed",
    "message": "Origin 'https://evil.example' is not in the token's allowlist"
  }
}

TTL strategy

TTL is your blast-radius dial. The default is 900 seconds (15 minutes), the floor is 60 and the ceiling is 3600 (1 hour). A leaked token is only dangerous until it expires, so shorter is safer, but too short and a long chat session dies mid-conversation and the page has to re-mint. Pick a TTL that covers a realistic session, not a realistic day.

Picking a TTL
Use caseSuggested ttl_secondsWhy
Support widget, bursty chats600 (10 min)Covers a conversation; re-mint on the next visit is cheap.
Embedded assistant, long sessions1800 - 3600Fewer re-mints during a working session; pair with a tight rpm_limit.
One-shot completion (single call)60 - 120Mint, fire one request, let it lapse. Minimal exposure window.
Re-mint, do not refresh

There is no refresh endpoint. When expires_at is near, call your server again for a fresh token. The client read it from your 201 response, so the page can schedule a re-mint a little before expiry and swap the in-memory token with no user-visible break.

Expiry is enforced twice: the JWT exp claim fails the signature-time check (verification returns expired), and the registry row's expires_at is re-checked on lookup. Either way an expired token returns 401 invalid_client_token with WWW-Authenticate: Client realm="ringside". The browser should treat that as "fetch a new token and retry once".

Step 3: revoke on logout

Expiry handles the slow case; revocation handles "right now". When the user logs out, or you detect abuse, revoke the token server-side. Revocation flips the registry row, and because every chat call does a registry lookup, the next request with that JWT returns 401 even though the signature is still cryptographically valid.

Token-management routes (Bearer-only)
Method + pathEffect
GET /v1/customers/{id}/client_tokensList active (non-revoked, non-expired) tokens with their jti, scope, expiry, rpm and origin allowlist.
DELETE /v1/customers/{id}/client_tokens/{jti}Revoke one token by jti. Idempotent: 204 whether or not it was already revoked, as long as the jti belongs to this (developer, customer) pair. Unknown or cross-tenant jti returns 404.
POST /v1/customers/{id}/client_tokens/revoke_allRevoke every active token for the customer. Returns { "revoked_count": n }. Useful when a customer rotates credentials or you suspect a leak.
curl -sS -X DELETE \
  https://api.fightclub.pro/v1/customers/cus_42/client_tokens/ctok_9a1b2c3d4e5f60718293a4b5 \
  -H 'Authorization: Bearer ko_7f3c...<64 hex>'
# -> 204 No Content
Revoke is idempotent and quiet

A DELETE on an already-revoked or already-expired token still returns 204, provided the jti is yours. You never get a "already revoked" error, so logout handlers can call it unconditionally without special-casing the response.

Rate limits on a client token

Each token carries its own per-minute ceiling (rpm_limit, default 60, max 600, never above the customer's own rpm_limit). It sits inside the wider four-layer rate-limit stack and the lowest applicable limit wins. So a token with rpm_limit: 30 is capped at 30/min even if the customer allows 600.

  • ·Edge per-IP (outermost)
  • ·Per-client-token (this rpm_limit)
  • ·Per-developer
  • ·Per-customer (innermost)

On a normal response, read X-RateLimit-Remaining for the count left in the current window. On a 429 you get Retry-After (integer seconds) and, for a token-rpm breach, code: "client_token_rate_limit_exceeded" with a retry_after_seconds field in the body. There is no X-RateLimit-Limit or X-RateLimit-Reset header.

{
  "error": {
    "type": "rate_limit_error",
    "code": "client_token_rate_limit_exceeded",
    "message": "Client token RPM limit exceeded (limit=30/min)",
    "retry_after_seconds": 42
  }
}

Failure modes and how to handle them

Every error body is { "error": { "type", "code", "message", ... } }. Branch on code, never the human message. The request id is the X-Request-Id response header, not a body field; log it when something goes wrong. The table below is the set you will actually see on the browser path.

Errors on the client-token chat path
StatuscodeCauseWhat the page should do
401invalid_auth_schemeHeader did not start with Bearer or Client .Fix the header; this is a client bug.
401invalid_client_tokenJWT failed verification, was revoked or expired.Re-mint from your server, retry once.
403endpoint_not_allowed_for_client_tokenCalled a route not on the browser allowlist.Move that call to your server with the ko_ key.
403insufficient_scopeEndpoint allowlisted but the token lacks the scope.Mint with the right scope[] (e.g. add embeddings).
403origin_not_allowedOrigin missing or not in the token allowlist.Call from an allowlisted page; check scheme + port.
403ip_binding_violationSource IP differs from the IP at mint time (IP-bound token).Re-mint; the user's network likely changed.
429client_token_rate_limit_exceededToken rpm_limit exhausted this minute.Back off for retry_after_seconds, then retry.
400invalid_model_refModel sent without an fc: / match: / slot: / dyn: prefix.Prefix the model; this is a client bug.
402customer_wallet_empty / customer_budget_exceededThe pinned customer is out of funds or over budget.Top up the wallet or raise monthly_budget_usd server-side.
The management routes are off-limits to the browser

Mint, list, revoke and revoke-all all require a Bearer ko_ key and explicitly reject the Client scheme with 403 endpoint_not_allowed_for_client_token. A browser token cannot escalate by minting itself a broader token. Keep all four behind your own server.

Hard limits and defaults to remember

ttl_seconds
Default 900. Min 60, max 3600. Integer only.
rpm_limit
Default 60. Max 600, and clamped to the customer's rpm_limit if that is lower.
origin_allowlist entry
Up to 512 chars each, non-empty. Exact-match against the Origin header.
scope
Defaults to ["chat"]. Allowed: chat, conversations, embeddings, moderations, assistants_threads.
mint rate
100 mints per minute per customer, then 429 mint_rate_limit with Retry-After: 60.
chat messages size
Serialized messages cap at 100,000 bytes per chat request.
Defence in depth

The strongest browser-token setup stacks all four controls: a tight scope, a short ttl_seconds, an origin_allowlist and a low rpm_limit, with revocation wired into logout. Each one alone narrows the blast radius; together a leaked token is near-useless before it even expires.