Prefer Swagger UI? Click hereThe same API in the classic OpenAPI explorer.
// best practice · 9 min read

Security

Ringside hands you two very different credentials (a long-lived developer key and a short-lived browser token) plus tenant isolation, webhook signatures and BYOK sealed stores. This page is the production checklist: where each credential is allowed to live, how to scope and rotate it, how to verify a webhook came from us and how to keep one customer from reading another customer's data.

Most Ringside security incidents come from one mistake. Someone ships a ko_ developer key to a browser, a mobile app or a public repo. That key carries your whole account. It can spend your wallet, read every customer, mint client tokens and register webhooks. Treat it like a database password, never an API token you paste into frontend code.

The platform gives you a second credential class precisely so the long-lived key never has to leave your server. A client token is a short-lived, origin-locked, customer-pinned JWT you mint on the backend and hand to one browser session. This page walks the threat model for each credential, then covers webhook verification, tenant isolation and data residency. Read it once before you go to production.

Two credentials, two trust levels

Ringside authenticates a request by the Authorization scheme word, not by a key flavour. Bearer ko_<key> is your developer key. Client <jwt> is a minted client token. The header parser checks for Bearer first, then Client ; anything else returns 401 with the message "Authorization header must start with 'Bearer ' or 'Client '". There is no test-vs-live key split, so a leaked key is live by definition.

Credential comparison
PropertyDeveloper key (`ko_`)Client token (`Client` JWT)
SchemeAuthorization: Bearer ko_<64 hex>Authorization: Client <jwt>
LifetimeUntil you revoke it60-3600s (default 900s)
Where it livesServer only, secret managerOne browser session
Scopesapi:chat, api:read, api:write, api:webhooks, api:client_tokenschat, conversations, embeddings, moderations, assistants_threads
Customer bindingNone (acts across all your customers)Pinned to one cus_ at mint time
Endpoint reachFull API surface (per scope)A fixed browser-safe allowlist
At restSHA-256 hashed; raw shown once on createJWT never stored; only jti in a revocation registry
Never expose a ko_ key client-side

A ko_ key in browser JavaScript, a mobile bundle, a Postman collection you share or a committed .env is a full-account compromise. If one reaches any of those places, rotate it immediately (see Rotation below) and assume everything it could touch is exposed.

Minting client tokens for browsers

When a browser needs to talk to the API directly (a chat widget, a support console, an in-app assistant) you mint a client token server-side and pass only the JWT to the page. The mint call needs a developer key holding the api:client_tokens scope. A key without that scope gets 403 insufficient_scope.

curl https://api.fightclub.pro/v1/customers/cus_42/client_tokens \
  -H "Authorization: Bearer ko_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "ttl_seconds": 900,
    "scope": ["chat"],
    "rpm_limit": 60,
    "origin_allowlist": ["https://app.example.com"],
    "bind_ip": true
  }'

The returned JWT has a ctok_-prefixed jti, is signed with EdDSA (Ed25519) and carries iss, aud, exp, nbf, the pinned customer_id and the scope array. The browser then sends Authorization: Client <jwt> on every call. The Client scheme is the only thing that tells the API this is a client token and not a developer key.

Mint parameters and their bounds

FieldDefaultBounds / rules
ttl_seconds900integer, 60-3600. Out of range returns invalid_ttl.
scope["chat"]Subset of the five client scopes. Unknown entry returns invalid_scope; empty array returns empty_scope.
rpm_limit601-600, and also clamped to the customer rpm_limit if set. Above the cap returns rpm_exceeds_customer_cap.
origin_allowlistnoneArray of strings, each <=512 chars. Duplicates stripped. Bad entry returns invalid_origin_allowlist.
bind_ipfalseWhen true, the mint records sha256(customer_id | ip). Missing IP returns bind_ip_missing_client_ip.

Minting is rate-limited to 100 calls per minute per customer, so a fresh token per page load is fine but a tight mint loop is not. Pick the shortest TTL that survives a user session. A widget that reconnects every few minutes does not need an hour-long token.

What the API enforces at the edge

A client token is checked on every request, in this order. Each check below maps to a specific failure code, all with error.type of permission_error (or rate_limit_error for the last one).

