All 17 condition types
The engine evaluates AND / OR / NOT combinators over 17 leaf conditions, stored as JSON on Rule.conditionTree. The same evaluator runs on the storefront (in the JS bundle) and in the simulator, so a tree that passes in one passes in the other given the same context.
This page is the canonical developer reference. For the merchant-facing walkthrough see Concepts → Conditions.
Tree shape
A conditionTree is a recursive JSON structure. Every node has a type field. Combinator nodes (AND / OR / NOT) wrap children; leaf nodes (the 17 below) are evaluated directly.
type ConditionNode = AndNode | OrNode | NotNode | LeafNode;
type AndNode = { type: "AND"; children: ConditionNode[] };
type OrNode = { type: "OR"; children: ConditionNode[] };
type NotNode = { type: "NOT"; child: ConditionNode };
type LeafNode = {
type: "cart.subtotal_gte" | "cart.subtotal_lte" | "cart.total_gte" | /* … 14 more … */;
// Plus condition-specific fields - see per-condition spec below.
};
Combinator semantics:
AND- every child must match. Emptychildrenis treated as malformed and never matches.OR- at least one child must match. Emptychildrenis malformed.NOT- singlechild, result is inverted.- Nesting depth is unbounded; performance is
O(n)in leaf count, depth doesn't matter.
A complete worked example - "logged-in VIP, cart subtotal ≥ $50, NOT a sneaker in cart":
{
"type": "AND",
"children": [
{
"type": "OR",
"children": [
{ "type": "customer.tag_in", "value": ["vip"] },
{ "type": "customer.is_logged_in", "value": true }
]
},
{ "type": "cart.subtotal_gte", "value": 5000 },
{
"type": "NOT",
"child": { "type": "line.in_collection", "value": "sneakers" }
}
]
}
Plan-tier matrix
| Plan | Condition keys allowed |
|---|---|
| Free | cart.subtotal_gte, cart.subtotal_lte, cart.total_gte |
| Growth | Free + cart.item_count_gte, line.has_product_id, line.has_variant_id, line.in_collection, line.quantity_min, line.property_equals, line.has_selling_plan |
| Pro / Scale | Growth + customer.tag_in, customer.is_logged_in, market.handle_in, country.in, discount.code_present, discount.code_not_present, discount.code_equals |
On a downgrade, enforcePlanFeatures strips disallowed condition types from rules at save-time. The campaign editor's condition picker hides locked types in the UI.
Per-condition spec
cart.subtotal_gte
Plan: Free+ · What it tests: cart subtotal (excluding gifts already added by Valotrix Cart Rewards) is ≥ the threshold
{
"type": "cart.subtotal_gte",
"value": 5000,
"currencyOverrides": { "EUR": 4500, "GBP": 4000 },
"marketOverrides": { "us-puerto-rico": 5500 }
}
| Field | Type | Required | Notes |
|---|---|---|---|
value | integer (cents) | yes | Threshold in shop's base currency |
currencyOverrides | Record<ISO, cents> | no | Per-currency thresholds. Wins over value when the cart's currency matches. |
marketOverrides | Record<marketHandle, cents> | no | Per-market thresholds. Wins over currencyOverrides for the matching market. Used when two markets share a currency but need different thresholds. |
Resolution order (first hit wins):
marketOverrides[<market handle>]- when the customer's active market matchescurrencyOverrides[<currency code>]- when the cart's currency matchesvalue- only if the cart's currency matches the shop's base currency
Foreign-currency behavior: if the cart is in a currency with no override and isn't the base currency, the condition fails closed (returns matched: false) rather than comparing raw cents across currencies. This is intentional - comparing 5000 USD-cents to a EUR cart would silently grant gifts to wrong-amount carts.
Defense-in-depth: negative / NaN / Infinity in any threshold candidate also fails closed.
cart.subtotal_lte
Plan: Free+ · What it tests: cart subtotal (excluding gifts already added by Valotrix Cart Rewards) is <= the threshold - the upper-bound sibling of cart.subtotal_gte
{
"type": "cart.subtotal_lte",
"value": 10000,
"currencyOverrides": { "EUR": 9000, "GBP": 8000 },
"marketOverrides": { "us-puerto-rico": 11000 }
}
| Field | Type | Required | Notes |
|---|---|---|---|
value | integer (cents) | yes | Upper-bound threshold in shop's base currency |
currencyOverrides | Record<ISO, cents> | no | Per-currency thresholds. Wins over value when the cart's currency matches. |
marketOverrides | Record<marketHandle, cents> | no | Per-market thresholds. Wins over currencyOverrides for the matching market. Used when two markets share a currency but need different upper bounds. |
Resolution order is identical to cart.subtotal_gte (first hit wins): marketOverrides[<market handle>] → currencyOverrides[<currency code>] → value (only when the cart's currency matches the shop's base currency).
Comparison: the resolved threshold is compared with <=, so a cart whose subtotal exactly equals the threshold still matches.
Foreign-currency behavior: same as cart.subtotal_gte - a cart in a currency with no override that isn't the base currency fails closed (returns matched: false) rather than comparing raw cents across currencies.
Defense-in-depth: negative / NaN / Infinity in any threshold candidate also fails closed.
Pairing for a band: combine with cart.subtotal_gte under an AND to express "subtotal between $X and $Y" without the older NOT workaround - see Patterns the engine doesn't have a single condition for.
cart.total_gte
Plan: Free+ · What it tests: cart total (subtotal + shipping + tax, minus already-added gift contribution) is ≥ the threshold
Same shape as cart.subtotal_gte - supports value, currencyOverrides, marketOverrides.
Use cart.total_gte when you want the threshold to fire on what the customer actually pays (post-shipping, post-tax) rather than just the items.
cart.item_count_gte
Plan: Growth+ · What it tests: total item count across all non-gift cart lines is ≥ the threshold
{ "type": "cart.item_count_gte", "value": 3 }
| Field | Type | Required |
|---|---|---|
value | integer | yes |
Negative / NaN / Infinity values fail closed.
line.has_product_id
Plan: Growth+ · What it tests: at least one cart line is the specified product
{
"type": "line.has_product_id",
"value": "gid://shopify/Product/12345",
"sellingPlanIds": ["gid://shopify/SellingPlan/9876"],
"propertyKey": "engraving",
"propertyValue": "Yes"
}
| Field | Type | Required | Notes |
|---|---|---|---|
value | Shopify Product GID or numeric id | yes | Numeric ids are auto-extracted from GIDs |
sellingPlanIds | array of GIDs (or "_otp") | no | Filter to subscription lines using a specific selling plan; "_otp" matches one-time-purchase lines |
propertyKey + propertyValue | strings | no | Filter to lines that have a specific custom note attached |
If sellingPlanIds is provided as an empty array [], the condition fails closed (no line can match an empty filter).
line.has_variant_id
Plan: Growth+ · What it tests: at least one cart line is the specified variant
Same shape as line.has_product_id but value is a variant GID. Tighter than the product-level match - useful when only "Red, Size M" should trigger.
line.in_collection
Plan: Growth+ · What it tests: at least one cart line is a product from the specified collection
{
"type": "line.in_collection",
"value": "summer-2026"
}
| Field | Type | Required | Notes |
|---|---|---|---|
value | collection handle, numeric id, or GID | yes | Resolved through the storefront's collectionProductMap payload |
If the collectionProductMap doesn't have an entry for the collection (e.g. mapping not yet built), the engine falls back to checking the cart line's properties._collections field. This fallback is rarely hit on a healthy install.
line.quantity_min
Plan: Growth+ · What it tests: the total quantity of a specific product or variant in the cart is ≥ the threshold
{
"type": "line.quantity_min",
"value": 2,
"productId": "gid://shopify/Product/12345"
}
Or variant-targeted:
{
"type": "line.quantity_min",
"value": 2,
"variantId": "gid://shopify/ProductVariant/67890"
}
| Field | Type | Required | Notes |
|---|---|---|---|
value | integer | yes | Minimum quantity |
productId or variantId | GID | one of the two | If neither is provided, the condition fails closed |
sellingPlanIds, propertyKey, propertyValue | - | no | Same modifiers as line.has_product_id |
variantId takes precedence over productId if both are provided.
line.property_equals
Plan: Growth+ · What it tests: at least one cart line has a custom note (line-property) with the matching key/value
{
"type": "line.property_equals",
"key": "engraving",
"value": "Happy Birthday"
}
| Field | Type | Required |
|---|---|---|
key | string | yes (empty string fails closed) |
value | string | yes |
The engine strips wrapping single/double quotes from both sides before comparing - pasted values don't accidentally compare with extra punctuation.
line.has_selling_plan
Plan: Growth+ · What it tests: the cart contains (or doesn't contain) any subscription line
{ "type": "line.has_selling_plan", "value": "has_subscription" }
| Field | Type | Required | Notes |
|---|---|---|---|
value | "has_subscription" | "no_subscription" | yes | Default if empty: "has_subscription" |
This is a binary cart-level check, not a per-line check. Use the sellingPlanIds modifier on line.has_product_id / line.has_variant_id / line.quantity_min for finer-grained filters.
customer.tag_in
Plan: Pro+ · What it tests: the logged-in customer has at least one of the listed tags (case-insensitive)
{
"type": "customer.tag_in",
"value": ["vip", "wholesale"]
}
| Field | Type | Required | Notes |
|---|---|---|---|
value | array of tag strings | yes | Comma-separated string also accepted; both normalized to a string array |
Empty array fails closed.
customer.is_logged_in
Plan: Pro+ · What it tests: the customer's logged-in state matches the expected boolean
{ "type": "customer.is_logged_in", "value": true }
| Field | Type | Required | Notes |
|---|---|---|---|
value | boolean | yes | Strings "true" / "false" accepted as boolean coerced; other values fail closed |
For "guest only", wrap in NOT:
{ "type": "NOT", "child": { "type": "customer.is_logged_in", "value": true } }
market.handle_in
Plan: Pro+ · What it tests: the customer's active Shopify market handle matches one of the listed values (case-insensitive)
{ "type": "market.handle_in", "value": ["eu-de", "eu-at", "eu-ch"] }
| Field | Type | Required |
|---|---|---|
value | array of market handle strings | yes |
Empty array fails closed.
country.in
Plan: Pro+ · What it tests: the customer's market country (ISO-3166 2-letter) matches one of the listed codes (case-insensitive)
{ "type": "country.in", "value": ["DE", "AT", "CH"] }
| Field | Type | Required |
|---|---|---|
value | array of 2-letter ISO codes | yes |
The country is whatever Shopify's market context resolves for the customer (which factors in IP-geolocation and any market selector your theme exposes).
discount.code_present
Plan: Pro+ · What it tests: at least one discount code is currently applied on the cart
{ "type": "discount.code_present" }
No fields. Always-true if any code is in the cart.
discount.code_not_present
Plan: Pro+ · What it tests: no discount code is currently applied on the cart
{ "type": "discount.code_not_present" }
No fields. Always-true if the cart has zero codes. Use this to keep gift campaigns off carts that already carry a promo (avoiding stacking).
discount.code_equals
Plan: Pro+ · What it tests: a specific discount code is currently applied (case-insensitive)
{ "type": "discount.code_equals", "value": "SUMMER20" }
| Field | Type | Required |
|---|---|---|
value | string | yes |
Comparison is uppercased on both sides before matching, so "summer20" and "SUMMER20" are equivalent.
Patterns the engine doesn't have a single condition for
A few common-sounding triggers don't map to a single leaf. They're achievable with combinations or with a Shopify Flow recipe upstream:
- First-time buyers - there's no
customer.first_orderleaf. Tag the customer asnew-customervia Shopify Flow's Customer creation trigger, then usecustomer.tag_in([new-customer]). See Operations → Shopify Flow recipes. - Subtotal between X and Y - this is now a direct pairing:
ANDthecart.subtotal_gtelower bound with thecart.subtotal_lteupper bound. No NOT workaround needed (the older "wrap a high-boundcart.subtotal_gtein NOT" recipe is obsolete now thatcart.subtotal_lteexists):Both bounds are inclusive ({"type": "AND","children": [{ "type": "cart.subtotal_gte", "value": 5000 },{ "type": "cart.subtotal_lte", "value": 10000 }]}>=and<=), so this matches subtotals from $50.00 through $100.00. - Locale match - there's no
shop.locale_inleaf. The customer's locale is determined by their active market; usemarket.handle_ininstead. - Time-window scheduling - there's no
shop.time_windowleaf. Schedule campaign enable/disable via Shopify Flow with the Scheduled time trigger, calling Valotrix Cart Rewards's Enable promotion campaign / Disable promotion campaign actions. See Holiday campaign with start/end dates orPOST /api/v1/campaigns/:id/schedule. - First-purchase product check - there's no per-customer order-history condition. Use
line.has_product_idto gate on what's currently in cart, not on past orders.
Performance characteristics
- Storefront hot path: AND short-circuits on the first false child (skips the rest).
- Simulator (developer mode): sets
traceEnabled = trueso every child evaluates regardless, producing the full per-condition trace. - OR evaluates every child only when all fail (so the trace shows every branch's reason). Short-circuits on the first match.
- Condition trees are evaluated against the non-gift cart - lines tagged with
_vltrx_campaign_idare excluded from threshold checks and qualifier presence checks. Without this, the engine would re-count its own output and the trigger would stay "matched" after the qualifier was removed.
Engine evaluation result
Each leaf returns:
{
matched: boolean;
reasons: string[]; // Short tags for telemetry / humanization
trace?: ConditionTraceNode; // Tree node consumed by the simulator's humanizer
}
reasons are short single-character or kebab-case codes - primarily used by the simulator's humanize-trace layer to render English explanations. They're not part of the public stability contract.
Next: Public REST API →