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

Architecture

Ringside is a single gateway in front of 19 model providers. One request crosses host routing, an edge IP limit, Bearer or Client auth, customer attribution, four rate-limit layers and a budget check, then a provider adapter, before the response comes back stamped with the exact wallet charge, the rate-limit headroom and a request id. This page traces that path end to end and names every moving part: the metering ledger, the per-user encrypted vault, managed vector stores and the in-process workers that run webhooks, batches and retention.

The mental model

Ringside is one OpenAI-compatible gateway sitting in front of 19 providers. You hold one developer key, you keep one wallet, and you address every model through a single base URL at https://api.fightclub.pro/v1. Behind that URL the gateway does the work your own backend would otherwise do: it authenticates the caller, decides whose budget the call spends, enforces rate limits at four levels, resolves an abstract model reference to a concrete provider model, calls that provider with credentials drawn from an encrypted vault, then records the spend and hands you back the answer.

Two ideas drive the whole design. First, the developer (you) and your end customers are separate billing entities. You have a wallet. Each customer you create can have its own wallet, its own monthly budget and its own rate limits, and a single header decides which one a call draws from. Second, nothing here leaks. Provider API keys live in a per-user encrypted vault the server cannot read without your password, and cross-tenant access returns a 404, never a 403, so ids stay unguessable.

OpenAI SDK drop-in

Point an existing OpenAI client at https://api.fightclub.pro/v1, put your key in Authorization: Bearer, and the only code change is the model string: a bare gpt-4o becomes fc:openai/gpt-4o. Everything below happens server-side without further changes.

A request, end to end

Here is the full path of a single POST /v1/chat/completions, from the client socket to the metered response. Each numbered stage is a real check in the gateway; an early failure short-circuits with a typed error and an X-Request-Id header so you can trace it.

client
  |  Authorization: Bearer ko_...   (or  Client <jwt>)
  |  FC-Customer: cus_42            (optional attribution)
  v
[ nginx ]  sets X-Real-IP, terminates TLS
  |
  v