Endpoint allowlist
The token can only reach a fixed set of browser-safe routes. Anything else returns 403 endpoint_not_allowed_for_client_token. The developer key's required scope is deliberately not consulted; the allowlist row is the authority.
Scope on the JWT
The matched route's scope must appear in the JWT scope[], else 403 insufficient_scope.
Origin allowlist
If you set origin_allowlist, the incoming Origin header must match an entry. A request with no Origin header is also rejected (403 origin_not_allowed). That blocks curl-style replay of a stolen token from outside a browser.
IP binding
If you set bind_ip, the request source IP must hash to the recorded value, else 403 ip_binding_violation. An undeterminable client IP fails the same way.
Per-token RPM
Each jti has its own request-per-minute budget. Over it returns 429 client_token_rate_limit_exceeded with Retry-After and retry_after_seconds.
Origin allowlist plus IP binding

For a token that should only ever run inside your own web app, set both origin_allowlist (your app origin) and bind_ip: true. A token lifted from the browser then fails origin if replayed server-side and fails IP binding if the attacker is on a different network. Neither is a substitute for a short TTL.

Scope keys least-privilege

Developer keys carry api:* scopes. Mint one key per job and grant only what that job needs. A backend that only ever calls chat does not need api:write or api:webhooks. A key whose sole purpose is issuing browser tokens needs exactly api:client_tokens and nothing else.

Developer key scopes
ScopeGrants
api:chatChat completions, and implicitly embeddings, moderations and conversation-message calls
api:readGET endpoints (lists, fetches)
api:writeCustomer create / update / archive / hard-delete
api:webhooksWebhook endpoint management
api:client_tokensMinting client tokens

A request whose key lacks the route's scope returns 403 with error.code of insufficient_scope. Branch on code, never on the human message. The error body shape is { "error": { "type", "code", "message" } } across the whole API, with param added on field-validation errors and retry_after_seconds on rate-limit errors.

Rotation

Rotate on any suspicion of exposure, on staff departure and on a schedule. Two things rotate independently: developer keys and webhook secrets.

Developer keys

  1. 01Create a fresh key in the dashboard at /app/api-keys. The raw ko_ value is shown once; copy it straight into your secret manager.
  2. 02Deploy the new key, confirm traffic flows under it (watch /app/logs).
  3. 03Delete the old key. Because keys are SHA-256 hashed at rest, deletion is the only revocation; there is no grace window on a developer key.

Client tokens

You rarely rotate a single client token; you revoke it. Each minted jti lives in a revocation registry. Revoking flips revoked_at, and the next request with that token gets the same 401 invalid_client_token an unknown or expired token gets, so nobody can probe the registry to enumerate live jti values. You can also bulk-revoke every active token for a customer in one call (the usual response to a suspected session hijack). Tokens expire on their own at TTL regardless.

Webhook secrets

Webhook secrets (whsec_) rotate with a grace window so you never drop an event mid-flip. POST /v1/webhooks/{id}/rotate_secret issues a new secret, moves the old one to a previous-secret slot and keeps it valid for the grace period (default 30 min, max 1440 min / 24 h). During the grace window each delivery carries two v1= digests in the signature header, one per secret. Verify against either. The new whsec_ is returned exactly once, here and on create.

curl https://api.fightclub.pro/v1/webhooks/wh_8f3c.../rotate_secret \
  -H "Authorization: Bearer ko_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{ "grace_minutes": 60 }'

Verifying webhook signatures

Every delivery carries X-FC-Signature: t=<unix>,v1=<hex>. The v1 digest is HMAC-SHA256(secret, "<t>.<raw_body>") in hex. Two rules decide whether a payload is genuine: the HMAC must match and the timestamp must be fresh. Get either wrong and you have a webhook handler that a forged or replayed POST can trigger.

  1. 01Read the RAW request body. Compute over the exact bytes you received; a re-serialized JSON body will not match.
  2. 02Parse t and every v1= digest from the header. During a rotation grace there are two digests; accept the payload if either matches.
  3. 03Recompute HMAC-SHA256(secret, t + "." + body) and compare constant-time. Use a timing-safe equality, not ===.
  4. 04Reject when |now - t| exceeds your tolerance. Ringside's own verifier uses a 300-second (5 min) window. Match it or go tighter.
import crypto from 'crypto';

