Meter and bill with Stripe
Ringside is the metering layer. Stripe is the billing layer. This guide wires the two together: set a per-customer markup so every call records what you charge alongside what it cost, pull the resulting usage and margin out of Ringside (or subscribe to webhooks), then push that into Stripe as meter events or invoice items and reconcile the two ledgers. Ringside gives you usage, margin and webhooks. The Stripe side you build, and this page shows exactly where the seam is.
The mental model
You resell LLM calls. A customer hits your product, your product calls Ringside, Ringside calls the upstream provider. Two numbers come out of every call: the raw cost (what the provider plus Ringside charged you) and the billable amount (what you decided to charge your customer). The gap between them is your margin.
Ringside owns the left half of that pipeline. It attributes each call to a customer, records the raw cost and the billable amount and exposes both as per-customer aggregates and as events. It does not talk to Stripe, send invoices or run subscriptions. Stripe owns the right half. Your job is the join in the middle: read metered usage out of Ringside on a schedule (or react to webhooks) and write it into Stripe as meter events or invoice items.
Ringside provides: per-customer attribution, a billable-amount override per call, margin and usage reports and a webhook taxonomy you can subscribe to. You provide: the Stripe account, the price/meter objects, the code that pushes meter events or invoice items and reconciliation. There is no built-in Stripe connector.
Data flow
your app Ringside (api.fightclub.pro/v1) Stripe
-------- ------------------------------ ------
| | |
| POST /v1/chat/completions | |
| FC-Customer: cus_42 | |
| X-FC-Billable-Amount: 30000 | |
|----------------------------->| |
| |-- meter call: deduct wallet |
| 200 + usage headers | record cost + billable |
|<-----------------------------| attribute to cus_42 |
| | |
| (a) PULL, on a schedule | |
| GET /customers/cus_42/margin| |
|----------------------------->| |
| { total, buckets[] } | |
|<-----------------------------| |
| | |
| (b) PUSH, reactively | |
| webhook: customer.* / | |
| wallet.low --------------->| (your endpoint) |
| | |
| create meter event / invoice item ------------------------->|
| | v1/billing/meter_events |
| reconcile margin total vs Stripe upcoming-invoice total <---|
| | |Step 1: set a billable markup per customer
A customer in Ringside is a first-class object with a cus_ id. Two mutually exclusive fields control what a call costs the customer relative to what it costs you:
- default_billable_markup_pct
- A percentage markup on the raw cost. A value of
25bills the customer 1.25x what the call cost you. Stored as a non-negative decimal. - default_billable_amount_usd
- A flat per-call billable amount in USD, independent of raw cost. Use this for fixed-price-per-request resale.
Send both on the same customer and the API returns 400 with code billable_defaults_mutex. Set one or the other, never both. Both fields also reject negatives (invalid_default_billable_markup_pct / invalid_default_billable_amount_usd).
curl https://api.fightclub.pro/v1/customers \
-H "Authorization: Bearer ko_<your-64-hex-key>" \
-H "Content-Type: application/json" \
-d '{
"external_id": "acct_acme_inc",
"display_name": "Acme Inc",
"default_billable_markup_pct": 25,
"monthly_budget_usd": 2000
}'The markup is a default, not a hard rule. Any individual call can override the billable amount at request time with the X-FC-Billable-Amount header, whose value is in micro-dollars (1 USD = 1,000,000). That lets you price a single expensive request differently from the customer default. When the header is absent, the customer default applies; when neither exists, billable equals raw cost and your margin on that call is zero.
The two fields you read back later are lifetime_spend_usd (raw cost the customer accrued) and lifetime_dev_revenue_usd (billable total). Their difference is lifetime margin, but for billing you want the windowed margin report below, not the lifetime counters.
Step 2: attribute every call to a customer
Margin only exists if the call is attributed. Send the FC-Customer header on POST /v1/chat/completions (and every other metered endpoint). It accepts three forms: the canonical cus_42, your own id prefixed as ext:acct_acme_inc or a plain external id string that Ringside will lazy-create into a customer if it does not exist yet.
Lazy-create is convenient in dev and dangerous in billing. A typo in an external id silently spawns a new untracked customer that then accrues real spend. Send FC-Customer-Strict: true to make an unknown customer a hard error instead of a silent insert.
curl https://api.fightclub.pro/v1/chat/completions \
-H "Authorization: Bearer ko_<your-64-hex-key>" \
-H "FC-Customer: ext:acct_acme_inc" \
-H "FC-Customer-Strict: true" \
-H "Content-Type: application/json" \
-d '{
"model": "fc:openai/gpt-4o-mini",
"messages": [{"role":"user","content":"summarise this ticket"}]
}'X-Ringside-Wallet-Deducted is the exact charge against the funding wallet for that call, and X-Ringside-Wallet-Balance is the balance after. These are the raw-cost side. The billable amount is not echoed in a chat response header; you read it back in aggregate from the margin report, which is the right grain for invoicing anyway.
If the customer is over monthly_budget_usd, the call returns 402 customer_budget_exceeded. If a prepaid customer wallet is empty, 402 customer_wallet_empty. If your own developer wallet is empty, 402 wallet_empty. None of these produce billable usage, so they never reach Stripe. Branch on code, never the human message.
Step 3: pull usage and margin
For a billing cycle you want a windowed total per customer. Two endpoints serve this. GET /v1/customers/{id}/margin is the one to drive invoicing: it returns billable revenue, raw cost and the margin between them. GET /v1/customers/{id}/usage returns token and request counts for the same window, which is what you want if you bill on tokens rather than dollars.
The margin report
Both {id} segments accept cus_42 or ext:acct_acme_inc. The window defaults to the last 30 days; pass from and to as ISO-8601 (or any value new Date() parses) to bill an exact cycle. An unparseable date returns 400 invalid_date with the offending param. group_by is one of day, customer or model (default day); an unknown value returns 400 invalid_group_by. Only settled ok events count, so blocked and errored calls never inflate an invoice.
curl "https://api.fightclub.pro/v1/customers/cus_42/margin?from=2026-06-01&to=2026-07-01&group_by=day" \ -H "Authorization: Bearer ko_<your-64-hex-key>"
| Field | Meaning | Bill this? |
|---|---|---|
| total.billable_amount_usd | What you charge the customer for the window. Sum of per-call billable amounts. | Yes - this is the invoice line for dollar billing |
| total.cost_charged_usd | What the calls cost you (provider + Ringside). | No - this is your COGS, for margin only |
| total.margin_usd | billable_amount_usd - cost_charged_usd. | No - reporting |
| total.margin_pct | Margin as a percent of revenue. null when revenue is 0 (avoids 0/0). | No - reporting |
| total.request_count | Settled ok calls in the window. | Optional - per-request pricing |
| buckets[] | The same five numbers per day / customer / model bucket. | Use for daily meter events |
The usage report (token billing)
If your Stripe meter is keyed on tokens, hit GET /v1/customers/{id}/usage instead. Its group_by set is wider (day, model, tag, property, conversation); group_by=property also requires a property_key query param or you get 400 missing_property_key. The totals object carries input_tokens, output_tokens, cached_input_tokens, reasoning_tokens, request_count and llm_cost_usd. Use cached_input_tokens and reasoning_tokens if you price those tiers separately.
The usage report carries a platform_cost_usd field that is currently always 0. Do not bill on it. For dollar amounts use cost_charged_usd from the margin report (your COGS) and billable_amount_usd (your revenue).
Many customers at once
To bill your whole book on a cycle, list customers and call the margin endpoint per id. GET /v1/customers paginates: { "data": [...], "has_more": boolean, "next_cursor": string|null }, limit default 20 max 100, and you pass the previous next_cursor back as after. There is no object: "list" envelope and no total count; loop until has_more is false.
import os, httpx
BASE = "https://api.fightclub.pro/v1"
H = {"Authorization": f"Bearer {os.environ['RINGSIDE_KEY']}"}
def all_customers(client):
after = None
while True:
params = {"limit": 100, "status": "active"}
if after:
params["after"] = after
page = client.get(f"{BASE}/customers", params=params, headers=H).json()
yield from page["data"]
if not page["has_more"]:
return
after = page["next_cursor"]
def cycle_margin(client, cus_id, frm, to):
r = client.get(
f"{BASE}/customers/{cus_id}/margin",
params={"from": frm, "to": to},
headers=H,
)
r.raise_for_status()
return r.json()["total"]
with httpx.Client(timeout=30) as c:
for cust in all_customers(c):
m = cycle_margin(c, cust["id"], "2026-06-01", "2026-07-01")
# m["billable_amount_usd"] -> the amount to push to Stripe
print(cust["external_id"], m["billable_amount_usd"], m["margin_usd"])Step 4 (alternative): react to webhooks
Polling on a cron is the simplest correct design and it is what most people should ship first. Webhooks are for reacting between cycles: top up a wallet when it runs low, flag a customer who blew their budget or stamp an invoice item the moment a chat completion settles. Register an endpoint with POST /v1/webhooks, where url must be https and SSRF-safe and unique per developer, and events is a non-empty subset of the taxonomy.
| Event | Fires when | Billing use |
|---|---|---|
customer.budget_exceeded | A call is blocked because the customer passed monthly_budget_usd. | Pause the customer, alert or raise their cap |
wallet.low | A funding wallet crosses the low threshold. | Trigger an auto-topup / a Stripe charge to refill prepaid balance |
wallet.empty | A funding wallet hits zero. | Hard-stop billing and notify |
customer.rate_limit_exceeded | A call is blocked on the customer rpm/tpm limit. | Capacity / upsell signal |
chat.completion.finished | Advertised for per-call invoice-item creation. | See the caveat below before relying on it |
customer.created / customer.archived | Customer lifecycle. | Keep your Stripe customer mapping in sync |
chat.completion.finished is registered in the taxonomy so subscriptions are accepted, but the emit wiring on the chat path is a tracked follow-up and it does not fire reliably today. For per-call billing, poll the margin report on a short interval instead. The lifecycle and wallet events above (customer.budget_exceeded, wallet.low, wallet.empty, customer.rate_limit_exceeded) do fire.
Verify the signature
Every delivery carries X-FC-Signature: t=<unix>,v1=<hex>, where v1 is HMAC-SHA256 of the raw request body keyed by the endpoint secret (whsec_..., returned once on create and on POST /rotate_secret). Verify constant-time and reject stale timestamps. During a secret rotation grace window the header carries two v1= digests; accept the message if either matches. A sustained delivery-failure streak auto-disables the endpoint (status: disabled_after_failures), so monitor GET /v1/webhooks/{id}/deliveries and use POST /v1/webhooks/{id}/deliveries/{id}/retry to replay.
import crypto from 'node:crypto';
// Mount with a raw-body parser so you hash the exact bytes Ringside signed.
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=')),
);
const t = parts.t;
// Reject anything older than 5 minutes.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
// header may carry two v1= digests during a rotation grace window.
const provided = header
.split(',')
.filter((p) => p.startsWith('v1='))
.map((p) => p.slice(3));
return provided.some((sig) =>
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
);
}The official Ringside SDK ships webhooks.verify() so you do not hand-roll the HMAC and timestamp checks. Python pip install ringside, Node npm install @ringside/sdk, Go github.com/fightclub/ringside-go.
Step 5: push into Stripe
This is the half you own. Stripe gives you two shapes, and the choice drives everything upstream. Meter events suit usage-based subscriptions; invoice items suit ad-hoc or per-cycle charges. Pick one and keep the grain consistent with how you read Ringside.
| Stripe primitive | Feed it from | Grain |
|---|---|---|
| Billing meter event | usage report token totals, or margin daily buckets | One event per customer per day (idempotent) |
| Invoice item | margin report total.billable_amount_usd | One item per customer per cycle |
| Wallet auto-topup charge | wallet.low webhook | On demand, between cycles |
The mapping that makes reconciliation possible is keeping external_id identical on both sides. Set the Ringside customer external_id to your own account id, and use that same id (or a Stripe customer whose metadata carries it) as the Stripe customer reference. Then a Ringside margin row and a Stripe invoice line refer to the same account by the same key.
import stripe, httpx, os
from datetime import date, timedelta
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
BASE = "https://api.fightclub.pro/v1"
H = {"Authorization": f"Bearer {os.environ['RINGSIDE_KEY']}"}
yesterday = (date.today() - timedelta(days=1)).isoformat()
today = date.today().isoformat()
with httpx.Client(timeout=30) as c:
r = c.get(
f"{BASE}/customers/ext:acct_acme_inc/margin",
params={"from": yesterday, "to": today, "group_by": "day"},
headers=H,
)
r.raise_for_status()
report = r.json()
for bucket in report["buckets"]:
# Bill on revenue (billable), not on your COGS (cost_charged).
cents = round(bucket["billable_amount_usd"] * 100)
if cents <= 0:
continue
stripe.billing.MeterEvent.create(
event_name="ringside_usage_usd_cents",
payload={
"stripe_customer_id": "cus_stripe_acme", # your Stripe id
"value": str(cents),
},
# Stripe dedupes on identifier; key it on (customer, day) so a
# re-run of this job never double-bills.
identifier=f"acct_acme_inc:{bucket['key']}",
)Ringside reports are queries, not events, so re-running your billing job re-reads the same window. That is fine only if the Stripe write is idempotent. Use a stable identifier on meter events (Stripe dedupes on it), or an idempotency key on invoice items, keyed on (external_id, bucket_date). Ringside's own idempotency-key header protects the chat call, not your downstream Stripe call.
Step 6: reconcile
Reconciliation confirms that what you charged in Stripe matches what Ringside says was billable. Run it before you finalize an invoice. For each customer, compare the Ringside margin total for the cycle against the sum of meter events or invoice items you pushed to Stripe for the same window and the same external_id.
- 01For the closing cycle, call
GET /v1/customers/{id}/margin?from=<cycle_start>&to=<cycle_end>for each customer.total.billable_amount_usdis the source of truth for revenue. - 02Sum what you pushed to Stripe for that customer and window (meter events for the meter, or invoice items on the upcoming invoice).
- 03Assert the two totals match within a rounding tolerance (you round to cents on the Stripe side; allow +/- 1 cent per push).
- 04On a mismatch, do not finalize. The usual cause is a missed or duplicated push; the stable
identifier/ idempotency key makes a corrective re-run safe. - 05Independently, reconcile your funding wallet:
GET /v1/customers/{id}/walletreturnsbalance_usdplus recenttransactions, so prepaid balances tie out against your Stripe top-up charges.
Bill the customer on billable_amount_usd. cost_charged_usd and margin_usd are yours: they tell you whether the markup you set is actually profitable per customer and per model (run the margin report with group_by=model to see which models erode margin). They never become an invoice line.
Failure modes to handle
| Code | HTTP | When | Handling |
|---|---|---|---|
billable_defaults_mutex | 400 | Customer create/update sets both markup_pct and amount_usd. | Pick one field |
invalid_default_billable_markup_pct | 400 | Negative or non-numeric markup. | Validate before send |
invalid_group_by | 400 | Bad group_by on margin/usage. | Use day/customer/model (margin) or the usage set |
invalid_date | 400 | Unparseable from/to. param names the field. | Send ISO-8601 |
missing_property_key | 400 | usage?group_by=property without property_key. | Add property_key |
customer_budget_exceeded | 402 | Attributed call over monthly_budget_usd. | No billable usage produced; raise cap or pause |
customer_wallet_empty | 402 | Prepaid customer wallet at zero. | Top up via /wallet/topup |
wallet_empty | 402 | Your developer wallet at zero. | Refill your own funding |
resource_not_found | 404 | Wrong customer id, or one owned by another developer. | Cross-tenant returns 404 not 403; ids are not enumerable |
The request id is the X-Request-Id response header, never a body field. Log it on every Ringside call in your billing job so a disputed line can be traced back to the exact metered request. Always branch on error.code, not the human error.message, which is not part of the contract and can change.
Limits and defaults to plan around
- ·Margin/usage window defaults to the last 30 days when
from/toare omitted. Always pass an explicit cycle for billing. - ·Customer list:
limitdefault 20, max 100. Loop onhas_more+next_cursor; there is no total count. - ·
X-FC-Billable-Amountis in micro-dollars (1 USD = 1,000,000). It overrides the customer default for that one call. - ·Margin and usage reports count only settled
okevents. Blocked (402) and errored calls never reach an invoice. - ·Customer
metadatais capped at 4 KB (16 keys, key <=64 chars, value <=512 chars). Store your Stripe customer id here if you want the mapping inside Ringside. - ·Wallet adjust/top-up magnitude is capped at 1,000,000 USD per operation.
- ·A webhook endpoint auto-disables after a sustained failure streak. Monitor delivery status and retry from
/deliveries.
A working end-to-end loop
Putting it together, a daily billing cron looks like this: list active customers, pull each one's margin for the day, push one idempotent Stripe meter event per customer keyed on (external_id, day) and at cycle close reconcile the Ringside cycle margin against the Stripe upcoming invoice before you let Stripe finalize. Wallet webhooks run on a separate, reactive path to keep prepaid balances funded between cron runs.
# 1. list customers (loop has_more) GET /v1/customers?status=active&limit=100 # 2. per customer, pull yesterday's billable GET /v1/customers/<id>/margin?from=<d-1>&to=<d>&group_by=day # 3. push to Stripe (idempotent on external_id + day) POST https://api.stripe.com/v1/billing/meter_events # 4. at cycle close, reconcile before finalize GET /v1/customers/<id>/margin?from=<cycle_start>&to=<cycle_end> # compare total.billable_amount_usd to Stripe upcoming invoice