Prefer Swagger UI? Click hereThe same API in the classic OpenAPI explorer.
// how-to · 8 min read

Create and manage customers

A customer is a first-class billing principal under your developer account: the end-user, tenant or seat you resell Ringside to. This guide walks the full lifecycle, from provisioning one with a budget, through attributing every metered call to it, reading its spend and margin, funding a prepaid wallet, to archiving it. Every id, header and error code here is the real one from the route code.

What a customer is, and why

When you resell Ringside you usually have your own end-users underneath you: the tenants of your SaaS, or the individual seats in an org. A cus_ record is how Ringside represents one of those. It is the unit you attribute spend to, cap with a monthly budget, rate-limit, fund with a prepaid wallet and report margin on.

Two wallets sit in the picture. Your developer wallet is what Ringside charges you, the raw provider cost plus platform markup. A customer wallet is an optional prepaid balance you hold on behalf of that end-user. Attributing a call to a customer does not move money out of your developer wallet by default; it tags the usage, enforces that customer's budget and rate limits and, when the customer wallet is enabled, debits the customer wallet for the billable amount you set. Keep that distinction straight and the rest of this page reads cleanly.

Mental model

A customer is metadata plus three guardrails (monthly budget, rpm/tpm limits, prepaid wallet) plus a margin ledger. Create it once, then carry one header (FC-Customer) on every call you want attributed to it.

The customer object

Every field below is settable on POST /v1/customers and patchable on PATCH /v1/customers/{id}. The id is server-minted as cus_ followed by 24 hex chars and is immutable.

Customer fields. Read-only fields on the response: id, status, wallet_balance_usd, lifetime_spend_usd, lifetime_dev_revenue_usd, created_at, updated_at, last_active_at.
FieldTypeNotes
external_idstringYour own id for this user (a UUID, a row id, an email). Address the customer later as ext:<external_id>. Unique per active record: creating with an external_id that already maps to an active customer returns that customer unchanged (idempotent).
display_namestringHuman label for dashboards and reports. No uniqueness constraint.
monthly_budget_usdnumber >= 0Calendar-month spend ceiling on attributed cost. At or over it, attributed calls return 402 customer_budget_exceeded. Your developer wallet is untouched. 0 or null means no ceiling.
rpm_limitinteger > 0Per-customer requests-per-minute cap. Null means no per-customer rpm cap (your developer and edge limits still apply).
tpm_limitinteger > 0Per-customer tokens-per-minute cap, a rolling 60s sum. Null means none.
default_billable_markup_pctnumber >= 0Resale markup applied to this customer's metered cost when you do not send X-FC-Billable-Amount per call. Mutually exclusive with the next field.
default_billable_amount_usdnumber >= 0Fixed billable amount per call instead of a markup. Sending both this and the markup returns 400 billable_defaults_mutex.
metadataobjectFree-form JSON. Max 4 KB serialized, max 16 keys, key <= 64 chars, value <= 512 chars.
The billable-defaults mutex

A customer can carry a markup percentage or a fixed amount, never both. The constraint lives in the database, so the conflict surfaces as 400 invalid_request_error / billable_defaults_mutex on both create and update. Pick one resale model per customer.

Create a customer

Authenticate with your developer key (Authorization: Bearer ko_<64 hex>). The create call needs the api:write scope. Here is a customer with an external id and a $50 monthly ceiling.

curl https://api.fightclub.pro/v1/customers \
  -H "Authorization: Bearer ko_REDACTED" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "tenant_8842",
    "display_name": "Acme Robotics",
    "monthly_budget_usd": 50,
    "rpm_limit": 120,
    "metadata": { "plan": "pro", "region": "eu" }
  }'

wallet_balance_usd: null means no prepaid wallet is enabled yet. We turn one on later in this guide. Note that create returns 200, not 201, and is idempotent on external_id: re-running the same POST with tenant_8842 returns the same cus_ row and does not fire a second customer.created webhook.

You often do not need to create explicitly

Send FC-Customer: tenant_8842 on a chat call and Ringside lazy-creates the customer on first sight, using your account-level billable defaults. Explicit creation is for when you want to set a budget, name or metadata up front, or when you run with lazy-create disabled.

Attribute calls with FC-Customer

Attribution is one request header on any metered endpoint (chat, embeddings, audio, image, moderations, runs, file_search). The header accepts three forms, checked in this order:

The `cus_` id
FC-Customer: cus_3f1a9c0b7e2d4a6f8b1c2d3e. Resolved by id, scoped to your developer account.
An `ext:` reference
FC-Customer: ext:tenant_8842. Resolved against the active record whose external_id is tenant_8842. The ext: form works on every customer subroute too (usage, margin, wallet).
A bare external id
FC-Customer: tenant_8842. Same lookup as ext:, and the value that gets lazy-created as a new external_id if no match exists and strict mode is off.

On chat, FC-Customer takes priority over the OpenAI-compat user body field for attribution. A worked call:

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

