Surge Pricing
Rule-based dynamic pricing: define a policy as an ordered list of price transforms (multipliers, discounts, lookups, charm rounding) with conditions, then compute final prices for items against a real-time context - with policy versioning, what-if simulation, evaluation traces, and a TTL'd demand-metric store. Every example on this page was executed against a live server.
When to reach for it: ride-share or delivery surge multipliers, time-of-day pricing, zone- or tier-based price tables, demand-driven API pricing, what-if simulation of a pricing change before shipping it.
When not to: static offers and scheduled price drops belong to Chrono or a Cart with fixed prices; usage metering and billing counters belong to Meter.
Concepts
- A policy is an ordered rule pipeline. Each enabled rule whose conditions pass transforms the running price; the output of one rule feeds the next. The final price is rounded to 2 decimals.
- Rules have a
rule_type,params, optionalconditions, and anenabledflag - the six types are cataloged in Rules and conditions. - Context is the JSON sent with each calculation; conditions and
dynamic multipliers read from it via dot paths (
user.tier). - Every update is a new version. The latest becomes active; old versions stay readable and rollback re-activates one in place.
- Metrics are a global, TTL-expiring store for demand signals - they do not feed calculations automatically.
- Roles: creating, updating, deleting, or rolling back policies and
creating lookup tables require a platform
adminoreditorrole; calculating, simulating, and all reads work with any token.
Define a policy
policy create takes a JSON file with policy_id, description, and
rules:
{
"policy_id": "rideshare",
"description": "Rideshare surge demo",
"rules": [
{ "name": "demand_surge", "rule_type": "multiply", "params": { "multiplier_field": "demand" },
"conditions": [ { "field": "demand", "operator": "greater_than", "value": 1.0 } ] },
{ "name": "late_night", "rule_type": "multiply", "params": { "multiplier": 1.2 },
"conditions": [ { "field": "clock", "operator": "time_in_range", "value": ["22:00", "05:00"] } ] },
{ "name": "charm", "rule_type": "round_psychological", "params": {}, "conditions": [] }
]
}snug surge-pricing policy create --file ./policy.json # admin or editor role
snug surge-pricing policy get --policy-id rideshare
snug surge-pricing policy listThe CLI's policy list takes no filters; the HTTP list endpoint speaks
the shared search grammar (q over
descriptions, filter=deleted:eq:true, sort_by, pagination).
Calculate prices
calculate is batch and item-centric: each item carries its own
base_price, and one context applies to all. Batch with --items-file
(a JSON array of {item_id, base_price}) and override the currency
label with --currency EUR - both verified.
snug surge-pricing calculate --policy-id rideshare --item-id ride1 \
--base-price 10.0 --context '{"demand":1.8,"clock":"23:30"}'Verified: base 10.00 with demand: 1.8 at 23:30 came back 21.99
(surge x1.8, late night x1.2, charm-rounded); with demand: 0.8 at
noon, 9.99. Over HTTP:
curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" -H "Content-Type: application/json" \
-d '{"policy_id":"rideshare","items":[{"item_id":"ride1","base_price":10.0}],
"context":{"demand":1.5,"clock":"12:00"}}' \
http://localhost:4000/api/v1/surge-pricing/calculate{
"status": 200,
"msg": "OK",
"data": {
"results": [
{ "item_id": "ride1", "final_price": 14.99, "currency": "USD", "trace_id": "uYqFxHDRXThK" }
]
}
}Explain, trace, simulate
--explain inlines a per-rule breakdown and persists it as a
retrievable trace; --persist-trace stores the trace without inlining.
Captured live:
{
"item_id": "ride1",
"final_price": 21.99,
"explanation": [
{ "rule": "demand_surge", "effect": "Increased by 80.0%", "result": 18.0 },
{ "rule": "late_night", "effect": "Increased by 20.0%", "result": 21.6 },
{ "rule": "charm", "effect": "Charm-rounded to $21.99", "result": 21.99 }
],
"trace_id": "uUSCGPzySDuH"
}Fetch it back later with snug surge-pricing trace get --trace-id ....
Every result carries a trace_id, but the trace is only retrievable if
you passed --explain or --persist-trace - otherwise
404 trace_not_found (reproduced live). Traces expire after 24 hours.
For a what-if without touching stored state, policy simulate takes one
--base-price plus --context '{...}' or --contexts-file and returns
the price and explanation per context. Verified: base 10.00 across three
contexts returned 9.99, 14.99, and 26.99 as demand rose.
Metrics: feeding demand signals
snug surge-pricing metric push --name demand --value 1.8 # or --json '{...}'
snug surge-pricing metric get --name demand
snug surge-pricing metric listMetrics are global (keyed by name, not per policy) and expire after 300
seconds by default, so push on an interval. Pushing a metric does not
change calculations - rules only read the per-request context
(verified: with demand pushed, calculating without --context still
failed field_not_found). The wiring is yours:
demand=$(snug --output json surge-pricing metric get --name demand | jq '.value')
snug surge-pricing calculate --policy-id rideshare --item-id ride1 \
--base-price 10.0 --context "{\"demand\":$demand,\"clock\":\"12:00\"}"Versions, rollback, delete
snug surge-pricing policy update --policy-id rideshare --file ./policy-v2.json
snug surge-pricing policy history --policy-id rideshare
snug surge-pricing policy get --policy-id rideshare --version 1
snug surge-pricing policy rollback --policy-id rideshare --version 1
snug surge-pricing policy delete --policy-id rideshareAll verified live: update replaces the whole rule list (not a merge).
rollback re-activates the target version in place - after rolling back
from version 2 to 1, history showed active_version: 1 with both
versions intact; no new version number is minted. delete is a soft
delete: subsequent reads fail with 410 policy_deleted (not 404), and
the policy disappears from list unless include_deleted=true.
Behaviors and gotchas
Each reproduced live:
- There are no price caps or floors. The final price is purely what
the rules compute: an
addof-50on a 10.00 base returned-40.0, and a contextdemandof 500 returned4999.99. Clamp via rule conditions or in the caller if you need bounds. - A
multiplier_fieldvalue from context is not range-checked - the 0.01-100 bound applies only to a literalmultiplierin the policy. - A field a rule needs but the context lacks is an error, not a
skip:
400 field_not_foundnames the field. - Lookup references resolve at calculate time, not create time. A
policy naming a nonexistent table creates fine but fails calculation
with
404 lookup_table_not_found. - Charm rounding can raise the price: 21.6 became 21.99. Whole numbers drop first (20.00 becomes 19.99).
Limits and configuration
Policies hold up to 50 rules, calculations up to 1000 items, and
simulations up to 500 contexts per request by default; metric TTL
(300s), trace TTL (24h), and the default currency (USD) are tunable
via the SURGE_PRICING_* variables in the
Surge Pricing CONFIG reference.
Reference
- Rules and conditions - the full rule-type and operator catalog, lookup tables, and time windows
- Surge Pricing API - every endpoint, callable
- Related: Chrono for scheduled offers, Meter for usage metering, Promo Code for discount codes