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

Authentication

Ringside has two ways to authenticate a request, and they exist for two different trust models. A developer API key (ko_...) is a long-lived secret that belongs to your server and grants the full scope set you granted it. A client token is a short-lived, per-customer JWT you mint server-side and hand to a browser so that frontend code can call a narrow allowlist of endpoints without ever holding your real key. This page covers both schemes end to end: header formats, scopes, lifetimes, the mint and revoke flows, every error code you can hit and how to handle a leak.

Every request to https://api.fightclub.pro/v1 carries an Authorization header. The first word of that header picks the scheme. Bearer means a developer API key. Client means a minted client token. Anything else is rejected before any route logic runs.

Authorization: Bearer ko_<64-hex>     # developer key, server-side
Authorization: Client <jwt>           # client token, browser-side
The scheme word is load-bearing

The header parser checks for the literal prefix Bearer and then Client . If the header starts with neither, the request fails with 401 and the message "Authorization header must start with 'Bearer ' or 'Client '". A common mistake is sending a minted JWT with the Bearer scheme. That fails: a JWT is not a ko_ key, so the Bearer branch rejects it on the format check.

Developer API keys

A developer key is the credential your backend uses. It is the literal string ko_ followed by 64 hex characters, so the full key is 67 characters. The plaintext is shown to you exactly once, at creation. The server keeps only a SHA-256 hash of it plus an 11-character prefix (ko_ plus the first 8 hex) for display in the dashboard. There is no test-vs-live split. A key is a key, and which models and customers it can touch is decided by its scopes and your wallet, not by a mode flag.

Format
ko_ + 64 lowercase hex characters (32 random bytes). Total length 67.
Header
Authorization: Bearer ko_<key>.
At rest
SHA-256 of the full token. The plaintext is never stored and cannot be recovered.
Created
In the dashboard at /app/api-keys. The full key is displayed once on create.
Revoked
Hard-deleted from the same page. Revocation is immediate; the next request with that key returns 401.
Name
A label up to 80 characters, for your own bookkeeping. Not part of auth.
Expiry
Optional. A key can carry an expires_at; an expired key authenticates as invalid.

Key scopes

A developer key carries one or more scopes. You pick them at create time, and at least one is required. Each endpoint declares the scope it needs; the auth layer checks that your key grants it before the route runs. The admin scope, if present, satisfies any required scope.

Ringside developer scopes. Scope on a request is satisfied by an exact match or by `admin`.
ScopeGrants
api:chatChat completions, and implicitly embeddings, moderations and conversation-message calls.
api:readGET endpoints (list and read operations).
api:writeCustomer create, update, archive and hard-delete.
api:webhooksWebhook management endpoints.
api:client_tokensMinting and revoking client tokens for your customers.
Tip

Grant the narrowest set that works. A key that only needs to send chat traffic should hold api:chat alone. Keep the api:client_tokens scope on a separate, tightly held key, because that scope is what lets a holder mint browser credentials for any of your customers.

Creating a key

  1. 01Open /app/api-keys in the dashboard.
  2. 02Give the key a name and select the scopes it needs.
  3. 03Copy the full ko_... value shown on the confirmation. This is the only time you will see it.
  4. 04Store it in your server's secret manager. Never commit it, never ship it to a client.
curl https://api.fightclub.pro/v1/chat/completions \
  -H "Authorization: Bearer ko_3f9a...d21c" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "fc:openai/gpt-4o-mini",
    "messages": [{ "role": "user", "content": "ping" }]
  }'

Client tokens

You should never ship a ko_ key to a browser. Anyone who opens devtools would read it and then has your full key for as long as it lives. Client tokens cover that case. Your server mints a short-lived JWT, pinned to one customer, scoped to a handful of browser-safe endpoints, and hands that to the frontend. If it leaks, it expires on its own in minutes and only ever had access to one customer's narrow surface.

A client token is an EdDSA-signed (Ed25519) JWT. The platform never stores the JWT itself, only a revocation-registry row keyed by its jti (which carries the ctok_ prefix). The browser presents the token with the Client scheme. That scheme word, not the token contents, is what tells the auth layer this is a client token and not a developer key.

  Browser                Your server               Ringside
    |                        |                          |
    |  session request       |                          |
    |----------------------->|                          |
    |                        |  POST /v1/customers/      |
    |                        |   {id}/client_tokens      |
    |                        |  Authorization: Bearer ko_|
    |                        |------------------------->|
    |                        |   { token (jwt), jti,     |
    |                        |     expires_at, scope }   |
    |                        |<-------------------------|
    |   the jwt only         |                          |
    |<-----------------------|                          |
    |                                                   |
    |  POST /v1/chat/completions                        |
    |  Authorization: Client <jwt>                      |
    |-------------------------------------------------->|
    |   completion                                      |
    |<--------------------------------------------------|
