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

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.

Where the line is

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 <---|
     |                              |                               |
Usage originates in Ringside; charges are created in Stripe. You own the bridge.

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 25 bills 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.
They are mutually exclusive

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.

Turn off lazy-create in production billing paths

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.

Budget and wallet stops

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>"
Margin report fields
FieldMeaningBill this?
total.billable_amount_usdWhat 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_usdWhat the calls cost you (provider + Ringside).No - this is your COGS, for margin only
total.margin_usdbillable_amount_usd - cost_charged_usd.No - reporting
total.margin_pctMargin as a percent of revenue. null when revenue is 0 (avoids 0/0).No - reporting
total.request_countSettled 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.

platform_cost_usd is a placeholder

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.

Billing-relevant webhook events
EventFires whenBilling use
customer.budget_exceededA call is blocked because the customer passed monthly_budget_usd.Pause the customer, alert or raise their cap
wallet.lowA funding wallet crosses the low threshold.Trigger an auto-topup / a Stripe charge to refill prepaid balance
wallet.emptyA funding wallet hits zero.Hard-stop billing and notify
customer.rate_limit_exceededA call is blocked on the customer rpm/tpm limit.Capacity / upsell signal
chat.completion.finishedAdvertised for per-call invoice-item creation.See the caveat below before relying on it
customer.created / customer.archivedCustomer lifecycle.Keep your Stripe customer mapping in sync
Do not build per-call Stripe billing on chat.completion.finished yet

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)),
  );
}
Use the SDK helper

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.

Matching the Ringside read to the Stripe write
Stripe primitiveFeed it fromGrain
Billing meter eventusage report token totals, or margin daily bucketsOne event per customer per day (idempotent)
Invoice itemmargin report total.billable_amount_usdOne item per customer per cycle
Wallet auto-topup chargewallet.low webhookOn 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']}",
        )
Make the push idempotent on the Stripe side

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.

  1. 01For the closing cycle, call GET /v1/customers/{id}/margin?from=<cycle_start>&to=<cycle_end> for each customer. total.billable_amount_usd is the source of truth for revenue.
  2. 02Sum what you pushed to Stripe for that customer and window (meter events for the meter, or invoice items on the upcoming invoice).
  3. 03Assert the two totals match within a rounding tolerance (you round to cents on the Stripe side; allow +/- 1 cent per push).
  4. 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.
  5. 05Independently, reconcile your funding wallet: GET /v1/customers/{id}/wallet returns balance_usd plus recent transactions, so prepaid balances tie out against your Stripe top-up charges.
Margin is a sanity check, not a charge

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

Errors you will meet on this path and what to do
CodeHTTPWhenHandling
billable_defaults_mutex400Customer create/update sets both markup_pct and amount_usd.Pick one field
invalid_default_billable_markup_pct400Negative or non-numeric markup.Validate before send
invalid_group_by400Bad group_by on margin/usage.Use day/customer/model (margin) or the usage set
invalid_date400Unparseable from/to. param names the field.Send ISO-8601
missing_property_key400usage?group_by=property without property_key.Add property_key
customer_budget_exceeded402Attributed call over monthly_budget_usd.No billable usage produced; raise cap or pause
customer_wallet_empty402Prepaid customer wallet at zero.Top up via /wallet/topup
wallet_empty402Your developer wallet at zero.Refill your own funding
resource_not_found404Wrong 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/to are omitted. Always pass an explicit cycle for billing.
  • ·Customer list: limit default 20, max 100. Loop on has_more + next_cursor; there is no total count.
  • ·X-FC-Billable-Amount is in micro-dollars (1 USD = 1,000,000). It overrides the customer default for that one call.
  • ·Margin and usage reports count only settled ok events. Blocked (402) and errored calls never reach an invoice.
  • ·Customer metadata is 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