Blocks checkout when cart attributes match merchant-defined rules. Powered by Shopify's cart.validations.generate.run Function target.
The validation function reads ONE cart attribute (_switchboard, a JSON-encoded blob of key→value pairs) and evaluates merchant-authored rule trees against it. On match, Shopify shows your custom error message and prevents the customer from progressing past the configured block-type step.
_switchboard cart attribute (a single rolled-up JSON blob). Attribute keys are arbitrary — whatever you want to evaluate against.attribute is operator value). Groups combine with AND/OR.The cart attribute and the rule both reference attribute keys by name. As long as the storefront writes the same key the rule reads, things match up.
Shopify's validation framework supports three block types:
| Block type | When the error fires | What the customer sees |
|---|---|---|
| Cart | Whenever the cart changes, including the moment they click Checkout | Error shown on the cart page; cannot proceed |
| Checkout | At the contact/shipping step in checkout | Error inside checkout; cannot advance |
| Checkout completion | At the final "Pay now" / order-submission step | Error at the payment step; payment cannot complete |
Pick the surface where you want to stop the customer:
RuleTree = {
operator: "AND" | "OR", // how groups combine
groups: [
{
operator: "AND" | "OR", // how rules within this group combine
rules: [
{ attribute, comparator, operator, value? }
]
},
...
]
}
Top-level operator: how the groups combine. "AND" = all groups must match; "OR" = any group matches → fire.
Group operator: how rules within a group combine. Same semantics.
Rule: one comparison between a cart attribute and a value.
This shape lets you express logical formulas like:
(country = "US" AND order_value > 5000) OR (fraud_score > 80)
which is:
{
"operator": "OR",
"groups": [
{
"operator": "AND",
"rules": [
{ "attribute": "country", "comparator": "is", "operator": "equals", "value": "US" },
{ "attribute": "order_value", "comparator": "is", "operator": "greater_than", "value": 5000 }
]
},
{
"operator": "AND",
"rules": [
{ "attribute": "fraud_score", "comparator": "is", "operator": "greater_than", "value": 80 }
]
}
]
}
The admin UI builds this for you via Add Group / Add Rule buttons — you don't author the JSON by hand.
| Operator | What it does | Notes |
|---|---|---|
equals |
Exact string equality | Compares stringified values; case-sensitive |
contains |
Substring match | Case-sensitive |
starts_with |
Prefix match | Case-sensitive |
ends_with |
Suffix match | Case-sensitive |
greater_than |
Numeric > |
Both sides parsed as numbers |
less_than |
Numeric < |
Both sides parsed as numbers |
exists |
Attribute is set to a non-empty value | Ignores value field |
Comparators — is or is not — wrap any of the above to negate (e.g. country is not equals "CA").
The function reads one well-known cart attribute: _switchboard. Its value is a JSON-encoded object whose keys are what your rules reference.
Easiest path — Switchboard SDK:
// Set one attribute
Switchboard.setCartAttribute('country', 'US');
Switchboard.setCartAttribute('order_value', '15000');
// Set several at once (single cart write)
Switchboard.setBatch({
country: 'US',
order_value: '15000',
loyalty_tier: 'vip'
});
// Clear one
Switchboard.clear('country');
// Read one back
Switchboard.read('country').then(console.log);
Declarative — bind form inputs:
<select data-switchboard-attr="loyalty_tier">
<option value="basic">Basic</option>
<option value="vip">VIP</option>
</select>
<input type="checkbox" data-switchboard-attr="age_confirmed">
The SDK syncs these to the cart attribute on every change. No JS required.
Don't want the SDK? Write the rollup attribute directly:
fetch('/cart/update.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
attributes: {
_switchboard: JSON.stringify({ country: 'US', order_value: '15000' })
}
})
});
See /docs/sdk for the full SDK reference.
Problem: "We only ship to Canada. Block US orders at the cart with a clear message."
Rule:
country, comparator is not, operator equals, value CAMessage: "We currently only ship within Canada. Please reach out for special arrangements."
Block type: Cart.
Storefront (Liquid in theme.liquid):
{%- if customer and customer.default_address.country_code -%}
<script>
document.addEventListener('DOMContentLoaded', function () {
if (window.Switchboard) {
Switchboard.setCartAttribute('country', {{ customer.default_address.country_code | json }});
}
});
</script>
{%- endif -%}
Problem: "Wholesale customers must have a $500 minimum order."
Rule:
loyalty_tier, is, equals, wholesalecart_value_cents, is, less_than, 50000Message: "Wholesale orders have a $500 minimum. You're at $X.XX — add a bit more or contact us."
Block type: Cart (preferred — they shouldn't even reach checkout if they're under the floor).
Storefront: Liquid sets loyalty_tier from customer.tags; JS watches the cart and updates cart_value_cents on every cart change.
Problem: "We sell knives — confirm 18+ before checkout."
Rule:
age_confirmed, is not, equals, trueMessage: "Please confirm you are 18+ to continue."
Block type: Cart.
Storefront (no JS — use declarative mode):
<label>
<input type="checkbox" data-switchboard-attr="age_confirmed" value="true">
I confirm I am 18 years or older.
</label>
The SDK auto-syncs this to the cart attribute on change.
Problem: "Customers shouldn't be able to mix subscription + one-time of the same product."
Rule:
subscription_conflict, is, equals, trueMessage: "You have a subscription and a one-time purchase of the same product in your cart. Please choose one."
Block type: Cart.
Storefront (JS detects conflict on add-to-cart):
function checkConflict(cart) {
const counts = {};
for (const item of cart.items) {
const key = item.product_id;
counts[key] = (counts[key] || 0) + (item.selling_plan_allocation ? 1 : 2);
}
// If any product has BOTH a subscription line and a one-time line, the count is 3
const conflict = Object.values(counts).some(c => c === 3);
Switchboard.setCartAttribute('subscription_conflict', conflict ? 'true' : 'false');
}
Problem: "Carts over $5,000 need manual review — let the customer see prices but block at payment."
Rule:
cart_value_cents, is, greater_than, 500000Message: "Orders over $5,000 need a quick manual review. Please email us at orders@example.com with your cart contents and we'll process this for you."
Block type: Checkout completion — they can build the cart, walk through shipping, see prices and tax — but can't actually submit payment.
The function NEVER throws and NEVER blocks checkout on bad input. On any failure (malformed _switchboard JSON, missing attributes, malformed rule trees), no validation is emitted and the customer proceeds normally.
| Failure | Result |
|---|---|
_switchboard cart attribute is malformed JSON |
Treated as empty attributes; rules can't match |
| Attribute referenced by rule is missing from the blob | That rule evaluates to false |
| Rule operator is unknown (shouldn't happen — admin enforces) | Rule evaluates to false |
| Validation metafield missing | Function emits no errors; checkout proceeds |
| Inactive rule | Skipped at sync time — never reaches the function |
The takeaway: misconfiguration never breaks checkout. The worst case is that a rule SILENTLY DOESN'T FIRE when you expected it to. Test with the storefront DevTools console or the QA plan in SHOPIFY_QA.md.
rule_trees metafield on that single Validation. The Shopify Validation's enforcementLevel is per-record; Switchboard sets it from each rule's block-type at sync time. (Technically Shopify allows one block-type per Validation; multi-block-type rules are partitioned client-side and synced as a single metafield with mixed entries.)app/lib/validation/evaluator.ts mirrored in extensions/switchboard-validation/src/evaluator.js). Same matrix of unit tests on both sides. If you change one, you change the other.country, cart_value_cents, loyalty_tier).theme.liquid or a snippet included from theme.liquid.greater_than / less_than operators auto-parse to numbers, but if your value contains "$15" instead of "15", the parse fails. Store raw numeric strings.See SHOPIFY_QA.md in the repo for the full test plan you can hand to reviewers.