Mint server-side, present client-side.

Minting a token

Mint with POST /v1/customers/{id}/client_tokens. The call is Bearer-only and the key must hold api:client_tokens. The customer id can be a cus_ id or ext:<external_id>. Minting is rate-limited to 100 per minute per customer; exceed it and you get 429 with code mint_rate_limit and a Retry-After: 60 header.

Request body for POST /v1/customers/{id}/client_tokens.
Body fieldTypeDefaultBounds
ttl_secondsinteger90060 to 3600 inclusive.
scopestring[]["chat"]Subset of chat, conversations, embeddings, moderations, assistants_threads.
rpm_limitinteger60Positive, max 600, and not above the customer's own rpm_limit.
origin_allowliststring[]noneEach entry non-empty, max 512 chars. Duplicates are stripped.
bind_ipbooleanfalseWhen true the token is pinned to the caller IP at mint time.
curl https://api.fightclub.pro/v1/customers/cus_42/client_tokens \
  -H "Authorization: Bearer ko_3f9a...d21c" \
  -H "Content-Type: application/json" \
  -d '{
    "ttl_seconds": 600,
    "scope": ["chat"],
    "rpm_limit": 30,
    "origin_allowlist": ["https://app.example.com"]
  }'
origin_allowlist is strict

When a token pins an origin allowlist, every request must carry an Origin header that exactly matches one entry. A missing Origin header is also rejected. That is deliberate. The allowlist is browser CORS-lite enforcement, and a curl call with no Origin is not trusted when a list is set. Requests that fail this check return 403 with code origin_not_allowed.

What a client token can reach

Client tokens fail closed. They can only reach a fixed allowlist of browser-safe endpoints, and each endpoint requires the matching scope to be present in the token. Everything else (customer CRUD, webhooks, minting more tokens, anything Bearer-only) returns 403. The required developer-key scope of the underlying route is ignored for client tokens; the allowlist row is the sole authority.

Client-token scope-to-endpoint allowlist. A scope grants only its own rows.
Token scopeReachable endpoints
chatPOST /v1/chat/completions
embeddingsPOST /v1/embeddings
moderationsPOST /v1/moderations
conversationsRead one conversation, send and list its messages, all under /v1/customers/{id}/conversations/...
assistants_threadsCreate and read threads, post and read thread messages, create runs, read run state, cancel runs, submit tool outputs, read run steps.

Per-token controls at request time

Beyond scope, three controls are checked on every client-token request, in this order: origin allowlist, then IP binding, then per-token RPM. IP binding compares sha256(customer_id | client_ip) against the hash recorded at mint; a mismatch (or an undeterminable source IP) returns 403 ip_binding_violation. The per-token RPM is its own rate-limit layer that sits above the customer-level limits, so a single noisy browser session cannot burn the customer's whole budget. Exceed it and you get 429 client_token_rate_limit_exceeded with a retry_after_seconds field and a Retry-After header.

curl https://api.fightclub.pro/v1/chat/completions \
  -H "Authorization: Client eyJhbGciOiJFZERTQSI...0kq" \
  -H "Origin: https://app.example.com" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "fc:openai/gpt-4o-mini",
    "messages": [{ "role": "user", "content": "hello" }]
  }'

Revoking client tokens

Tokens self-expire at ttl_seconds, but you can kill one early. All three management routes are Bearer-only. The two revoke routes need api:client_tokens; the list route needs api:read.

  • ·Revoke one: DELETE /v1/customers/{id}/client_tokens/{jti}. Needs api:client_tokens. Idempotent, returns 204 whether or not it was already revoked. An unknown or cross-tenant jti returns 404.
  • ·Revoke all for a customer: POST /v1/customers/{id}/client_tokens/revoke_all. Needs api:client_tokens. Returns { "revoked_count": <n> }.
  • ·List active tokens: GET /v1/customers/{id}/client_tokens. Needs api:read. Returns a { "data", "has_more", "next_cursor" } envelope where each row carries jti, scope, created_at, expires_at, rpm_limit, origin_allowlist and ip_bound. Never the token itself.
