All use cases

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_seconds anywhere from 60 to 3600 if you do. An hour is the hard ceiling; there is no long-lived Client Token.
  • Origin-bound. Pass origin_allowlist at 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_limit sets 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 returns 402 customer_wallet_empty when it runs dry, or 402 customer_wallet_insufficient when a call would cost more than the remaining balance, rather than draining it part way. No spend-tracking code on your side.

Architecture

BrowserReact componentYour servermints Client TokenRingside APIapi.fightclub.pro

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 }),
# });

Cross-links

Get started in 5 minutes →