function verify(rawBody, header, secret, prevSecret, toleranceSec = 300) {
  const parts = header.split(',').map((p) => p.trim());
  const t = Number(parts.find((p) => p.startsWith('t='))?.slice(2));
  const digests = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false; // stale

  const candidates = [secret, prevSecret].filter(Boolean).map((s) =>
    crypto.createHmac('sha256', s).update(`${t}.${rawBody}`).digest('hex'),
  );
  return digests.some((d) =>
    candidates.some(
      (c) => c.length === d.length &&
        crypto.timingSafeEqual(Buffer.from(c, 'hex'), Buffer.from(d, 'hex')),
    ),
  );
}
Stale timestamps are not optional

Without the timestamp check, an attacker who captures one valid delivery (a logged proxy, a leaked replay) can resend it forever. The HMAC stays valid because the body is unchanged. The t window is what closes that hole, so reject anything outside 300 seconds.

Registration is hardened too. A webhook url must be https and must not resolve to a private, loopback or cloud-metadata host; a private target is rejected at create time (SSRF egress control), so the delivery worker cannot be turned into a probe of your internal network. A sustained delivery-failure streak auto-disables the endpoint (status: disabled_after_failures); re-enable it with a PATCH to active, which also clears the streak.

Tenant isolation: 404, not 403

Every customer-scoped resource is keyed by your developer id and the customer id. Reach for an id you do not own (or that does not exist) and you get 404 not_found_error, never 403. The two cases are deliberately indistinguishable. A 403 would confirm the id exists, which lets an attacker enumerate cus_, vs_, wh_ and other ids by probing. The same rule covers client tokens: an unknown, revoked, expired or cross-tenant jti all collapse to one 401.

You get this for free. Do not add your own 403-on-wrong-owner branch in a proxy or gateway in front of Ringside; it reintroduces the enumeration oracle the platform spent effort removing.

BYOK sealed stores for regulated data

For data under a residency or regulatory mandate, set encryption: "byok" on a vector store and supply a passphrase the platform never persists. The passphrase derives the store key; you unlock the store per session with POST /v1/vector_stores/{id}/unlock or the FC-Vector-Store-Key header, and it idle-locks otherwise. Pair it with vector_sealing: "full" (the default once sealed) so the embedding vectors are sealed at rest alongside the source text. Full detail is on the sealed RAG page.

A BYOK passphrase has no recovery

The passphrase (minimum 12 chars) is never stored. Lose it and the sealed data is unreadable. Hold it in your own secret manager with the same care as the data it protects.

Pin regions for data residency

Where a model runs is a security control when residency is in scope. Attach an @<region> suffix to a pinned fc: model ref to force a region. The suffix works on fc: refs only; a match:, slot: or dyn: ref cannot carry one, and an unknown suffix returns 400.

Residency suffixes
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)
{
  "model": "fc:anthropic/claude-sonnet-4@eu-bedrock",
  "messages": [{ "role": "user", "content": "..." }]
}

Production security checklist

Run through this before launch and on every audit
ControlDo thisVerify
Developer key locationKey lives only in a server-side secret managerGrep your frontend bundle and git history for ko_
Browser accessMint short-lived Client tokens; never ship a ko_ keyNetwork tab shows Authorization: Client, not Bearer
Token TTLShortest TTL that survives a session (default 900s)Mint payload ttl_seconds <= session length
Origin lockSet origin_allowlist to your app originsA curl with no Origin gets 403 origin_not_allowed
IP bindingSet bind_ip: true for fixed-network clientsReplay from another IP gets 403 ip_binding_violation
Key scopesOne key per job, least-privilege api:*Token-mint key has only api:client_tokens
Key rotationRotate on suspicion and on scheduleOld key deleted; new traffic in /app/logs
Webhook secret rotationRotate with grace; accept both digestsDual v1= digests verify during grace
Webhook verificationConstant-time HMAC over raw bodyForged body and stale t both rejected
Webhook URLHTTPS, public host onlyPrivate-host URL rejected at registration
Tenant isolationRely on 404-not-403; add no owner checkCross-tenant fetch returns 404
Regulated dataBYOK sealed store; passphrase in your vaultStore idle-locks without an unlock lease
ResidencyPin @<region> on fc: refsX-Ringside-Model-Used shows the in-region route

One closing rule. Always read the X-Request-Id response header and log it. When you open a support ticket about a 403 or 404 you cannot explain, that id is how we find the exact request without you having to share a key or a payload.