> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.runledger.co/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.runledger.co/_mcp/server.

# Cost attribution and margin

Prices and guardrails need costs to compare against. This guide shows how cost reaches Runledger, how reversals change the margin you report, and which integration pattern fits where your costs are known.

> **Note**
>
> Every amount in the examples is illustrative, and so are the providers (Vidlab, Textco) and the catalog entries shown: none is a real provider price.

## Before you start

* You know the [parts of pricing from cost](/concepts/cost-reflective-pricing#the-pieces) and who controls each variable.
* Your code already calls [`check()` and `track()`](/concepts/checks-and-holds), and you have a [cost model](/guides/cost-catalog-and-models) for each model you call.

## Cost attribution with trace() and track()

`trace()` takes `usage_lines` for units that aren't tokens. Each line has a unit, a quantity and the dimension values that set its rate:

```bash
curl -X POST https://api.runledger.co/v1/costs \
  -H "Authorization: Bearer $RUNLEDGER_API_KEY" \
  -H "Runledger-Version: 2026-10-18" \
  -H "Content-Type: application/json" \
  -d '{"idempotency_key": "clip_9001-render", "run_id": "run_01JD7Q2W",
       "external_customer_id": "org_acme", "provider": "vidlab", "model": "vidlab-v2",
       "usage_lines": [{"unit": "video_seconds", "quantity": "8", "dimensions": {"resolution": "1080p"}}]}'
```

When an active cost model matches the call's `provider` and `model`, Runledger prices every line from its rate card, applies the cost model's overrides, and records which one priced it, so a cost can always be traced back to the rates behind it. Here the line was priced by the catalog price version unchanged (`rate_source: "catalog"`). A line priced by an override says `"rate_source": "override"` and names it in `override`, and a custom cost model's lines say `"custom"`:

```json
{
  "id": "cost_8Jq3vB6yDs",
  "object": "cost",
  "idempotency_key": "clip_9001-render",
  "run_id": "run_01JD7Q2W",
  "customer": "cus_9sF2kq81LmQz",
  "provider": "vidlab",
  "model": "vidlab-v2",
  "lines": [
    {
      "unit": "video_seconds",
      "quantity": "8",
      "dimensions": { "resolution": "1080p" },
      "cost_model": "cm_6bR3xN8vQe",
      "rate_card": "rc_4tG9wK2nPd",
      "rate_card_version": 1,
      "rate_source": "catalog",
      "catalog_price": "catp_4dK2mZ8vQw",
      "cost": { "amount_decimal": "96", "currency": "usd" }
    }
  ],
  "cost": { "amount_decimal": "96", "currency": "usd" },
  "priced_by": "catalog",
  "timestamp": "2026-10-24T11:02:41Z",
  "livemode": false,
  "created_at": "2026-10-24T11:02:41Z"
}
```

Token usage goes in `usage`, as usual, and a call can send both for different units. A prompt of 1,840 input tokens and 512 output tokens on `textco-large` at the list prices in the [override example](/guides/cost-catalog-and-models#your-own-rates-override-the-catalog) costs 0.552 + 0.768 = 1.32. `priced_by` can also be `catalog`. Without a matching cost model, a call on a model the catalog has is priced at the catalog's list price, with `priced_by: price_table` and the warning `catalog_model_available` naming the catalog model, so you or an agent can create the cost model. For a model the catalog doesn't have, no cost is worked out, and a cost you send yourself is kept as you sent it (`priced_by: request`).

On the revenue side, `track()` carries what the customer chose in `properties`, and a price rule reads it. The response says how the event was priced:

```json
{
  "pricing": {
    "price_rule": "video_cost_plus",
    "price_rule_version": 1,
    "method": "cost_plus",
    "basis": "catalog",
    "cost": "96",
    "price": "134.4",
    "floor_applied": false,
    "cap_applied": false
  }
}
```

The same `run_id` on `check()`, `track()` and `trace()` joins revenue to cost, and a run has a [margin](#read-the-margin-of-runs) once it has a confirmed charge and a traced cost. `POST /v1/events/preview` returns the same `pricing` block without recording anything, so an agent can test a rule before any integration code changes.

### Preview a price

By default a preview prices the event with the rule its plan rate uses. To price it with a rule that no plan rate uses yet, name the rule with `price_rule`, and optionally a version with `price_rule_version`:

```bash
curl -X POST https://api.runledger.co/v1/events/preview \
  -H "Authorization: Bearer $RUNLEDGER_API_KEY" \
  -H "Runledger-Version: 2026-10-18" \
  -H "Content-Type: application/json" \
  -d '{"name": "video.generate", "external_customer_id": "org_acme",
       "quantity": "8", "properties": {"resolution": "1080p"},
       "price_rule": "video_cost_plus", "price_rule_version": 1}'
```

The response carries the same `pricing` block as above, and `price`, in the customer's balance unit. For eight seconds of 1080p on the rule above, `pricing.cost` is `"96"` and `pricing.price` is `"134.4"`. Without `price_rule_version`, the version in force at the event's `timestamp` is used. The rule's currency must match the customer's plan. An unknown `price_rule` returns `404`.

The same call gives a workflow an upfront estimate. Before an AI job runs, preview the event it would send and show "this will use about 12 credits", with `price` in the customer's balance unit. `track()` prices the event it receives, so the estimate is a preview and not a promise. The preview doesn't say whether the customer's balance covers it. To ask that, send a `check()`: its [`affordability`](/concepts/checks-and-holds#how-far-the-work-fits) block gives the balance, pools, allowances and spend limits before and after, and the check places no hold unless you send `hold: true`.

## Currencies

A cost is recorded in the currency of the rate card that priced it, and a price rule works in one currency. The rule's `currency` must match its rate cards and the plans that use it, and Runledger doesn't convert between currencies. Revenue and cost in different currencies are never summed: the margin report and a simulation each cover one `currency`, so a product that sells and spends in two currencies reads two reports.

## Read the margin of runs

`GET /v1/runs/{run_id}` returns one run's revenue, cost and margin, and `GET /v1/runs` lists runs, newest first.

**A run has a margin only with a confirmed charge and a traced cost.** `has_margin` says which. Pending and reversed charges don't count as confirmed: a run whose charges are all pending has no margin yet, and when a reversal removes the only confirmed charge, the run drops back to no margin. The cost stays recorded. Without a margin, `margin` and `margin_percent` are `null`, and the run still shows its `revenue_pending`, `revenue_reversed` and `cost`.

**`Response: Run`**

```json title="Response: Run"
{
  "object": "run",
  "run_id": "run_01JD7Q2W",
  "customer": "cus_9sF2kq81LmQz",
  "currency": "usd",
  "revenue_confirmed": "134.4",
  "revenue_pending": "0",
  "revenue_reversed": "0",
  "cost": "96",
  "has_margin": true,
  "margin": "38.4",
  "margin_percent": "0.2857",
  "events_count": 1,
  "costs_count": 1,
  "first_seen_at": "2026-10-25T01:12:09Z",
  "last_seen_at": "2026-10-25T01:12:41Z",
  "livemode": false
}
```

The same run while its outcome charge is still pending has no margin. `margin` and `margin_percent` are `null`, and revenue and cost still show what is known:

**`Response: Run`**

```json title="Response: Run"
{
  "object": "run",
  "run_id": "run_01JD7Q2W",
  "customer": "cus_9sF2kq81LmQz",
  "currency": "usd",
  "revenue_confirmed": "0",
  "revenue_pending": "134.4",
  "revenue_reversed": "0",
  "cost": "96",
  "has_margin": false,
  "margin": null,
  "margin_percent": null,
  "events_count": 1,
  "costs_count": 1,
  "first_seen_at": "2026-10-25T01:12:09Z",
  "last_seen_at": "2026-10-25T01:12:41Z",
  "livemode": false
}
```

The same holds on `GET /v1/runs` and `GET /v1/runs/{run_id}`: whenever `has_margin` is `false`, `margin` and `margin_percent` are `null`, whichever of the confirmed charge or the traced cost is missing.

`GET /v1/runs` filters by `customer_id`, `outcome_definition`, `from`, `to` and `has_margin`. `has_margin=true` returns only runs with a margin, and `has_margin=false` only runs without one.

### Totals across runs

`GET /v1/runs/summary` takes the same filters and totals all the runs that match, not a page of them:

```bash
curl "https://api.runledger.co/v1/runs/summary?from=2026-10-01T00:00:00Z&to=2026-11-01T00:00:00Z&outcome_definition=chat_resolution" \
  -H "Authorization: Bearer $RUNLEDGER_API_KEY" \
  -H "Runledger-Version: 2026-10-18"
```

**`Response: RunSummary`**

```json title="Response: RunSummary"
{
  "object": "runs_summary",
  "currency": "usd",
  "runs": 412,
  "runs_with_margin": 371,
  "runs_without_margin": 41,
  "revenue_confirmed": "35240",
  "cost": "9112.5",
  "margin": "26127.5",
  "margin_percent": "0.7414",
  "livemode": false
}
```

**How runs without a margin count.** The money totals add up only the runs that have a margin, so `margin` is always `revenue_confirmed` minus `cost`. A run with no confirmed charge, or with no traced cost, adds nothing to `revenue_confirmed`, `cost` or `margin`, whichever of the two it lacks. That includes the cost of a run whose revenue is still pending or was reversed. Those runs are counted in `runs` and `runs_without_margin`, which is how you see how many were left out. With `has_margin=false`, every matching run is without a margin, so the totals are `"0"` and `margin_percent` is `null`. For margin that includes cost with no revenue, use the [margin report](/api-reference/reports/margin), which groups by customer, model or outcome definition.

A summary is in one currency, and amounts are never converted or added across currencies. Send `currency`, or leave it out when every matching run is in one. If the matching runs are in more than one, the call returns `400` with `parameter_invalid` on `currency`.

Add `group_by=day`, `week` or `customer` to get the same totals as `rows` for a chart: margin over time, or margin by customer. A row with no run that has a margin has `has_margin: false` and a `null` margin. [Reports and charts](/guides/reports) shows the response.

## Margin after reversals

The margin report shows margin as confirmed revenue minus cost, and shows pending and reversed revenue separately, because pending revenue may still reverse. A reversal removes the revenue of an [outcome charge](/concepts/outcome-settlement). It never changes a cost already recorded, because the model call happened either way. So for an outcome that sells at a fixed price while the cost of each run varies, a reversed outcome shows up in the margin report as cost with no revenue, which a customer or an outcome definition includes. On a single run, a reversal that removes the only confirmed charge leaves the run without a margin (`has_margin: false`), with its cost still recorded.

Nothing on this page changes that arithmetic. Expected margin at `check()` time doesn't try to predict reversals, and a simulation keeps recorded reversals as they were. A `realized` guardrail and the margin report are where reversals show.

## Integration patterns

### Attribute cost from your own code

Call `trace()` after each model call with the provider, the model, the usage and the `run_id`. Use a stable `idempotency_key` built from your own data, such as the provider's request ID, so a retry lands on the same cost. `check()` and `track()` carry the same `run_id`, and the customer's choices in `properties`.

### Ingest usage from a gateway or a provider export

If your model calls already pass through a gateway or proxy that logs usage, or you can export usage from a provider, a small forwarder can turn each record into an item of `POST /v1/costs/batch`, up to 100 at a time. Each item succeeds or fails on its own, and the `idempotency_key` deduplicates for 35 days, so a replayed log doesn't double-count.

```json
{
  "costs": [
    {
      "idempotency_key": "gw_req_7f3a91",
      "run_id": "run_01JD7Q2W",
      "external_customer_id": "org_acme",
      "provider": "textco",
      "model": "textco-large",
      "timestamp": "2026-10-24T11:02:39Z",
      "usage": { "input_tokens": 1840, "output_tokens": 512 }
    },
    {
      "idempotency_key": "gw_req_7f3a92",
      "run_id": "run_01JD7Q2W",
      "external_customer_id": "org_acme",
      "provider": "vidlab",
      "model": "vidlab-v2",
      "timestamp": "2026-10-24T11:02:41Z",
      "usage_lines": [{ "unit": "video_seconds", "quantity": "8", "dimensions": { "resolution": "1080p" } }]
    }
  ]
}
```

The forwarder is yours: Runledger doesn't provide adapters for specific gateways. What it needs from the gateway is a request ID, the provider and model names as the cost models spell them, the usage, and a way to carry your `run_id` and customer.

### Use the catalog's prices

Create a cost model with `catalog_model` for each provider model you call, and `trace()` the usage. There are no rates to enter and none to keep current: Runledger prices every line from the catalog price version in force when the call happened. This is the quickest setup, and the right start if you pay list price.

### Bring your own rates

If you pay less than list, add `overrides` to the catalog cost model: a discount or a replacement rate, with the dates and the contract it comes from. If you run your own GPUs, or a provider isn't in the catalog, create a custom cost model with a rate card of your own. Both kinds of cost model work together in one run, so a request that calls a catalog model and a self-hosted one is priced from each.

### Report a cost you already know

If your system already computes what a call cost, send it as `cost` and skip the cost models. Runledger records it as you sent it (`priced_by: request`), and margin reporting works the same way. Cost models are for when you want Runledger to work the cost out, version the rates, and price from them.

### Set it up with an agent

A coding agent can set this up in the sandbox the same way it sets up everything else, and propose the live change for a person to approve. The [MCP server](/agents/mcp) has three tools for this, `search_provider_catalog`, `get_cost_catalog` and `simulate_pricing`, and `validate_config`, `apply_config`, `propose_live_config` and `preview_event` cover the new objects. The order:

1. `get_cost_catalog` and `get_config` show what exists.
2. The agent finds the providers, models and units your code calls, and uses `search_provider_catalog` to match each one to a catalog model. It drafts a cost model with `catalog_model` for every match, so it doesn't enter or guess any prices. For a model the catalog doesn't have, it drafts a custom cost model and **asks you for the rates and where they came from**.
3. The agent asks whether you pay list price. **You supply any negotiated, committed or private rates and the contract behind them**, and the agent adds them as overrides. It doesn't assume a discount.
4. The agent asks you to confirm the markup or target margin, the floor, and whether a guardrail may block. It suggests `warn`.
5. `validate_config` returns the changes and warnings. The agent shows you every warning.
6. `apply_config` writes the sandbox. `preview_event` shows the `pricing` an event would get, and takes `price_rule` and `price_rule_version` to price it with a rule no plan rate uses yet.
7. `simulate_pricing` replays recent usage against the setup.
8. For live, `propose_live_config` sends the same document with a reason, and you approve the diff in the dashboard.

### Handle a provider price change

For a catalog cost model, listen for `catalog.price_changed` (or read `GET /v1/catalog/prices?changed_since=...`), read the scheduled rate card's `changes` and `affected_price_rules`, run a simulation with the new `catalog_price`, and decide whether the price rules hold or follow. For a custom cost model, add the rate card yourself. [Provider price changes and simulation](/guides/price-changes-and-simulation#when-a-provider-changes-its-prices) walks through both.

## API reference

[Trace a model call](/api-reference/trace), [list runs](/api-reference/runs/list), [summarize runs](/api-reference/runs/summary), the [margin report](/api-reference/reports/margin) and the [cost report](/api-reference/reports/cost).

## Next steps

* [Price rules and margin guardrails](/guides/price-rules-and-guardrails) use the margin this guide records.
* [Provider price changes and simulation](/guides/price-changes-and-simulation) shows what a catalog update does to your costs and prices.
* [Going live](/going-live/overview) lists what to check before real billing starts.