Order Promotions
Promotions are dynamic, component-based calculations attached to orders. Each promotion runs a Filtrera script in a pure runtime and can produce discount effects (percentage or absolute adjustments) and promotional messages (customer-facing text). Promotions re-evaluate automatically whenever the order changes.
TIP
For simple fixed-value deductions that don't need calculation logic, see Discounts.
How Promotions Work
When an order changes — items added, quantities updated, delivery methods changed — every promotion on that order is re-evaluated:
- The promotion component executes with the current order state
- The component returns discount effects and/or message effects
- Discount effects are applied as calculated discounts on order lines and shipping
- Message effects are stored on the promotion for display to the customer
This happens automatically. No manual triggering is required.
Promotion Components
Promotion logic is written in Filtrera using the promotion runtime — a pure, side-effect-free environment. Components use the .hpromo or .hpr file extension.
The order Symbol
Every promotion component has access to the order symbol, which represents the current state of the entire order including deliveries, order lines, and shipping.
Discount Effects
A promotion can return discount effects using three built-in helpers:
percentage(target, rate)
Apply a percentage discount:
// 10% off entire order
from percentage(order, 10%)
// 100% off shipping (free shipping)
from percentage(target(e => e is Delivery), 100%)
// 20% off specific product category
from percentage(target(e => e is OrderLine and e.dynamic->'category' == 'electronics'), 20%)absolute(target, amount)
Apply a fixed amount discount:
// 50 off entire order
from absolute(order, 50)
// 10 off each qualifying line
from absolute(target(e => e is OrderLine and e.quantity > 5), 10)target(filter)
Select which parts of the order to discount:
// Target deliveries
target(e => e is Delivery)
// Target specific order lines
target(e => e is OrderLine and e.productNumber == 'SKU-123')Returning Nothing
If a promotion should have no effect for a given order state, return nothing:
from order.total < 100 match
true |> nothing
false |> percentage(order, 5%)Message Effects
Beyond discounts, promotions can return messages — customer-facing text that communicates promotional status. Messages are useful for showing progress toward a threshold, confirming that a promotion is active, or upselling related offers.
Basic Message
A message effect is a record with type = 'message' and a message field:
from {
type = 'message'
message = 'Free shipping applied!'
}Message Placeholders and Fields
Messages support a placeholder syntax using {key} in the message text, paired with a fields record that provides the actual values:
from {
type = 'message'
message = 'Add {remaining} more for free shipping!'
fields = { remaining = threshold - order.total }
}This produces three stored values on the promotion:
| Field | Value | Purpose |
|---|---|---|
messageTemplate | "Add {remaining} more for free shipping!" | The raw template with placeholders intact |
messageFields | { remaining: 150 } | The structured field values |
messageRendered | "Add 150 more for free shipping!" | Server-rendered version with placeholders replaced |
The placeholder approach gives consumers flexibility:
- Simple clients can display
messageRendereddirectly - Rich clients (e.g., storefronts) can use
messageTemplate+messageFieldsto render the message with custom formatting — for example, styling the{remaining}value in bold or a different color
Message Type
Each message has a messageType that identifies the kind of message. This is useful for consumers to decide how to display or filter messages without comparing raw strings.
You can set it explicitly:
from {
type = 'message'
messageType = 'free-shipping-progress'
message = 'Add {remaining} more for free shipping!'
fields = { remaining = threshold - order.total }
}If messageType is omitted, one is automatically generated by hashing the message template. This means messages with the same template text will share the same type, while different templates get different types.
Combining Discount and Message Effects
A promotion component can return both discount effects and message effects as a tuple:
// free-shipping-with-message.hpromo
param threshold: number
from order.total >= threshold match
true |>
from percentage(target(e => e is Delivery), 100%)
from { type = 'message', message = 'You qualify for free shipping!' }
false |>
from {
type = 'message'
message = 'Add {remaining} more for free shipping!'
fields = { remaining = threshold - order.total }
}When the order total meets the threshold, the customer gets free shipping and a confirmation message. Below the threshold, they see a progress message with the remaining amount — no discount applied.
Promotion Groups and Combination Rules
Every promotion belongs to a promotion group — a string that categorizes the promotion (e.g., 'shipping', 'loyalty', 'campaign'). Groups control how promotions interact when multiple are active on the same order.
canOnlyCombineWith
Restrict a promotion to only work alongside specific groups. If any other group is active, this promotion is excluded:
{
"type": "createPromotion",
"componentId": "exclusive-deal",
"promotionGroup": "exclusive",
"canOnlyCombineWith": ["loyalty"],
"description": "Exclusive Deal"
}This promotion only stays active if the only other active promotions belong to the loyalty group.
canNotCombineWith
Exclude a promotion when specific groups are active:
{
"type": "createPromotion",
"componentId": "free-shipping",
"promotionGroup": "shipping",
"canNotCombineWith": ["exclusive"],
"description": "Free Shipping"
}This promotion is excluded if any promotion in the exclusive group is active.
How Combination Rules Are Applied
After all promotions evaluate, the system checks combination rules:
- Each active promotion's rules are tested against other active promotions' groups
- Promotions that violate their rules are marked as inactive (
isActive = false) - Inactive promotions contribute no discount effects or messages
- The order totals are calculated using only the surviving active promotions
Creating Promotions
Promotions are added to orders via commands, typically emitted from rules.
Via Component Reference
The most common approach. The component is a deployed .hpromo resource:
{
"type": "createPromotion",
"componentId": "free-shipping",
"promotionGroup": "shipping",
"description": "Free Shipping on Orders Over 500",
"parameters": {
"threshold": "500"
}
}See createPromotion command reference.
Via Inline Source
For dynamic or one-off promotions, embed the Filtrera source directly:
{
"type": "createPromotionBySource",
"source": "from percentage(order, 10%)",
"promotionGroup": "campaign",
"description": "10% Welcome Discount"
}See createPromotionBySource command reference.
Common Pattern: Rule-Based Promotions
The typical approach is to use a rule to add promotions to orders when certain conditions are met, then let the promotion component handle the ongoing calculation.
Example: Time-Limited Free Shipping
1. Create a reusable promotion component:
// free-shipping.hpromo
param threshold: number
from order.total >= threshold match
true |>
from percentage(target(e => e is Delivery), 100%)
from { type = 'message', message = 'Free shipping applied!' }
false |>
from {
type = 'message'
message = 'Spend {remaining} more for free shipping'
fields = { remaining = threshold - order.total }
}2. Create a rule to add the promotion:
// black-friday.hrule
param input: OnOrderCreated
from [{
effect = 'orderCommand'
type = 'createPromotion'
componentId = 'free-shipping'
promotionGroup = 'shipping'
description = 'Black Friday Free Shipping'
parameters = { threshold = '500' }
}]3. Deploy via manifest with activation dates:
uri: /resources/components/free-shipping.hpromo
spec:
codeFile: free-shipping.hpromo
---
uri: /resources/rules/black-friday
spec:
codeFile: black-friday.hrule
activeFrom: 2025-11-29T00:00:00Z
activeTo: 2025-11-29T23:59:59ZThe activeFrom and activeTo fields on the rule entity control when the rule fires. The promotion component itself is stateless and reusable — timing is configured at the rule level.
How It Plays Out
During the campaign (Nov 29):
OnOrderCreatedrule fires for each new order (within the active window)- Promotion is added with the
free-shippingcomponent andthreshold = 500 - As the customer adds items, the promotion re-evaluates
- Below threshold: message says "Spend X more for free shipping"
- Above threshold: free shipping is applied with a confirmation message
After the campaign ends (Nov 30+):
- The rule no longer fires for new orders
- Existing orders keep their promotion — it continues to work as the order is modified
- If the customer removes items and drops below threshold, the discount disappears but the message updates
Managing Promotions
Updating Parameters
Change the runtime parameters of an existing promotion:
{
"type": "setPromotionParameters",
"promotionId": "65812410-bd14-4d6d-ab79-374789a976c1",
"parameters": { "threshold": "750" }
}Updating Description
{
"type": "setPromotionDescription",
"promotionId": "65812410-bd14-4d6d-ab79-374789a976c1",
"description": "Updated Campaign Name"
}Dynamic Fields
Store custom metadata on a promotion:
{
"type": "setPromotionDynamicFields",
"promotionId": "65812410-bd14-4d6d-ab79-374789a976c1",
"fields": { "campaignId": "BF2025", "source": "email" }
}See setPromotionDynamicFields.
Deleting
{
"type": "deletePromotion",
"promotionId": "65812410-bd14-4d6d-ab79-374789a976c1"
}See deletePromotion.
Pure Runtime Constraints
Promotion components run in a pure runtime with strict restrictions. This ensures promotions evaluate quickly and safely without unintended side effects.
What You CAN Do
- ✅ Access the current order via the
ordersymbol - ✅ Access custom parameters passed to the component
- ✅ Perform calculations and logic
- ✅ Use pattern matching and filtering
- ✅ Return discount effects (
percentage,absolute,target) - ✅ Return message effects
What You CANNOT Do
- ❌ Send messages to actors
- ❌ Send emails
- ❌ Query the graph
- ❌ Access the registry
- ❌ Schedule jobs
- ❌ Modify external state
Error Handling
If a promotion component fails to evaluate — due to a parse error, invalid parameters, or runtime exception — the error is captured on the promotion entity:
error.code— error classification (e.g.,PARSE_ERROR,INVALID_PARAMETER,INTERNAL_ERROR)error.message— human-readable description
Errored promotions are marked as inactive and contribute no effects. The rest of the order's promotions continue to function normally.
Promotion Entity Fields
| Field | Description |
|---|---|
promotionId | Unique identifier |
component | Reference to the component (componentId, version) |
parameters | Key-value pairs passed to the component |
description | Customer-facing text, copied to invoices |
promotionGroup | Group for combination rules |
canOnlyCombineWith | Groups this promotion can coexist with |
canNotCombineWith | Groups that exclude this promotion |
isActive | Whether the promotion currently produces effects |
messageType | Type identifier for the current message (explicit or auto-hashed) |
messageTemplate | The raw message text with {placeholder} syntax |
messageRendered | Server-rendered message with placeholders replaced |
messageFields | Structured field values for client-side rendering |
calculatedTotal | Total discount amount this promotion contributes |
error | Error details if evaluation failed |
dynamic | Custom metadata |
createdAt | Creation timestamp |
Querying Promotions
Promotions are available via the Graph:
promotion— the promotion entitycalculatedDiscount— resolved discount amounts (linked viapromotionId)
Advanced Example: Tiered Volume Discount with Message
// volume-discount.hpromo
let rate = order.total match
when order.total >= 10000 |> 15%
when order.total >= 5000 |> 10%
when order.total >= 1000 |> 5%
|> 0%
let nextTier = order.total match
when order.total >= 10000 |> nothing
when order.total >= 5000 |> { target = 10000, rate = '15%' }
when order.total >= 1000 |> { target = 5000, rate = '10%' }
|> { target = 1000, rate = '5%' }
from rate > 0% match
true |> percentage(order, rate)
from nextTier match
nothing |>
{ type = 'message', message = "You're at our best tier — 15% off!" }
|>
{
type = 'message'
message = 'Spend {remaining} more to unlock {nextRate} off'
fields = {
remaining = nextTier.target - order.total
nextRate = nextTier.rate
}
}This promotion:
- Applies 5%/10%/15% off based on order value
- Shows a message about the next available tier using placeholders
- Re-calculates automatically as the order changes
Best Practices
Use Generic Components with Parameters
Create reusable promotion components and configure them via parameters:
// free-shipping.hpromo — reusable across campaigns
param threshold: number
from order.total >= threshold match
false |> nothing
true |> percentage(target(e => e is Delivery), 100%)Rules configure the specifics:
param input: OnOrderCreated
from [{
effect = 'orderCommand'
type = 'createPromotion'
componentId = 'free-shipping'
promotionGroup = 'shipping'
description = 'Summer Free Shipping'
parameters = { threshold = '300' }
}]Use Promotion Groups Intentionally
Group promotions by business category so combination rules are meaningful:
'shipping'— free shipping promotions'campaign'— seasonal campaigns'loyalty'— member benefits'coupon'— one-time coupon codes
Use Messages for Customer Communication
Messages are rendered and stored on the promotion, making them available through the Graph for display in storefronts, portals, and emails. Use placeholders with fields when you want rich clients to render values with custom formatting:
// Placeholder approach — allows rich rendering
from {
type = 'message'
message = 'You saved {amount} on this order!'
fields = { amount = order.total * rate }
}Use Clear Descriptions
The description field appears on invoices and customer communications:
- ✅
"Free Shipping on Orders Over 500","10% Volume Discount","Loyalty Member Benefit" - ❌
"Discount","Promo","Test"
See Also
- Discounts — static, fixed-value discounts
- Components — learn about components and runtimes
- Rules — react to system events
- Rule Effects — how to emit order commands from rules
- Order Actor — order actor overview
- Order Commands — full command reference