Prefer Swagger UI? Click hereThe same API in the classic OpenAPI explorer.
// concept · 10 min read

The customer and spend model

Ringside treats your end-customers as first-class objects (cus_*) rather than a free-text label on a log line. A customer carries its own budget, rate limits, prepaid wallet and resale-billing defaults, so you can meter and bill the people who use your product without building that plumbing yourself. This page covers the two independent spend models, how they sit on top of your developer wallet and how attribution resolves on every call.

Why customers are a primitive

Most LLM APIs give you one account with one balance behind one rate limit. If you resell that capacity to your own users you end up rebuilding per-user budgets, per-user metering and per-user billing on your side, usually badly. Ringside pushes that down into the platform. A customer is a real object with a stable id (cus_ followed by 24 hex chars), addressable by your own id, and every metered call can be attributed to one.

Two ledgers exist, and keeping them straight is the whole mental model. There is your developer wallet (the single pre-funded pot you top up, shared across Fight Club, Knockout and Ringside). And there is each customer's own ledger, which is independent: a monthly_budget_usd ceiling and an optional prepaid wallet. The platform always charges your developer wallet for the raw provider cost. The customer ledgers are a layer you opt into on top, for capping and for resale.

The dev wallet always pays the provider

Customer budgets and customer wallets gate whether a call is allowed and what the customer is billed. They never replace the charge to your developer wallet. An empty developer wallet returns 402 wallet_empty regardless of how much credit a customer has.

Two independent metering models

A customer can carry a monthly budget, a prepaid wallet, both or neither. They are orthogonal and enforced at different points. Pick by how you want to treat overspend.

Budget and wallet are separate gates. A customer can hit either independently.
ModelField / sourceCap typeError on breachWhen it fires
Monthly budgetmonthly_budget_usdSoft cap402 customer_budget_exceededPre-call, when attributed spend this calendar month has already reached the ceiling
Prepaid wallet/wallet/topup + /wallet/adjustHard cap402 customer_wallet_emptyPre-call, when the wallet balance is <= 0 (or below the estimated billable cost of this call)

Monthly budget (soft cap)

Set monthly_budget_usd to a non-negative number. Before each attributed call the platform sums that customer's spend for the current calendar month and, if it has already reached the ceiling, refuses with 402 customer_budget_exceeded and fires the customer.budget_exceeded webhook. It is a soft cap in the sense that your developer wallet still has money: you are choosing to stop spending on this one customer, not because you are out of credit. The check is greater-than-or-equal against accumulated spend, so a single call can overshoot the ceiling slightly before the next one is blocked.

Note

Budget enforcement compares already-recorded spend against the ceiling before the call runs. The call that pushes a customer over the line completes and is billed; the *next* attributed call is the one that gets the 402.

Prepaid wallet (hard cap)

A customer wallet is a true prepaid balance. It starts disabled (NULL) and turns on the first time you POST /v1/customers/{id}/wallet/topup. Top-ups must be positive. wallet/adjust applies an arbitrary signed correction (credit or debit) for manual reconciliation. Every mutation writes both the new balance and a ledger row in one transaction, so the balance and history can never drift apart.

The wallet is only debited when you have resale billing configured (a billable header override or a customer billable default, covered below). When it is in play, the pre-call check estimates the billable cost of this request the same way the post-call charge computes it. If the balance is at or below zero you get 402 customer_wallet_empty. If the balance is positive but below the estimate you get 402 customer_wallet_insufficient. Both are 402s; branch on code, never the message.

customer_wallet_empty vs customer_wallet_insufficient

A zero-or-negative wallet returns customer_wallet_empty. A low-but-positive wallet that cannot cover the estimated billable cost of the incoming call returns customer_wallet_insufficient. The estimate uses your request's max_tokens (defaulting to 1024) for the unknown output, so a borderline balance is rejected up front rather than letting the post-call debit silently fail to collect.

How customer spend relates to your developer wallet

  incoming metered call (FC-Customer: cus_42)
             |
   [1] customer monthly budget?   --over--> 402 customer_budget_exceeded
             | ok
   [2] customer rate limits?      --over--> 429 customer_rate_limit_exceeded
             | ok
   [3] customer wallet (if resale billing on)
         <= 0  --> 402 customer_wallet_empty
         < est --> 402 customer_wallet_insufficient
             | ok
   [4] run provider call -> raw cost
             |
   [5] DEBIT developer wallet (raw * markup)  --empty--> 402 wallet_empty
             |
   [6] DEBIT customer wallet (billable amount) -- only if resale billing on
