SDKs and tools
There are two ways to call Ringside. Point an existing OpenAI SDK at our base URL and almost nothing changes, or install the official Ringside SDK to get typed helpers for the parts that are ours alone (the match: model builder, customer CRUD, signed-webhook verification and browser client tokens). This page shows the same chat call both ways across four languages, then walks the dashboard, the in-browser playground and request logs you use to operate the API in production.
Two ways to call Ringside
Ringside speaks the OpenAI wire format. Chat completions, embeddings, moderations, files, batches and the Assistants shim are all wire-compatible, so the fastest path is to keep your existing OpenAI client and change two things: the base URL and the model string. That gets you metering, customers, fallback cascades and caching with no new dependency.
The second path is the official Ringside SDK. It wraps the same HTTP API but adds typed methods for the primitives OpenAI does not have. You reach for it when you want a match: builder that resolves to the newest model in a family, customer CRUD without hand-rolling requests, a one-line webhooks.verify() that does the constant-time HMAC check for you, or server-side minting of browser client tokens. Both paths hit the same endpoints and return the same bodies. Pick per task, and you can mix them in one codebase.
| Decision | Use the OpenAI SDK drop-in | Use the Ringside SDK |
|---|---|---|
| Plain chat / embeddings / moderation | Yes, zero new deps | Also fine |
Cost-aware fallback (model: [...]) | Yes, pass the array | Yes, plus a typed builder |
match: / slot: / dyn: model refs | Yes, as a string | Typed match: builder |
| Create/list/update customers | Hand-rolled HTTP | Typed customers methods |
| Verify webhook signatures | Implement HMAC yourself | webhooks.verify() |
| Mint browser client tokens | Hand-rolled HTTP | Typed clientTokens.create() |
Every call goes to https://api.fightclub.pro/v1. Authenticate with Authorization: Bearer ko_<64 hex>. There is no test-vs-live key split and no version header. The only API version is the /v1 path segment.
The OpenAI SDK drop-in
Set base_url (or baseURL) to https://api.fightclub.pro/v1, set the API key to your ko_ key and add the fc: prefix to model names. That is the whole migration. A bare model name like gpt-4o is rejected with 400 invalid_model_ref, so every ref needs a prefix: fc:openai/gpt-4o-mini pins an exact model, match:anthropic/sonnet>=4 resolves to the newest matching model, slot:<alias> points at an alias you defined and dyn:<name> selects a dynamic profile.
from openai import OpenAI
client = OpenAI(
base_url="https://api.fightclub.pro/v1",
api_key=os.environ["FC_API_KEY"], # ko_<64 hex>
)
resp = client.chat.completions.create(
model="fc:openai/gpt-4o-mini",
messages=[{"role": "user", "content": "Hello"}],
extra_headers={"FC-Customer": "cus_42"},
)
print(resp.choices[0].message.content)The OpenAI SDK has no field for Ringside-only headers, so you pass them as raw headers. extra_headers in Python, the per-call headers option in Node. That is how you attach a call to a customer (FC-Customer), override the billed amount (X-FC-Billable-Amount), tag usage (FC-Tag, FC-Property-<name>) or opt into the response cache (FC-Cache, FC-Cache-TTL).
Ringside returns operational data in response headers the OpenAI SDK exposes via the raw response. X-Request-Id is the id you quote in support tickets (it is never a body field). X-Ringside-Wallet-Deducted is the exact charge for the call, X-Ringside-Wallet-Balance the balance after it, and X-RateLimit-Remaining the requests left in the current window.
A cost-aware cascade is just an array in the model field. The first model is tried and Ringside escalates to the next on a 5xx, an upstream_unavailable or a response_schema_validation_failed. The winner comes back in X-Ringside-Model-Used, the full chain in X-Ringside-Models-Tried and a boolean in X-Ringside-Fallback-Triggered. A cascade cannot combine with stream: true (400 fallback_with_stream_unsupported) or with an idempotency-key (400 fallback_with_idempotency_unsupported).
curl https://api.fightclub.pro/v1/chat/completions \
-H "Authorization: Bearer $FC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": ["fc:openai/gpt-4o-mini", "fc:openai/gpt-4o"],
"messages": [{"role":"user","content":"Hello"}]
}'
# Response headers tell you which model served:
# X-Ringside-Model-Used: fc:openai/gpt-4o
# X-Ringside-Models-Tried: fc:openai/gpt-4o-mini,fc:openai/gpt-4o
# X-Ringside-Fallback-Triggered: trueThe official Ringside SDK
The Ringside SDK ships in four languages. Each one carries the same surface: a chat client, customer CRUD, webhook signature verification, client-token minting and a typed model-ref builder. The install line and import path differ per language.
| Language | Install | Import / module path |
|---|---|---|
| Python | pip install ringside | from ringside import Ringside |
| Node | npm install @ringside/sdk | import { Ringside } from '@ringside/sdk' |
| Go | go get github.com/fightclub/ringside-go | import "github.com/fightclub/ringside-go" |
| Java | pro.fightclub:ringside-sdk (Maven/Gradle) | import pro.fightclub.ringside.Ringside; |
The same chat call, four languages
Construct a client with your ko_ key (the base URL is baked in) and call chat. Attribution and other Ringside headers are first-class arguments rather than raw header maps.
from ringside import Ringside
rs = Ringside(api_key=os.environ["FC_API_KEY"])
resp = rs.chat.completions.create(
model="fc:openai/gpt-4o-mini",
messages=[{"role": "user", "content": "Hello"}],
customer="cus_42", # sets FC-Customer
)
print(resp.choices[0].message.content)
print(resp.request_id) # the X-Request-Id header, surfaced on the responseThe chat response is OpenAI-shaped either way: id (a chatcmpl- value), object, created, model, choices[] and usage. The Ringside SDK adds accessors for the response headers (request id, resolved model, wallet figures) so you do not parse them by hand.
The match: model builder
A match: ref resolves at request time to the newest model in a family that satisfies a constraint, so you do not hard-pin a version that goes stale. The SDK exposes a builder for it; under the hood it is still the match:<provider>/<family> string the API expects, and the resolved concrete model comes back in X-Ringside-Model-Resolved.
from ringside import match
resp = rs.chat.completions.create(
model=match("anthropic", "sonnet").gte(4), # -> "match:anthropic/sonnet>=4"
messages=[{"role": "user", "content": "Summarize this contract."}],
)
print(resp.headers["X-Ringside-Model-Resolved"]) # e.g. fc:anthropic/claude-sonnet-4The @<region> data-residency suffix attaches to fc: refs only, not to match:. Use fc:openai/gpt-4o@eu or fc:openai/gpt-4o@us to pin OpenAI in-region, and the @eu-bedrock / @us-bedrock / @eu-vertex / @us-vertex suffixes to route Claude through AWS Bedrock or Google Vertex in a specific region. An unknown suffix is a 400.
Customers CRUD
Customers are a first-class primitive (id prefix cus_). The SDK gives you create, retrieve, list and update without building requests by hand, and addressing by your own id works through the ext: prefix.
// Create a customer with a monthly budget and your external id
const cust = await rs.customers.create({
externalId: 'u_42',
displayName: 'Alice',
monthlyBudgetUsd: 50,
});
console.log(cust.id); // cus_...
// Retrieve later by YOUR id, no need to store ours
const same = await rs.customers.retrieve('ext:u_42');
// List with cursor pagination ({ data, has_more, next_cursor })
const page = await rs.customers.list({ limit: 50 });
if (page.has_more) {
const next = await rs.customers.list({ limit: 50, after: page.next_cursor });
}Set default_billable_markup_pct or default_billable_amount_usd (never both, that is a 400 billable_defaults_mutex) to control resale pricing, and monthly_budget_usd to cap spend. A call attributed to a customer past budget returns 402 customer_budget_exceeded while your own developer wallet still has money. The ext: form composes onto every customer subroute, for example customers/ext:u_42/wallet and customers/ext:u_42/margin.
Verifying webhooks
Every webhook delivery carries X-FC-Signature: t=<unix>,v1=<hex hmac_sha256>, where the digest is HMAC-SHA256 of the raw request body keyed by the endpoint secret (the whsec_ value returned once on create). The SDK does the constant-time compare and the stale-timestamp rejection for you. Pass it the raw body bytes, the header and your secret.
import express from 'express';
import { Ringside } from '@ringside/sdk';
const rs = new Ringside({ apiKey: process.env.FC_API_KEY });
const app = express();
app.post('/wh/fc', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = rs.webhooks.verify(
req.body, // raw bytes, NOT a parsed object
req.header('X-FC-Signature'),
process.env.FC_WEBHOOK_SECRET, // whsec_...
);
} catch {
return res.status(400).send('bad signature');
}
// event.type is one of customer.created, wallet.low, batch.completed, ...
res.sendStatus(204);
});When you rotate a secret, the X-FC-Signature header carries two v1= digests during the grace window (current and previous). webhooks.verify() accepts either. If you implement the check yourself, do the same or in-flight deliveries fail during cutover.
Minting client tokens
A client token lets a browser call Ringside directly without ever shipping your secret key. You mint it server-side with POST /v1/customers/{id}/client_tokens (your key must hold the api:client_tokens scope), then the browser sends Authorization: Client <jwt>. Note the scheme: Client, not Bearer. That is what tells the auth parser this is a scoped token and not a developer key. The token id (jti) is prefixed ctok_.
token = rs.client_tokens.create(
customer="cus_42",
ttl_seconds=900, # default 900, min 60, max 3600
rpm_limit=60, # default 60, max 600, clamped to the customer rpm
origin_allowlist=["https://app.example.com"], # each <= 512 chars
)
# token.token -> the JWT the browser sends as: Authorization: Client <jwt>
# token.jti -> ctok_...
# token.expires_at -> ISO 8601Minting is rate-limited to 100 per minute per customer. The token defaults to chat-only scope; if it hits an endpoint outside that scope it is rejected 403. Files, for one, are developer-key only and reject a client token with 403 endpoint_not_allowed_for_client_token. See Client tokens for the browser for the full origin and IP-binding model.
Errors and idempotency, the same in both clients
Every error has the same body shape regardless of which client sent the request. Always branch on error.code, never on the human-readable error.message, which is not a stable contract.
{
"error": {
"type": "rate_limit_error",
"code": "customer_rate_limit_exceeded",
"message": "Customer rpm limit reached.",
"retry_after_seconds": 12
}
}- ·
typeis one ofauthentication_error,invalid_request_error,permission_error,rate_limit_error,conflict_error,not_found_error,wallet_error,internal_error,api_error. - ·
paramis present on field-validation errors.retry_after_secondsis present on rate-limit errors. - ·Cross-tenant access returns 404
not_found_error, never 403, so ids cannot be enumerated. - ·The request id is the
X-Request-Idresponse header, never a body field. Capture it fromresp.request_id(Python) orresp.requestId(Node/Go/Java) for support tickets.
For safe retries, send a lowercase idempotency-key header. Ringside replays the stored result for 24 hours, scoped to the tuple (developer, customer, key). The same header works on every endpoint. One exclusion: a model: [] cascade plus an idempotency key is a 400 fallback_with_idempotency_unsupported, since a cascade is non-deterministic about which model wins.
A 429 carries Retry-After as integer seconds. There is no X-RateLimit-Limit or X-RateLimit-Reset. The only steady-state counter is X-RateLimit-Remaining. Four enforcement layers apply (edge per-IP, per-client-token, per-developer, per-customer) and the lowest one wins.
The dashboard
The dashboard lives at https://ringside.fightclub.pro, with pages under /app/*. It is where you do the things an SDK does not. Create keys, watch spend, top up wallets and read what actually happened on a request. You can run the whole API from code, but the dashboard is the operational surface.
- API keys (/app/api-keys)
- Create and revoke
ko_keys. Keys are SHA-256 hashed at rest, so the full value is shown once at creation. There is no test/live distinction. Scopes such asapi:client_tokensare granted here. - Customers (/app/customers)
- Browse the
cus_records the API creates, set budgets, rpm/tpm limits and billable defaults, top up prepaid wallets and read per-customer margin (billable minus raw cost). - Usage (/app/usage)
- Spend and token rollups, broken down by the dimensions you attached at call time via
FC-Session-Id,FC-Property-<name>andFC-Tag. - Logs (/app/logs)
- The request log: every call with its
req_request id, model resolved, status, latency and the exact amount deducted. - Playground (/app/playground)
- An in-browser console to send a chat call without writing code, then read the response headers and copy a working curl/SDK snippet.
The playground
The playground sends real requests through your account against a key you select, so what you see is what production sees. Pick a model ref (try a match: to watch it resolve), set headers like FC-Customer, fire the call and read back the resolved model, the wallet deduction, the rate-limit headers and the request id. Use it to confirm a model ref resolves the way you expect before you wire it into code, then copy the generated snippet into your app.
Request logs and the request id
Every call gets a unique request id, returned in the X-Request-Id response header and listed against the call in /app/logs. When something goes wrong, grab the id from your client (resp.request_id) or the response header and find the exact request in the log: the model that ran, the upstream provider, the headers you sent, the status code and the charge. Quote that id in any support ticket. Below is a worked failure: a customer over budget.
- 1You send a chat call attributed to a customer
curl https://api.fightclub.pro/v1/chat/completions \ -H "Authorization: Bearer $FC_API_KEY" \ -H "FC-Customer: cus_42" \ -H "Content-Type: application/json" \ -d '{"model":"fc:openai/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}' - 2The customer is over its monthly_budget_usd, so you get a 402
// HTTP/1.1 402 Payment Required // X-Request-Id: req_8f2c1a... { "error": { "type": "wallet_error", "code": "customer_budget_exceeded", "message": "Customer cus_42 has exceeded its monthly budget." } } - 3You branch on the code, not the message
In code, switch on
error.code === "customer_budget_exceeded"and raise the budget via the dashboard orcustomers.update(). Open/app/logs, pastereq_8f2c1a...and you see the blocked call with the customer, the model and a $0 deduction (the call never reached a provider).
Hard limits and defaults worth knowing
| Thing | Value |
|---|---|
| Base URL | https://api.fightclub.pro/v1 |
| Key format | ko_ + 64 hex chars, sent as Bearer |
| Client-token TTL | default 900s, min 60, max 3600 |
| Client-token rpm | default 60, max 600 (clamped to customer rpm) |
| Client-token mint rate | 100 per minute per customer |
| List pagination | limit default 20, max 100 (vector-store queries 50/200) |
| Idempotency replay window | 24 h, scoped to (developer, customer, key) |
| Chat messages serialized | 100,000 bytes |
| Embeddings input | <= 2,048 items, <= 100,000 chars each |
| File upload | 512 MB max |
| Batch size | 50,000 requests max |
Stripe-style version pinning does not exist here. The only versioning is the /v1 path. If we ship a breaking change it lands on a new path segment, so your /v1 calls keep behaving as documented.
Next
Make your first authenticated call in under five minutes, from key to first completion.
Key format, the Bearer vs Client schemes, scopes and the four rate-limit layers.
Every parameter, header, response field and error code for /v1/chat/completions.
Budgets, wallets, billable defaults, margin and ext: addressing in full.