The response carries X-Ringside-Wallet-Balance and X-Ringside-Wallet-Deducted so you can see the balance and the exact charge for that call. Read X-Request-Id from the response headers for support and tracing; the request id is never a body field.

Strict mode: turn off lazy-create

By default an unknown FC-Customer value is lazy-created. If you provision customers yourself and want an unknown id to fail loudly instead of silently spawning a record, send FC-Customer-Strict: true on the request. A value of true or 1 forces strict; false or 0 forces lazy-create on; absent falls back to your account-level lazyCreateCustomers setting.

curl https://api.fightclub.pro/v1/chat/completions \
  -H "Authorization: Bearer ko_REDACTED" \
  -H "FC-Customer: ext:tenant_does_not_exist" \
  -H "FC-Customer-Strict: true" \
  -H "Content-Type: application/json" \
  -d '{ "model": "fc:openai/gpt-4o-mini", "messages": [{"role":"user","content":"hi"}] }'
Lazy-create has its own rate limits

Even with lazy-create on, Ringside caps auto-provisioning at 100 new customers per hour and 1000 per day per developer. Past that you get 429 lazy_create_rate_exceeded. Bulk-provision through POST /v1/customers instead when you expect a spike of new tenants.

Read usage and margin

Two read endpoints answer the two questions you actually have: how much did this customer use, and how much did I make on them.

Usage

GET /v1/customers/{id}/usage returns metered usage for the customer. Group it with group_by, one of day (default), model, tag, property or conversation. group_by=property requires a property_key query param. Date bounds are from and to; an unparseable date returns 400 invalid_date with the offending param.

curl "https://api.fightclub.pro/v1/customers/cus_3f1a9c0b7e2d4a6f8b1c2d3e/usage?group_by=model&from=2026-06-01&to=2026-06-24" \
  -H "Authorization: Bearer ko_REDACTED"

Margin

GET /v1/customers/{id}/margin computes margin = billable revenue minus your charged cost, per bucket. group_by here is day (default), customer or model. Only successful (status=ok) events contribute; blocked and errored calls carry neither revenue nor cost. The default window is the last 30 days when you omit from/to. margin_pct is null when revenue is zero, so you do not get noisy 0/0 percentages.

curl "https://api.fightclub.pro/v1/customers/cus_3f1a9c0b7e2d4a6f8b1c2d3e/margin?group_by=day" \
  -H "Authorization: Bearer ko_REDACTED"

The revenue side comes from what you bill: either X-FC-Billable-Amount set per call (a non-negative number, overriding the metered amount) or the customer's default markup/amount. If you never set a billable, revenue is zero and margin is just the negative of your cost. To resell at a margin you must configure one or the other.

Top up a prepaid wallet

A customer wallet is a prepaid balance Ringside debits per attributed call (for the billable amount). It starts disabled (wallet_balance_usd: null). The first top-up enables it.

  1. 1
    Top up

    POST a positive amount to the topup subroute. This enables the wallet on first call and adds to the balance thereafter. A single mutation is capped at $1,000,000.

    curl https://api.fightclub.pro/v1/customers/cus_3f1a9c0b7e2d4a6f8b1c2d3e/wallet/topup \
      -H "Authorization: Bearer ko_REDACTED" \
      -H "Content-Type: application/json" \
      -d '{ "amount": 25.00, "description": "June prepay" }'
  2. 2
    Correct with a signed adjustment

    For refunds or manual corrections use the adjust subroute, which accepts a non-zero signed amount. Same $1,000,000 magnitude cap. Negative amounts debit.

    curl https://api.fightclub.pro/v1/customers/cus_3f1a9c0b7e2d4a6f8b1c2d3e/wallet/adjust \
      -H "Authorization: Bearer ko_REDACTED" \
      -H "Content-Type: application/json" \
      -d '{ "amount": -3.50, "description": "goodwill refund" }'
  3. 3
    Inspect balance and ledger

    GET the wallet root for the balance plus recent transactions (paginated, limit default 20, max 100, cursor via after). Ledger entry types are topup, debit, adjust and refund.

    curl "https://api.fightclub.pro/v1/customers/cus_3f1a9c0b7e2d4a6f8b1c2d3e/wallet" \
      -H "Authorization: Bearer ko_REDACTED"
Budget and wallet are independent guardrails

monthly_budget_usd caps attributed spend regardless of any wallet. A customer wallet caps prepaid funds you hold for resale. A customer can hit its monthly budget with a full wallet, or drain its wallet while under budget. The two errors below tell you which fired.

Handle the 402s

Both spend guardrails reject with HTTP 402 before the upstream provider is called, so a blocked request costs you nothing and is recorded as a blocked usage event. Always branch on the code, never the human message.