Order of checks on a single attributed chat call. The developer wallet is always charged at step 5; the customer wallet at step 6 is the resale layer.

Read the running state off two response headers on every metered call: X-Ringside-Wallet-Balance reports the balance after the call and X-Ringside-Wallet-Deducted reports the exact charge. When the call is attributed to a customer that carries a wallet, those headers describe the customer wallet; otherwise they describe your developer pool.

Billable vs raw cost

Raw cost is what the upstream provider charged, after Ringside's platform markup, debited from your developer wallet. Billable is what you want to charge your customer for the same call. They are tracked separately so your margin is just billable minus raw. You control billable in two ways, and they compose: a per-customer default and a per-call header override.

Per-customer defaults (mutually exclusive)

default_billable_markup_pct
A percentage added to the charged cost. 20 means bill the customer 120% of what the call cost you. Must be >= 0.
default_billable_amount_usd
A flat per-call amount, independent of the actual cost. Must be >= 0.
Set at most one

A markup and a flat amount are mutually exclusive. Sending both on create or update returns 400 billable_defaults_mutex. Set neither to bill raw cost (no markup line at all).

Worked example

Say a call costs you $0.004 after platform markup (the amount debited from your developer wallet). The customer has default_billable_markup_pct: 20. The billable amount is 0.004 * (1 + 20/100) = $0.0048. Your margin on that call is 0.0048 - 0.004 = $0.0008. If instead the customer had default_billable_amount_usd: 0.01, the billable would be a flat $0.01 no matter what the call actually cost.

ConfigRaw (charged to dev wallet)Billable (to customer)Margin
markup_pct: 20$0.004$0.0048$0.0008
amount_usd: 0.01$0.004$0.0100$0.0060
neither$0.004$0.004$0

Read accumulated margin for a customer via GET /v1/customers/{id}/margin, which returns billable totals against raw cost. Markup is applied to the charged cost, so the resolution is costCharged * (1 + pct / 100). The flat amount ignores cost entirely.

The X-FC-Billable-Amount override

When a single call needs a billable amount that the customer default cannot express (per-feature pricing, a promo, a free call) send the X-FC-Billable-Amount request header. The value is a USD amount (a decimal string like 0.008) and must be a non-negative number. Anything else returns 400 invalid_billable_amount. The header wins over any customer default for that one call. When the header is absent the platform falls back to the customer's markup or flat amount, and if neither is set the billable equals the charged cost.

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

# X-FC-Billable-Amount is a USD amount: 0.008 = $0.008 billed to cus_42,
# regardless of this customer's default_billable_markup_pct / amount.

Attribution with FC-Customer

Attach a metered call to a customer with the FC-Customer request header. It accepts three forms and resolution tries them in order.

Resolution order: cus_ id (if it starts with cus_), then external_id, then lazy-create or strict refusal.
Header valueResolves asExample
A cus_ idDirect id lookup, scoped to your accountFC-Customer: cus_42
ext:<external_id>Your own id, explicit prefix (active row only)FC-Customer: ext:tenant_42
A plain stringTried as cus_ id first, then as an external_idFC-Customer: tenant_42

Resolution is scoped to your developer account at every step, so one developer can never attribute a call to another's customer. A value that starts with cus_ is looked up by id first; the external_id path is tried next and matches active customers only. The OpenAI-compat user body field is also accepted as an attribution source, which is what lets an unmodified OpenAI SDK call still land against a customer.

Lazy-create and FC-Customer-Strict

By default, attributing a call to an unknown external id creates the customer on the fly using your account's billable defaults, then attributes the call to it. That is convenient when your user ids already exist on your side and you would rather not pre-register every one. Lazy-create is rate limited per developer at 100 per hour and 1000 per day. Breaching either returns 429 lazy_create_rate_exceeded.

Turn lazy-create off when you want attribution to fail loudly on a typo or an unprovisioned id. Send FC-Customer-Strict: true (or 1) on the call, or set the account-level default. In strict mode an unresolved id raises 404 customer_not_found instead of silently minting a customer. The per-request header overrides the account default in both directions: FC-Customer-Strict: false re-enables lazy-create for one call even when your account default disables it.

# Without strict: "tenant_99" auto-creates a new cus_ and bills it.
# With strict: an unknown id is a hard 404, so typos do not silently bill.
curl https://api.fightclub.pro/v1/chat/completions \
  -H "Authorization: Bearer $FC_API_KEY" \
  -H "FC-Customer: tenant_99" \
  -H "FC-Customer-Strict: true" \
  -H "Content-Type: application/json" \
  -d '{"model":"fc:openai/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'

