> 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.

# Price rules and margin guardrails

A price rule sets what you charge for a usage event. A margin guardrail sets the lowest margin you accept. This guide shows both, with the `check()` and `track()` results they produce.

> **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.
* You have at least one [cost model](/guides/cost-catalog-and-models), and a plan with a rate to point the rule at. [Prepaid and postpaid billing](/concepts/billing) covers plans.

## Price rules

A price rule works out the price of a usage event from its quantity and `properties`. A plan rate uses it with `price_rule` instead of a fixed amount:

```json
{
  "key": "studio_postpaid",
  "name": "Studio, postpaid",
  "billing_mode": "postpaid",
  "currency": "usd",
  "interval": "month",
  "rates": [
    { "event_name": "video.generate", "price_rule": "video_cost_plus", "description": "Generated video" }
  ]
}
```

There are three methods.

**`cost_plus`** prices the cost of the usage from your cost models, then adds a markup or targets a margin.

```json
{
  "key": "video_cost_plus",
  "name": "Video, cost plus",
  "method": "cost_plus",
  "currency": "usd",
  "basis": "catalog",
  "inputs": [{ "cost_model": "vidlab-v2", "unit": "video_seconds" }],
  "markup_percent": "0.4",
  "floor_decimal": "50",
  "on_cost_change": "hold"
}
```