[ edge / Layer 0 ]  per-IP rate limit + body-size cap
  |    429 edge_rate_limit_exceeded / 413 payload_too_large
  |    host routing: api.fightclub.pro  /v1/*  ->  /api/v1/*
  v
[ auth ]  Bearer ko_ (dev key)  |  Client <jwt> (browser)
  |    401 missing_token / invalid_token / invalid_auth_scheme
  |    403 insufficient_scope
  |    Layer 0.5: per-client-token RPM (Client mode only)
  v
[ attribution ]  resolve FC-Customer -> cus_ or dev-as-target
  |    404 customer_not_found  (FC-Customer-Strict: true)
  v
[ limits + budget ]  Layer 1 per-dev, Layer 2 per-customer RPM/TPM
  |    429 rate_limit_error      (Retry-After: <s>)
  |    402 customer_budget_exceeded / customer_wallet_empty / wallet_empty
  v
[ model resolve ]  fc: / match: / slot: / dyn:  ->  provider+model
  |    400 invalid_model_ref
  v
[ vault ]  decrypt the provider key for this model
  v
[ provider adapter ]  one of 19 providers  (optionally @region)
  |
  v
[ metering ]  deduct from dev OR customer wallet, record ledger row
  |
  v
response 200
  X-Request-Id: req_a1b2c3d4e5f6a7b8
  X-Ringside-Wallet-Balance: 41.8800000
  X-Ringside-Wallet-Deducted: 0.0003120
  X-RateLimit-Remaining: 59
POST /v1/chat/completions, happy path. A failure at any gate returns a typed error with X-Request-Id.

The gateway

Every public call lands on api.fightclub.pro. The host-routing layer classifies the host and rewrites the public /v1/* path onto the physical route tree; the path you see in docs and the path that runs are the same /v1 either way. Two things happen at this edge before any database, JWT or HMAC work, so abuse is cheap to reject.

Per-IP edge limit (Layer 0)
A sliding per-IP window keyed off the nginx-set X-Real-IP header. X-Forwarded-For is deliberately ignored here because it is client-controlled. Over the limit returns 429 with code: edge_rate_limit_exceeded and a Retry-After header.
Body-size cap
File upload and audio routes accept up to 512 MB. Everything else (chat, embeddings, moderations) is capped at 4 MB and rejected with 413 payload_too_large before the body is read.
Request id
Every request gets a req_-prefixed id. It comes back as the X-Request-Id response header, never as a body field, and it is what support and your logs key on.

Authentication

The Authorization header parser branches on exactly two schemes. A developer key uses Bearer; a browser-minted short-lived token uses Client. Anything else is a 401 with the message "Authorization header must start with 'Bearer ' or 'Client '". The scheme word is what distinguishes the two paths, so a client token sent as Bearer will not authenticate.

The two credential types
Developer keyClient token
SchemeAuthorization: Bearer ko_<64 hex>Authorization: Client <jwt>
Where it livesYour server / CI secretA browser, short-lived
How it is madeDashboard at /app/api-keysPOST /v1/customers/{id}/client_tokens (needs scope api:client_tokens)
At restSHA-256 hashedStateless JWT, jti prefix ctok_
LifetimeUntil you revoke itTTL 60-3600s, default 900s
Pinned to a customerNoYes, the customer it was minted for

Developer keys carry scopes: api:chat, api:read, api:write, api:webhooks and api:client_tokens. A call to an endpoint the key cannot reach returns 403 insufficient_scope. Client tokens are different. They are not checked against the Bearer scope you would need; instead they are confined to a fixed allowlist of browser-safe endpoints, and the JWT can also pin an origin_allowlist (each entry up to 512 chars), an IP binding and its own RPM (default 60, max 600). A client token outside its allowlist gets 403 endpoint_not_allowed_for_client_token; over its RPM it gets 429 client_token_rate_limit_exceeded. This is rate-limit Layer 0.5, just under the per-developer and per-customer limits.

curl https://api.fightclub.pro/v1/chat/completions \
  -H "Authorization: Bearer ko_$RINGSIDE_KEY" \
  -H "FC-Customer: cus_42" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "fc:openai/gpt-4o-mini",
    "messages": [{"role": "user", "content": "ping"}]
  }'

Customer attribution

By default a call spends your developer wallet. Add the FC-Customer header and it spends that customer instead. The value can be the cus_ id, an ext:<external_id> reference or a plain external id string. If the value does not resolve to a known customer, Ringside lazy-creates one (capped at 100/hour and 1000/day per developer) unless you send FC-Customer-Strict: true, in which case an unknown customer is a 404 customer_not_found. The ext: form works on every customer subroute, so you can address customers by your own ids without ever storing the cus_ mapping.

monthly_budget_usd
A spend ceiling per calendar month. A call that would cross it returns 402 customer_budget_exceeded.
wallet (prepaid)
Topped up via /wallet/topup and adjusted via /wallet/adjust. An empty customer wallet returns 402 customer_wallet_empty; an empty developer wallet returns 402 wallet_empty.
rpm_limit / tpm_limit
Per-customer request- and token-per-minute ceilings, enforced as rate-limit Layer 2.
default_billable_markup_pct / default_billable_amount_usd
A resale price for what you charge this customer. They are mutually exclusive; setting both returns billable_defaults_mutex.

Rate limits and budget

Four limit layers stack, and the lowest one wins. A call has to pass all four to reach a provider.

  1. 01Layer 0, per-IP at the edge (the X-Real-IP window above).
  2. 02Layer 0.5, per-client-token RPM (Client mode only).
  3. 03Layer 1, per-developer.
  4. 04Layer 2, per-customer RPM/TPM.

On every response you get X-RateLimit-Remaining, the requests left in the current window. On a 429 you also get an integer Retry-After in seconds. There is no X-RateLimit-Limit or X-RateLimit-Reset header. Budget is a separate gate: it is a 402 (customer_budget_exceeded, customer_wallet_empty or wallet_empty), not a 429, so branch on the status and the code, never the human-readable message.

Always branch on code

Error bodies carry both a stable code and a human message. The message text can change; the code will not. Write your retry and fallback logic against error.code and the HTTP status.

Model resolution and the provider adapters

A bare model name is rejected with 400 invalid_model_ref. Every reference needs a prefix so the gateway knows how to resolve it.

Model reference forms
PrefixMeaningExample
fc:A pinned provider/modelfc:openai/gpt-4o-mini
match:A resolving constraint over a familymatch:anthropic/sonnet>=4
slot:A developer-defined aliasslot:cheap-summary
dyn:A dynamic profile chosen per requestdyn:fast

On a fc: reference you can append a data-residency suffix to keep inference in-region. The suffix attaches to fc: refs only; an unknown suffix is a 400.

Region suffixes (fc: refs only)
SuffixRoutes to
@eu / @usOpenAI in-region
@eu-bedrock / @us-bedrockClaude via AWS Bedrock (eu-west-1 / us-east-1)
@eu-vertex / @us-vertexClaude via Google Vertex (europe-west1 / us-east5)

Once resolved, the call goes to one of 19 provider adapters carrying the provider key decrypted from your vault. The response tells you what actually ran. On a match: ref X-Ringside-Model-Resolved reports the concrete model the constraint resolved to, and X-Ringside-Model-Newer-Available rides alongside it when a newer model exists outside that constraint. On a cost cascade (passing model as an array) X-Ringside-Model-Used, X-Ringside-Models-Tried and X-Ringside-Fallback-Triggered show the fallback path. A dyn: ref instead returns X-Ringside-Dynamic-Profile and X-Ringside-Dynamic-Reason.

Metering and the wallet ledger

The charge is recorded as the call returns. It deducts from the customer wallet when the call is attributed, otherwise from your developer wallet. Two headers report the outcome exactly: X-Ringside-Wallet-Balance is the balance after the deduction and X-Ringside-Wallet-Deducted is the precise charge. For resale you override the metered amount with the X-FC-Billable-Amount request header in micro-dollars; your margin (billable minus raw cost) is then readable per customer via GET /v1/customers/{id}/margin.

Chat completion metering and routing response headers
HeaderMeaning
X-Request-IdThe req_ id for this call, for tracing and support
X-Ringside-Model-ResolvedThe concrete provider model a match: ref resolved to (absent on a pinned fc: ref)
X-Ringside-Wallet-BalanceWallet balance after the deduction
X-Ringside-Wallet-DeductedExact amount charged for this call
X-RateLimit-RemainingRequests left in the current window
FC-Cache-Status / FC-Cache-Key / FC-Cache-AgeResponse-cache outcome when FC-Cache is in use
HTTP/1.1 200 OK
X-Request-Id: req_a1b2c3d4e5f6a7b8
X-Ringside-Wallet-Balance: 41.8800000
X-Ringside-Wallet-Deducted: 0.0003120
X-RateLimit-Remaining: 59

The per-user encrypted vault

Provider API keys never sit in plaintext on the server. Each developer has a vault encrypted with a key derived from a vault password the server never stores. The gateway decrypts the relevant provider key in-process only for the duration of a call. A locked or unconfigured vault means the gateway has no provider credential to present, so configure and unlock the vault before your first live call. See Provider keys and the vault for the unlock and rotation flow.

Managed RAG: vector stores

Ringside hosts the retrieval half of RAG so you do not run a vector database. A vector store (vs_) fixes its embedding_model at create time, ingests files asynchronously (status pending, in_progress, completed, failed or cancelled), and is queried through the OpenAI file_search tool inside an Assistants run by passing tool_resources.file_search.vector_store_ids. Stores are per-customer isolated; a cross-tenant id is a 404. Set graphrag_enabled: true and file_search also returns a facts[] array of {subject, predicate, object} relationships beside the chunk citations, billed as a per-GB-day line with the first 1 GB-day per store per day free.

Stores can be sealed. encryption is none, managed or byok; vector_sealing is none, source or full (it defaults to full when sealed and is ignored when none); vector_index is auto, flat or ivf, where ivf requires a sealed store. A byok store needs a {type: passphrase, passphrase} key of at least 12 chars that is never persisted: you take a short lease via POST .../unlock or pass the FC-Vector-Store-Key header, and the store idle-locks otherwise.

In-process workers

Several jobs run on intervals inside the web tier, started once at boot rather than per request. They are why webhooks deliver, batches finish and old data expires without a separate queue service.

  • ·Webhook delivery worker: signs and POSTs events to your endpoints, retries on failure and auto-disables an endpoint after a sustained failure streak (status disabled_after_failures).
  • ·Batch worker: drains queued batch jobs against /v1/chat/completions, /v1/embeddings or /v1/moderations at half the synchronous price.
  • ·Run-expiry worker: expires Assistants runs left in a non-terminal state.
  • ·Conversation lock sweeper: releases stale conversation locks (10 min TTL).
  • ·Retention sweeper: deletes data past its retention window.

Webhook deliveries are signed. The X-FC-Signature header carries t=<unix>,v1=<hex hmac_sha256>, where v1 is HMAC-SHA256 of the raw request body keyed by the endpoint secret (whsec_, shown once on create and on rotate). During a secret rotation the header carries two v1= digests, current and previous; accept the delivery if either matches. Verify in constant time and reject stale timestamps. See Webhooks for the event taxonomy and the verify helper.

Cross-cutting conventions

A handful of rules hold across every endpoint, so learn them once.

Platform-wide conventions
ConcernBehavior
VersioningThe /v1 path segment only. There is no version header.
Errors{ "error": { "type", "code", "message", "param"?, "retry_after_seconds"? } }. Branch on code.
Tenant isolationCross-tenant access returns 404, never 403, so ids are not enumerable.
Pagination{ "data": [...], "has_more": boolean, "next_cursor": string|null }. Use limit (default 20, max 100) and after (the previous next_cursor).
IdempotencySend the lowercase idempotency-key header. 24 h replay window, scoped to (developer, customer, key).
Id prefixescus_, file_, req_, ctok_, asst_, thread_, run_, vs_, batch_, conv_, wh_, chatcmpl-.
Idempotency and cascades do not mix

A model: [] cost-cascade combined with an idempotency-key returns 400 fallback_with_idempotency_unsupported. A cascade also cannot combine with stream: true (fallback_with_stream_unsupported) or a response_format json_schema. Pick a single resolving ref when you need idempotency or streaming.

Where state lives

Keep the ownership boundary clear. Your developer wallet, your developer key and your vault belong to you. Each customer wallet, budget, rate limit and resale price belongs to that customer record under you. The FC-Customer header on a call is the one switch that moves the spend from your wallet to a customer wallet; absent it, the call is on you. Margin is the gap between what you billed (the X-FC-Billable-Amount override or the customer default) and the raw provider cost, and it accrues to you.

Next steps