Prefer Swagger UI? Click hereThe same API in the classic OpenAPI explorer.
// get started · 6 min read

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.

Both paths call the same API. The Ringside SDK only adds typed sugar for Ringside-only primitives.
DecisionUse the OpenAI SDK drop-inUse the Ringside SDK
Plain chat / embeddings / moderationYes, zero new depsAlso fine
Cost-aware fallback (model: [...])Yes, pass the arrayYes, plus a typed builder
match: / slot: / dyn: model refsYes, as a stringTyped match: builder
Create/list/update customersHand-rolled HTTPTyped customers methods
Verify webhook signaturesImplement HMAC yourselfwebhooks.verify()
Mint browser client tokensHand-rolled HTTPTyped clientTokens.create()
One base URL, one key format

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).

Reading the response headers

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: true

The 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.

LanguageInstallImport / module path
Pythonpip install ringsidefrom ringside import Ringside
Nodenpm install @ringside/sdkimport { Ringside } from '@ringside/sdk'
Gogo get github.com/fightclub/ringside-goimport "github.com/fightclub/ringside-go"
Javapro.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 response
Both clients return the same body

The 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-4

The @<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);
});
During a secret rotation

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 8601

Minting 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
  }
}
  • ·type is one of authentication_error, invalid_request_error, permission_error, rate_limit_error, conflict_error, not_found_error, wallet_error, internal_error, api_error.
  • ·param is present on field-validation errors. retry_after_seconds is 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-Id response header, never a body field. Capture it from resp.request_id (Python) or resp.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.

Rate-limit headers

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 as api:client_tokens are 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> and FC-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.

  1. 1
    You 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"}]}'
  2. 2
    The 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."
      }
    }
  3. 3
    You branch on the code, not the message

    In code, switch on error.code === "customer_budget_exceeded" and raise the budget via the dashboard or customers.update(). Open /app/logs, paste req_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

ThingValue
Base URLhttps://api.fightclub.pro/v1
Key formatko_ + 64 hex chars, sent as Bearer
Client-token TTLdefault 900s, min 60, max 3600
Client-token rpmdefault 60, max 600 (clamped to customer rpm)
Client-token mint rate100 per minute per customer
List paginationlimit default 20, max 100 (vector-store queries 50/200)
Idempotency replay window24 h, scoped to (developer, customer, key)
Chat messages serialized100,000 bytes
Embeddings input<= 2,048 items, <= 100,000 chars each
File upload512 MB max
Batch size50,000 requests max
No version header

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