Eight seconds of 1080p costs 8 × 12 = 96, so the price is 96 × 1.4 = 134.4 (US\$1.344; rounding happens once per invoice line, as [usual](/reference/conventions#money-credits-and-quantities)). Two seconds of 720p costs 12, which would price at 16.8, so the floor of 50 applies and the response says `floor_applied: true`. `markup_percent` is cost plus that share: any decimal of zero or more, so `"1.5"` prices at two and a half times cost. `target_margin_percent` makes the margin that share of the price, so `"0.3"` prices at cost over 0.7; it is a decimal from 0 to below 1, like `margin_percent` on a run. Send one of them. `floor_decimal` raises a price that comes out below it and `cap_decimal` lowers one that comes out above it, both in minor units; the response says `floor_applied` or `cap_applied`.

The basis `catalog` (and `priced_by: catalog` on a cost) is older than the provider price catalog and means priced from your cost models, whether their rates come from the provider price catalog, your overrides or your own rate cards. The event's dimension values come from its `properties`, so `track()` carries `{"resolution": "1080p"}` and the rule finds the 1080p rate. The rule's `basis` can also be `run_cost`, which prices from the costs `trace()` recorded under the event's `run_id`. That fits pass-through pricing of tokens after the work. `check()` can't know that cost in advance, so it prices from `estimated_usage` or from the observed average.

**`table`** looks the price up by dimension values and doesn't touch your cost models. It suits a price list your customers see.

```json
{
  "key": "image_table",
  "name": "Images, by size",
  "method": "table",
  "currency": "usd",
  "credit_value_decimal": "1",
  "rows": [
    { "dimensions": { "size": "1024" }, "unit_amount_decimal": "8" },
    { "dimensions": { "size": "2048" }, "unit_amount_decimal": "20" }
  ]
}
```

**`formula`** evaluates an expression over `cost`, `quantity` and numeric event properties written `properties.<name>`, with `+`, `-`, `*`, `/`, brackets, `min()`, `max()` and `ceil()`. `ceil(cost * 1.25 / 10) * 10` prices at cost plus 25%, rounded up to the next 10 cents. A formula that doesn't parse, or names something unknown, is a validation error that names the path.

**Prepaid plans.** The price is in money, and Runledger turns it into credits at `credit_value_decimal`, which is how many minor units one credit is worth. Without it, the plan's top-up price per credit is used. To weight credits by model or by plan tier, see the [recipes](/guides/recipes#weight-by-model).

**Outcomes.** Price rules price usage events. An outcome charge keeps its fixed price from the plan, because its cost isn't known when the charge is created. Margin guardrails cover outcomes too, as described next.

**Currency.** A rule has one `currency`, and it must match the currency of the rate cards of the cost models it reads and of every plan that uses it. Runledger doesn't convert between currencies, so a rule that mixes them fails validation, and reports show each currency separately.

**Try a rule before a plan uses it.** `POST /v1/events/preview` takes `price_rule` and `price_rule_version`, and returns the `pricing` the rule would give an event even when no plan rate uses the rule. See [Preview a price](/guides/cost-attribution-and-margin#preview-a-price).

**Deleting.** A rule that a plan rate still uses can't be deleted: `DELETE /v1/price_rules/{id}` returns `409` with `invalid_state`. Move the plan rate to another rule or a fixed amount first.

**Versions.** Each update to a rule makes a new version that applies to events from its `effective_from`. Earlier events keep the price they were given. Set `effective_from` in the future to give customers notice. A plan rate that uses a rule follows the version in force when each event happens. Plans with fixed rates stay as they are today.

## Margin guardrails

A guardrail sets a floor on margin for some of your work, and says what happens below it.

```json
{
  "key": "video_margin_floor",
  "name": "Video margin floor",
  "applies_to": { "event_name": "video.generate" },
  "basis": "expected",
  "min_margin_percent": "0.25",
  "action": "block"
}
```

`applies_to` can name an `event_name`, an `outcome_definition` and a `plan`, and all of them must match. With none, the guardrail covers everything.

* **`warn`** (the default) adds the warning `margin_below_guardrail` to the `check()` and `track()` response, and sends the `margin_guardrail.breached` webhook. A [notification rule](/api-reference/how-configuration-works#notification-rules) on the same event tells people.
* **`block`** also makes `check()` deny the work, with the reason `margin_below_floor`. `track()` never blocks, because the work has already happened, so it adds the warning `margin_below_guardrail`.

The two names are one guardrail seen two ways. `margin_below_floor` is the *reason* on a denied check, and appears in `reason`. `margin_below_guardrail` is the *code* on a warning, and appears in `warnings[].code`. Neither is an error code.

The `basis` says which margin is compared with the floor:

* **`expected`** is the margin the work should have: its price against its expected cost, worked out when `check()` runs. The cost comes from the first of three sources: `estimated_usage` if you send it; then, for a `cost_plus` rule on the `catalog` basis, the rule's own cost lines priced from the event's `quantity` and `properties`; then the average cost of recent work of the same kind over the guardrail's `window` (default `P7D`). With none of them, the guardrail isn't evaluated: `margin.evaluated` is `false`, each guardrail's `status` is `not_evaluated`, and the response carries the warning `no_cost_estimate`. It doesn't deny work it can't judge.
* **`realized`** is confirmed revenue minus cost over the trailing `window`, for the work the guardrail covers, counted the way the margin report counts it. A rising reversal rate lowers it, so a guardrail on one outcome definition can warn you when reversals start to eat the margin.

A `check()` that is allowed carries the margin it expected:

```json
{
  "object": "check",
  "allowed": true,
  "customer": "cus_9sF2kq81LmQz",
  "currency": "usd",
  "required": "134.4",
  "affordability": {
    "balance": { "available_before": null, "available_after": null, "shortfall": "0" },
    "pools": [],
    "allowances": [],
    "spend_limits": [],
    "would_exceed": false,
    "reason": null
  },
  "margin": {
    "evaluated": true,
    "currency": "usd",
    "price": "134.4",
    "expected_cost": "96",
    "expected_margin": "38.4",
    "margin_percent": "0.2857",
    "cost_source": "estimated_usage",
    "guardrails": [
      { "key": "video_margin_floor", "status": "ok", "min_margin_percent": "0.25", "margin_percent": "0.2857" }
    ]
  },
  "livemode": false
}
```

After the provider's price goes up and the rule holds its prices, the same request is denied:

```json
{
  "object": "check",
  "allowed": false,
  "reason": "margin_below_floor",
  "customer": "cus_9sF2kq81LmQz",
  "currency": "usd",
  "required": "134.4",
  "affordability": {
    "balance": { "available_before": null, "available_after": null, "shortfall": "0" },
    "pools": [],
    "allowances": [],
    "spend_limits": [],
    "would_exceed": false,
    "reason": "margin_below_floor"
  },
  "margin": {
    "evaluated": true,
    "currency": "usd",
    "price": "134.4",
    "expected_cost": "136",
    "expected_margin": "-1.6",
    "margin_percent": "-0.0119",
    "cost_source": "estimated_usage",
    "guardrails": [
      { "key": "video_margin_floor", "status": "block", "min_margin_percent": "0.25", "margin_percent": "-0.0119" }
    ]
  },
  "warnings": [
    { "code": "margin_below_guardrail", "message": "Expected margin is below the 25% floor of video_margin_floor." }
  ],
  "livemode": false
}
```

Blocking is a real decision: a denied `check()` is a customer who can't do what they asked. Start with `warn`, watch the warnings for a few weeks, and move to `block` once the floor is one you would stand behind.

## Next steps

* [Cost attribution and margin](/guides/cost-attribution-and-margin) sends the costs a guardrail compares against.
* [Provider price changes and simulation](/guides/price-changes-and-simulation) replays recent usage against a rule or guardrail before it goes live.
* [Checks and holds](/concepts/checks-and-holds) explains how a denied `check()` reaches your code.