# -> 404 { "error": { "type": "not_found_error", "code": "customer_not_found", ... } }

Addressing customers by your own id

You never have to persist our cus_ id. If you set external_id on create, the ext:<external_id> prefix addresses that customer on every customer route, not just the base object. So GET /v1/customers/ext:tenant_42, GET /v1/customers/ext:tenant_42/wallet, .../usage, .../margin, .../client_tokens and .../conversations all resolve the same row. The ext: lookup deliberately resolves to the active row newest-first, so after an archive-and-recreate cycle on the same external_id you always land on the live customer rather than the tombstone.

Tip

There are two ways to look up by your own id. GET /v1/customers?external_id=tenant_42 is a list filter and returns a single-item { data, has_more, next_cursor } page. GET /v1/customers/ext:tenant_42 is a direct fetch that returns the object and composes with subroutes. Use the second for anything beyond a list.

Lifecycle and status

A customer is either active or archived. Archiving is the normal soft delete (DELETE /v1/customers/{id} or PATCH with status: archived); the row stays, spend history stays, and it stops resolving on the ext: / external_id attribution paths. Creating a customer with an external_id that already belongs to an archived row mints a fresh active row (the archived one is tombstoned), so the external_id is reusable.

StatusSet byAttributionWebhookNotes
activeCreate (default)Resolves on id, ext: and external_idcustomer.created on true first-createIdempotent re-create of the same active external_id returns the existing row and fires no event
archivedDELETE or PATCH status: archivedNo longer resolves via ext: / external_idcustomer.archived on the active -> archived transition onlyRow + spend history retained; re-archiving is a no-op (no duplicate webhook)

A separate GDPR hard delete exists beyond archive. It purges the customer row, anonymizes its usage events (nulls the customer link and strips request/response previews while preserving billing totals) and fires customer.hard_deleted. Archive for normal lifecycle; hard delete only for a data-erasure request.

Always branch on code, never the human message. Cross-tenant access is a 404, never a 403.
StatuscodetypeMeaning
402customer_budget_exceededpayment_requiredThe customer's monthly_budget_usd ceiling is reached. Your dev wallet still has money.
402customer_wallet_emptypayment_requiredThe customer prepaid wallet is at or below zero (resale billing is on).
402customer_wallet_insufficientpayment_requiredThe customer wallet is positive but below the estimated billable cost of this call.
402wallet_emptypayment_requiredYour developer wallet is empty. Independent of any customer balance; top up the Ringside pool.
400billable_defaults_mutexinvalid_request_errorYou set both default_billable_markup_pct and default_billable_amount_usd.
400invalid_billable_amountinvalid_request_errorX-FC-Billable-Amount was not a non-negative number.
400metadata_too_largeinvalid_request_errorCustomer metadata exceeds 4 KB serialized.
400invalid_amountinvalid_request_errorA wallet/topup amount was not positive, or a wallet/adjust amount was zero or not finite.
404customer_not_foundnot_found_errorStrict mode (or FC-Customer-Strict: true) and the id did not resolve.
404resource_not_foundnot_found_errorNo customer with that id or external_id, including cross-tenant access (ids are not enumerable).
429customer_rate_limit_exceededrate_limit_errorThe customer hit its rpm_limit or tpm_limit; carries Retry-After.
429lazy_create_rate_exceededrate_limit_errorLazy-create hit the per-developer cap (100/hour or 1000/day).
Note

The request id is the X-Request-Id response header, not a body field. Every error body is { "error": { "type", "code", "message", "param"?, "retry_after_seconds"? } }; type, code and message are always present.

Hard limits and defaults

  • ·Customer id format: cus_ followed by 24 hex characters.
  • ·Customer metadata: up to 4 KB serialized; 16 keys max, key <= 64 chars, value <= 512 chars.
  • ·monthly_budget_usd, default_billable_markup_pct, default_billable_amount_usd: all must be >= 0.
  • ·Billable defaults are mutually exclusive (markup XOR flat amount).
  • ·Lazy-create rate limit: 100 new customers per hour and 1000 per day, per developer.
  • ·X-FC-Billable-Amount is a USD amount (0.008 = $0.008) and must be a non-negative number.
  • ·List pages default to 20 rows, max 100; paginate with after set to the previous next_cursor. Filters: status, external_id, order_by (created_asc | created_desc | last_active_desc).