What you'll build
A chat widget that talks to Ringside directly from the browser, no proxy server, no long-lived API key in your frontend bundle. The end user sees a streaming chatbot pinned to the bottom-right of your page. Your server mints a short-lived Client Token (default 15 minutes) bound to one Customer, one IP, and one Origin; the browser fetches completions with that token.
What you need
- An FC API key (from
/app/api-keys) with scopeapi:client_tokens - A small backend route (Express, Next.js API route, Go handler, anything that can sign)
- A React frontend (the pattern works in any framework)
Full code
Server-side mint (/api/chat-token, Node):
js// server/mint-token.js — run on every page load, or cache 10 min per user. import express from 'express'; const app = express(); app.use(express.json()); app.post('/api/chat-token', async (req, res) => { const { userId } = req.body; // your app's user id const externalId = `ext:user_${userId}`; // stable Ringside Customer handle // 1. Upsert a Customer idempotently. await fetch('https://api.fightclub.pro/v1/customers', { method: 'POST', headers: { Authorization: `Bearer ${process.env.FC_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': `upsert-${userId}`, }, body: JSON.stringify({ external_id: externalId, budget_usd: 5.0, rate_limit_rpm: 30, }), }); // 2. Mint a short-lived Client Token pinned to this Customer + IP. const r = await fetch( `https://api.fightclub.pro/v1/customers/ext:user_${userId}/client_tokens`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FC_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ttl_seconds: 900, scope: ['chat'], origin_allowlist: ['https://example.com'], bind_ip: true, // bind to req.ip }), }, ); const { token, expires_at } = await r.json(); res.json({ token, expires_at }); }); app.listen(3001);
Browser-side chat component (React + TypeScript):
tsx// app/chat-widget.tsx 'use client'; import { useEffect, useRef, useState } from 'react'; export function ChatWidget({ userId }: { userId: string }) { const [token, setToken] = useState<string | null>(null); const [messages, setMessages] = useState<{ role: string; content: string }[]>([]); const [draft, setDraft] = useState(''); const streamingRef = useRef<string>(''); useEffect(() => { fetch('/api/chat-token', { method: 'POST', body: JSON.stringify({ userId }) }) .then((r) => r.json()) .then((d) => setToken(d.token)); }, [userId]); async function send() { if (!token || !draft.trim()) return; const history = [...messages, { role: 'user', content: draft }]; setMessages(history); setDraft(''); 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', messages: history, stream: true, }), }); const reader = res.body!.getReader(); const dec = new TextDecoder(); streamingRef.current = ''; let buf = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); for (const line of buf.split('\n\n')) { const data = line.match(/^data: (.*)$/m)?.[1]; if (!data || data === '[DONE]') continue; const delta = JSON.parse(data).choices?.[0]?.delta?.content; if (delta) { streamingRef.current += delta; setMessages([...history, { role: 'assistant', content: streamingRef.current }]); } } buf = buf.split('\n\n').slice(-1)[0]; } } return ( <div className="fixed bottom-4 right-4 w-96 rounded-lg border bg-white p-4 shadow-xl"> <div className="max-h-96 overflow-y-auto"> {messages.map((m, i) => ( <div key={i} className={m.role === 'user' ? 'text-right' : ''}> {m.content} </div> ))} </div> <input value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} /> </div> ); }
Walkthrough
The server route does two things: (1) idempotently upsert a Customer keyed on your internal user id, and (2) mint a Client Token scoped to that Customer with ttl_seconds: 900 and origin_allowlist. The Idempotency-Key header on the Customer create means repeat calls within 24 hours return the same record rather than 409-ing.
In the browser, Authorization: Client <token> (note: Client, not Bearer) tells Ringside this is a Client-Token request. Ringside force-scopes the request to the pinned Customer, you cannot pass user: in the body, and any mismatch returns 403 client_token_customer_mismatch.
Streaming uses SSE; the body is a sequence of data: {json}\n\n frames terminated by data: [DONE]\n\n.
Run it
bash# server FC_API_KEY=sk_live_xxx node server/mint-token.js # frontend dev server pnpm dev
Open https://example.com, the widget mounts, token is minted, chat streams back.