Embedded chatbot in the browser
A hotel gives you a plastic card, not a key from the master ring. The card opens one door, stops working on Friday, and if you leave it in a taxi the hotel does not have to change every lock in the building. It is a worse key on purpose, and that is exactly what makes it safe to hand to a stranger at reception.
Which brings you to the widget. You want a chat box on the docs page or in the app sidebar, it should be done this week, and it should be boring enough that you forget it exists. The API key cannot go in the bundle. So you write a proxy, and now a two-day feature involves a streaming service, SSE backpressure, an extra network hop on every token and something new to keep running at 4am.
What is wrong with the key
The reason your API key cannot live in the frontend has nothing to do with browsers being leaky. It is that the key is a master key. It never expires, it works from any machine on Earth, it can call every endpoint you have, and it spends from one pool shared by all of your users. Every property that makes it convenient on your server makes it catastrophic in a bundle.
Take those properties away one at a time and you end up with a credential that is safe to publish to a browser. Give it fifteen minutes to live. Bind it to one origin, so it dies the moment it is replayed from anywhere else. Pin it to a single Customer, so the worst case is one user's balance rather than your account. Restrict it to the handful of endpoints a chat widget needs. Now the proxy has nothing left to do. The browser talks to Ringside directly, streaming included, and your server is back to doing one thing: deciding who is allowed to get a token.
The token, precisely
- • Ed25519-signed, short by default. 15 minutes if you say nothing,
ttl_secondsanywhere from 60 to 3600 if you do. An hour is the hard ceiling; there is no long-lived Client Token. - • Origin-bound. Pass
origin_allowlistat mint time and a token lifted off device A is useless on anyone else's page. - • Customer-pinned and rate-capped. Every token belongs to one Customer, and
rpm_limitsets a per-token ceiling up to 600 that cannot exceed the Customer's own limit. - • Endpoint allowlist. Client Tokens reach chat, embeddings, moderations, threads and messages. Minting, listing and revoking stay Bearer-only, so a stolen Client Token cannot mint itself a successor.
- • Prepaid per user. Top up a chat user's balance with
POST /v1/customers/:id/wallet/topup. Ringside debits each call and returns402 customer_wallet_emptywhen it runs dry, or402 customer_wallet_insufficientwhen a call would cost more than the remaining balance, rather than draining it part way. No spend-tracking code on your side.
Architecture
The solid line to your server happens once per session. The dashed line at the bottom is every chat message after that, browser to Ringside, with nothing of yours in the middle.
In code
# Your server: mint a short-lived Client Token on page load.
# Client Tokens are Bearer-only to mint, so this call stays server-side.
@app.post("/api/chat-token")
def mint_chat_token(request):
user = request.user
r = httpx.post(
f"https://api.fightclub.pro/v1/customers/{user.ringside_customer_id}/client_tokens",
headers={"Authorization": f"Bearer {RINGSIDE_KEY}"},
json={
"scope": ["chat"],
"ttl_seconds": 900, # 15 min; 60..3600 allowed
"rpm_limit": 20, # per-token ceiling
"origin_allowlist": ["https://app.example.com"],
},
).json()
return {"token": r["token"], "expires_at": r["expires_at"]}
# Browser: call Ringside directly with the Client Token.
# (This is JS, shown here for illustration.)
# const r = await fetch("https://api.fightclub.pro/v1/chat/completions", {
# method: "POST",
# headers: {
# "Authorization": `Client ${jwt}`,
# "Content-Type": "application/json",
# },
# body: JSON.stringify({ model: "fc:openai/gpt-4o", messages, stream: true }),
# });