Skip to navigation

Price rules and margin guardrails

Work out a usage price from cost, and warn or stop work that would sell below a margin floor.

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.

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

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:

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

{
"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). 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.

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

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.

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.

{
"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 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:

{
"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:

{
"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