Skip to main content

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. Empty children is treated as malformed and never matches.
  • OR - at least one child must match. Empty children is malformed.
  • NOT - single child, 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

PlanCondition keys allowed
Freecart.subtotal_gte, cart.subtotal_lte, cart.total_gte
GrowthFree + 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 / ScaleGrowth + 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 }
}
FieldTypeRequiredNotes
valueinteger (cents)yesThreshold in shop's base currency
currencyOverridesRecord<ISO, cents>noPer-currency thresholds. Wins over value when the cart's currency matches.
marketOverridesRecord<marketHandle, cents>noPer-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):

  1. marketOverrides[<market handle>] - when the customer's active market matches
  2. currencyOverrides[<currency code>] - when the cart's currency matches
  3. value - 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 }
}
FieldTypeRequiredNotes
valueinteger (cents)yesUpper-bound threshold in shop's base currency
currencyOverridesRecord<ISO, cents>noPer-currency thresholds. Wins over value when the cart's currency matches.
marketOverridesRecord<marketHandle, cents>noPer-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 }
FieldTypeRequired
valueintegeryes

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"
}
FieldTypeRequiredNotes
valueShopify Product GID or numeric idyesNumeric ids are auto-extracted from GIDs
sellingPlanIdsarray of GIDs (or "_otp")noFilter to subscription lines using a specific selling plan; "_otp" matches one-time-purchase lines
propertyKey + propertyValuestringsnoFilter 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"
}
FieldTypeRequiredNotes
valuecollection handle, numeric id, or GIDyesResolved 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"
}
FieldTypeRequiredNotes
valueintegeryesMinimum quantity
productId or variantIdGIDone of the twoIf neither is provided, the condition fails closed
sellingPlanIds, propertyKey, propertyValue-noSame 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"
}
FieldTypeRequired
keystringyes (empty string fails closed)
valuestringyes

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" }
FieldTypeRequiredNotes
value"has_subscription" | "no_subscription"yesDefault 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"]
}
FieldTypeRequiredNotes
valuearray of tag stringsyesComma-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 }
FieldTypeRequiredNotes
valuebooleanyesStrings "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"] }
FieldTypeRequired
valuearray of market handle stringsyes

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"] }
FieldTypeRequired
valuearray of 2-letter ISO codesyes

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" }
FieldTypeRequired
valuestringyes

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_order leaf. Tag the customer as new-customer via Shopify Flow's Customer creation trigger, then use customer.tag_in([new-customer]). See Operations → Shopify Flow recipes.
  • Subtotal between X and Y - this is now a direct pairing: AND the cart.subtotal_gte lower bound with the cart.subtotal_lte upper bound. No NOT workaround needed (the older "wrap a high-bound cart.subtotal_gte in NOT" recipe is obsolete now that cart.subtotal_lte exists):
    {
    "type": "AND",
    "children": [
    { "type": "cart.subtotal_gte", "value": 5000 },
    { "type": "cart.subtotal_lte", "value": 10000 }
    ]
    }
    Both bounds are inclusive (>= and <=), so this matches subtotals from $50.00 through $100.00.
  • Locale match - there's no shop.locale_in leaf. The customer's locale is determined by their active market; use market.handle_in instead.
  • Time-window scheduling - there's no shop.time_window leaf. 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 or POST /api/v1/campaigns/:id/schedule.
  • First-purchase product check - there's no per-customer order-history condition. Use line.has_product_id to 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 = true so 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_id are 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 →