curl -X DELETE \
  https://api.fightclub.pro/v1/customers/cus_42/client_tokens/ctok_9c1d4f7a2b6e8051c3aa19fd \
  -H "Authorization: Bearer ko_3f9a...d21c"
# -> 204 No Content

curl -X POST \
  https://api.fightclub.pro/v1/customers/cus_42/client_tokens/revoke_all \
  -H "Authorization: Bearer ko_3f9a...d21c"
# -> { "revoked_count": 3 }

Comparing the two modes

Developer keyClient token
SchemeAuthorization: BearerAuthorization: Client
Formatko_ + 64 hexEdDSA JWT, jti prefix ctok_
LifetimeLong-lived (optional expiry)60 to 3600s, default 900s
Where it livesYour server onlyMintable to a browser
Pinned to a customerNoYes, exactly one
Scopesapi:chat / api:read / api:write / api:webhooks / api:client_tokenschat / conversations / embeddings / moderations / assistants_threads
Endpoint reachAny endpoint its scopes allowFixed browser-safe allowlist
Stored at restSHA-256 hashNot stored; only a jti registry row
RevokeDelete in /app/api-keysDELETE by jti or revoke_all
Extra controlsWallet, customer budgetOrigin allowlist, IP binding, per-token RPM

Errors

Auth failures use the standard error envelope. Always branch on the machine-readable code, never on the human message, which can change. The request id is the X-Request-Id response header, never a body field; quote it when you open a support ticket.

{
  "error": {
    "type": "authentication_error",
    "code": "invalid_token",
    "message": "Token is invalid or expired"
  }
}
Authentication and authorization error codes.
StatustypecodeWhen
401authentication_errormissing_tokenNo Authorization header at all.
401authentication_errorinvalid_auth_schemeHeader does not start with Bearer or Client .
401authentication_errorinvalid_token_formatBearer value does not start with ko_.
401authentication_errorinvalid_tokenUnknown, revoked or expired developer key.
401authentication_errorinvalid_client_tokenClient JWT failed verification, or its jti is revoked, expired or unrecognized.
403permission_errorinsufficient_scopeThe credential lacks the scope the endpoint needs.
403permission_errorendpoint_not_allowed_for_client_tokenA client token hit an endpoint outside its allowlist.
403permission_errororigin_not_allowedOrigin header missing or not in the token allowlist.
403permission_errorip_binding_violationCaller IP does not match an IP-bound token.
429rate_limit_errorclient_token_rate_limit_exceededPer-token RPM exceeded. Carries retry_after_seconds.
429rate_limit_errormint_rate_limitMore than 100 mints per minute for one customer.
Revoked, expired and unknown look the same

For client tokens, the revocation registry collapses "unknown jti", "revoked" and "expired" into one identical 401 invalid_client_token. A caller cannot tell which condition tripped, so the registry can't be used to enumerate token ids. Cross-tenant access to a key or a token id returns 404 (not_found_error) rather than 403, for the same anti-enumeration reason.

Security guidance

  • ·Never put a ko_ key in client-side code, a mobile app bundle, a public repo or a URL. Treat it like a database password.
  • ·For anything a browser calls, mint a client token server-side and ship only the JWT. The browser never sees the developer key.
  • ·On a suspected leak of a developer key, revoke it in /app/api-keys and create a fresh one. Revocation takes effect on the next request. There is no grace window to manage because the key is gone the moment you delete it.
  • ·Scope client tokens tightly: short ttl_seconds, the minimum scope set, a real origin_allowlist and, for high-value flows, bind_ip. A token that can only chat, lives 10 minutes and only works from your app origin is a small blast radius.
  • ·Keep the api:client_tokens scope on a dedicated key. A holder of that scope can mint browser credentials for every one of your customers.
  • ·Log the X-Request-Id of failed auth calls. It is the fastest way to correlate a 401 or 403 with server-side telemetry.
Warning

There is no automatic key rotation and no key recovery. If you delete a key you cannot get it back, and if you lose the plaintext you must create a new one. Mint keys per environment so revoking a leaked staging key never touches production traffic.

Versioning note

The only API version selector is the /v1 path segment. There is no version header. If you have used Stripe, note that there is no Stripe-Version equivalent here; pin behavior through the path alone.