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 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.
| Scope | Grants |
|---|---|
api:chat | Chat completions, and implicitly embeddings, moderations and conversation-message calls. |
api:read | GET endpoints (list and read operations). |
api:write | Customer create, update, archive and hard-delete. |
api:webhooks | Webhook management endpoints. |
api:client_tokens | Minting and revoking client tokens for your customers. |
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
- 01Open /app/api-keys in the dashboard.
- 02Give the key a name and select the scopes it needs.
- 03Copy the full
ko_...value shown on the confirmation. This is the only time you will see it. - 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 |
|<--------------------------------------------------|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.
| Body field | Type | Default | Bounds |
|---|---|---|---|
ttl_seconds | integer | 900 | 60 to 3600 inclusive. |
scope | string[] | ["chat"] | Subset of chat, conversations, embeddings, moderations, assistants_threads. |
rpm_limit | integer | 60 | Positive, max 600, and not above the customer's own rpm_limit. |
origin_allowlist | string[] | none | Each entry non-empty, max 512 chars. Duplicates are stripped. |
bind_ip | boolean | false | When 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"]
}'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.
| Token scope | Reachable endpoints |
|---|---|
chat | POST /v1/chat/completions |
embeddings | POST /v1/embeddings |
moderations | POST /v1/moderations |
conversations | Read one conversation, send and list its messages, all under /v1/customers/{id}/conversations/... |
assistants_threads | Create 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}. Needsapi:client_tokens. Idempotent, returns 204 whether or not it was already revoked. An unknown or cross-tenantjtireturns 404. - ·Revoke all for a customer:
POST /v1/customers/{id}/client_tokens/revoke_all. Needsapi:client_tokens. Returns{ "revoked_count": <n> }. - ·List active tokens:
GET /v1/customers/{id}/client_tokens. Needsapi:read. Returns a{ "data", "has_more", "next_cursor" }envelope where each row carries jti, scope, created_at, expires_at, rpm_limit, origin_allowlist andip_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 key | Client token | |
|---|---|---|
| Scheme | Authorization: Bearer | Authorization: Client |
| Format | ko_ + 64 hex | EdDSA JWT, jti prefix ctok_ |
| Lifetime | Long-lived (optional expiry) | 60 to 3600s, default 900s |
| Where it lives | Your server only | Mintable to a browser |
| Pinned to a customer | No | Yes, exactly one |
| Scopes | api:chat / api:read / api:write / api:webhooks / api:client_tokens | chat / conversations / embeddings / moderations / assistants_threads |
| Endpoint reach | Any endpoint its scopes allow | Fixed browser-safe allowlist |
| Stored at rest | SHA-256 hash | Not stored; only a jti registry row |
| Revoke | Delete in /app/api-keys | DELETE by jti or revoke_all |
| Extra controls | Wallet, customer budget | Origin 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"
}
}| Status | type | code | When |
|---|---|---|---|
| 401 | authentication_error | missing_token | No Authorization header at all. |
| 401 | authentication_error | invalid_auth_scheme | Header does not start with Bearer or Client . |
| 401 | authentication_error | invalid_token_format | Bearer value does not start with ko_. |
| 401 | authentication_error | invalid_token | Unknown, revoked or expired developer key. |
| 401 | authentication_error | invalid_client_token | Client JWT failed verification, or its jti is revoked, expired or unrecognized. |
| 403 | permission_error | insufficient_scope | The credential lacks the scope the endpoint needs. |
| 403 | permission_error | endpoint_not_allowed_for_client_token | A client token hit an endpoint outside its allowlist. |
| 403 | permission_error | origin_not_allowed | Origin header missing or not in the token allowlist. |
| 403 | permission_error | ip_binding_violation | Caller IP does not match an IP-bound token. |
| 429 | rate_limit_error | client_token_rate_limit_exceeded | Per-token RPM exceeded. Carries retry_after_seconds. |
| 429 | rate_limit_error | mint_rate_limit | More than 100 mints per minute for one customer. |
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 realorigin_allowlistand, for high-value flows,bind_ip. A token that can onlychat, lives 10 minutes and only works from your app origin is a small blast radius. - ·Keep the
api:client_tokensscope on a dedicated key. A holder of that scope can mint browser credentials for every one of your customers. - ·Log the
X-Request-Idof failed auth calls. It is the fastest way to correlate a 401 or 403 with server-side telemetry.
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.