Spend rejections on attributed calls. All three are HTTP 402.
codetypeWhenWhat to do
customer_budget_exceededpayment_requiredMonth-to-date attributed spend reached monthly_budget_usd.Raise the budget via PATCH, or wait for the calendar month to roll. A customer.budget_exceeded webhook also fires.
customer_wallet_emptypayment_requiredThe customer wallet is enabled and the balance is zero or below.Top up the wallet.
customer_wallet_insufficientpayment_requiredBalance is positive but below the estimated billable cost of this specific call.Top up, or lower the per-call billable for this customer.

A budget rejection looks like this. Note the type is payment_required and the actionable field is code.

{
  "error": {
    "type": "payment_required",
    "code": "customer_budget_exceeded",
    "message": "Customer cus_3f1a9c0b7e2d4a6f8b1c2d3e has exceeded their monthly budget"
  }
}
Per-customer rate limits are 429, not 402

Hitting rpm_limit or tpm_limit returns 429 customer_rate_limit_exceeded with a Retry-After header and a retry_after_seconds body field, and fires customer.rate_limit_exceeded. That is a throttle, not a spend stop.

Update, archive and hard-delete

PATCH /v1/customers/{id} updates any settable field. Only a status transition to archived is eventful; changing the budget, limits or metadata fires no webhook.

curl -X PATCH https://api.fightclub.pro/v1/customers/cus_3f1a9c0b7e2d4a6f8b1c2d3e \
  -H "Authorization: Bearer ko_REDACTED" \
  -H "Content-Type: application/json" \
  -d '{ "monthly_budget_usd": 100, "metadata": { "plan": "enterprise" } }'

Archiving is a soft delete: DELETE /v1/customers/{id} (or PATCH with status: "archived") flips status to archived and fires customer.archived once. Archived rows keep their history, stop resolving via ext:/lazy lookups and free up their external_id so you can recreate a fresh active customer under the same external id later.

# Soft delete (archive). Reversible, history retained.
curl -X DELETE https://api.fightclub.pro/v1/customers/cus_3f1a9c0b7e2d4a6f8b1c2d3e \
  -H "Authorization: Bearer ko_REDACTED"

# Hard delete (GDPR). Purges the row, anonymizes usage events, irreversible.
curl -X DELETE "https://api.fightclub.pro/v1/customers/cus_3f1a9c0b7e2d4a6f8b1c2d3e?hard=true" \
  -H "Authorization: Bearer ko_REDACTED"
Hard delete is for GDPR erasure

Passing ?hard=true purges the customer row, nulls customer_id on every usage event and strips request/response previews and filenames that could carry PII. Billing totals are preserved. It fires customer.hard_deleted and cannot be undone. Use the plain archive for normal offboarding.

Listing and tenant isolation

List customers with GET /v1/customers. Filters: status (active|archived), external_id, order_by (created_desc default, created_asc, last_active_desc). Pagination is cursor-based: limit defaults to 20 (max 100), and you pass the previous next_cursor as after. The envelope is { data, has_more, next_cursor } with no object field.

curl "https://api.fightclub.pro/v1/customers?status=active&limit=50" \
  -H "Authorization: Bearer ko_REDACTED"

Every customer route is scoped to your developer account. A cus_ id owned by another developer resolves to 404 not_found_error, never 403, so ids stay non-enumerable. Treat a 404 on a customer subroute as "not yours or gone", not "wrong path".

Customer-scoped client tokens

To call Ringside straight from a browser without shipping your developer key, mint a short-lived client token scoped to one customer: POST /v1/customers/{id}/client_tokens. Your developer key needs the api:client_tokens scope, and the mint endpoint is Bearer-only, so a client token can never mint another. The browser then sends Authorization: Client <jwt> (the Client scheme, not Bearer).

Client-token mint parameters. Minting is rate-limited to 100/min per customer.
Body fieldDefaultBounds
ttl_seconds900integer in [60, 3600]
rpm_limit60integer <= 600, also clamped to the customer rpm
scoperequired, non-emptysubset of the client scope set
origin_allowlistnonearray of non-empty strings, each <= 512 chars
bind_ipfalsepins the token to the requesting client IP
curl https://api.fightclub.pro/v1/customers/cus_3f1a9c0b7e2d4a6f8b1c2d3e/client_tokens \
  -H "Authorization: Bearer ko_REDACTED" \
  -H "Content-Type: application/json" \
  -d '{ "scope": ["chat:completions"], "ttl_seconds": 900, "rpm_limit": 60 }'

Recap of the limits and defaults

  • ·Customer id: cus_ + 24 hex, immutable. Create returns 200 and is idempotent on external_id.
  • ·Metadata: 4 KB, 16 keys, key <= 64 chars, value <= 512 chars.
  • ·Lazy-create cap: 100/hour and 1000/day per developer, else 429 lazy_create_rate_exceeded.
  • ·Wallet mutation cap: $1,000,000 per top-up or adjust. Wallet list: limit 20 default, 100 max.
  • ·Customer list: limit 20 default, 100 max, cursor via after.
  • ·Margin default window: last 30 days. Only status=ok events count.
  • ·Spend rejections are 402; rate-limit rejections are 429.
  • ·Cross-tenant access returns 404, never 403.