Compiling ideas into code…
Wiring up APIs and UI…
Sit back for a moment while the site gets ready.
ShopifyFunctions

Cart Validation RulesCase Studies

Why Server-Side Validation Matters

Theme-level JavaScript validation, hiding buttons, showing alerts, disabling form controls, is trivially bypassed. A customer with browser dev tools can delete a disabled attribute from a checkout button in seconds. Headless storefronts, third-party sales channels, POS terminals, and custom integrations never see your theme JavaScript at all. If your business rules only exist in client-side code, they aren't rules, they're suggestions.

The Cart and Checkout Validation Function runs on Shopify's infrastructure, inside the checkout pipeline itself. It receives the full cart state, line items, customer identity, shipping address, cart attributes, evaluates your rules, and returns errors that Shopify enforces at the UI level. No sales channel can circumvent it. No browser hack can bypass it. It is the enforcement layer, not the UX layer.

Unlike Cart Transform (limited to one function per store), Shopify allows up to 25 validation functions per store. Each function can target a specific category of business rules, quantity limits, geographic restrictions, product conflicts, making your validation logic modular, independently deployable, and maintainable.


How the Validation Function Works

The execution model follows a request-response pattern within the checkout lifecycle:

  1. A customer interacts with their cart or proceeds through checkout
  2. Shopify sends the cart state (lines, customer, addresses, attributes) to your function via the GraphQL input query you define
  3. Your function evaluates business rules and returns an array of errors, or an empty array for valid carts
  4. Shopify surfaces errors in the checkout UI automatically, telling customers exactly what's wrong
  5. If blockOnFailure is true, checkout cannot complete until all errors are resolved

The timing of execution depends on the buyer journey step. Shopify fires your function at different points in the checkout flow:

  • CART_INTERACTION, fired when the cart changes (adding, removing, or updating items)
  • CHECKOUT_INTERACTION, fired when the customer enters checkout details (address, shipping method)
  • CHECKOUT_COMPLETION, fired when the customer clicks "Pay" or completes checkout

Run validation at the right step. Checking a shipping address during CART_INTERACTION makes no sense, the address isn't known yet. Use CHECKOUT_INTERACTION or CHECKOUT_COMPLETION for address-dependent rules. Running unnecessary checks at early steps wastes execution time and can surface irrelevant errors.


Scaffolding the Function

Start by generating the extension scaffold from the Shopify CLI:

Terminal
shopify app generate extension --template cart_checkout_validation --name order-validation

This creates the following directory structure:

extensions/order-validation/
├── src/
│   ├── run.graphql
│   └── run.js
├── input.graphql
└── shopify.extension.toml

The TOML configuration declares the function type and build settings:

extensions/order-validation/shopify.extension.toml
api_version = "2025-07"
type = "cart_checkout_validation"

[build]
command = "npm exec -- shopify app function build"
path = "dist/function.wasm"

The input.graphql file defines what cart data your function receives. The run.js file contains your validation logic. Both are compiled to WebAssembly during the build step, your JavaScript runs inside Shopify's sandboxed runtime, not in a browser.


Case Study 1: Per-Product Quantity Limits

A limited-edition sneaker brand caps purchases at 2 pairs per style to combat resellers. Without server-side enforcement, resellers bypass theme-level quantity selectors by modifying the cart payload directly or purchasing through alternative channels. The validation function reads a per-product quantity limit from a metafield, compares it against the cart line quantity, and blocks checkout if the limit is exceeded.

extensions/order-validation/input.graphql
query RunInput {
  cart {
    lines {
      id
      quantity
      merchandise {
        ... on ProductVariant {
          id
          title
          product {
            title
            quantityLimit: metafield(namespace: "$app:validation", key: "max_quantity") {
              value
            }
          }
        }
      }
    }
  }
  buyerJourney {
    step
  }
}
extensions/order-validation/src/run.js
// @ts-check

/**
 * @typedef {import("../generated/api").CartValidationsGenerateRunInput} RunInput
 * @typedef {import("../generated/api").CartValidationsGenerateRunResult} RunResult
 * @typedef {import("../generated/api").ValidationError} ValidationError
 */

/**
 * @param {RunInput} input
 * @returns {RunResult}
 */
export function cartValidationsGenerateRun(input) {
  const errors = [];

  for (const line of input.cart.lines) {
    if (line.merchandise.__typename !== "ProductVariant") continue;

    const limitMeta = line.merchandise.product.quantityLimit;
    if (!limitMeta) continue;

    const maxQty = parseInt(limitMeta.value, 10);
    if (isNaN(maxQty)) continue;

    if (line.quantity > maxQty) {
      errors.push({
        message: `Maximum ${maxQty} units allowed for "${line.merchandise.product.title}". You have ${line.quantity}.`,
        target: "$.cart",
      });
    }
  }

  return {
    operations: [{ validationAdd: { errors } }],
  };
}

The function name is cartValidationsGenerateRun, not run. This is the required export name for the cart.checkout.validation.generate.run target. Getting this wrong means your function silently returns nothing and every cart passes validation.

The quantity limit is stored in a product metafield under the $app:validation namespace. Merchants update limits per product without code changes, set the metafield to 2 for limited editions, 10 for regular stock, or remove it entirely for unrestricted products. The error message includes both the product name and current quantity so customers know exactly what to fix.


Case Study 2: Geographic Shipping Restrictions

A wine retailer can only ship to states where they hold distribution licenses. This isn't a UX preference, it's a legal compliance requirement. If a customer in a restricted state reaches checkout with alcohol in their cart, the order must be blocked regardless of which sales channel they're using.

extensions/order-validation/input.graphql
query RunInput {
  cart {
    lines {
      id
      merchandise {
        ... on ProductVariant {
          product {
            title
            hasAnyTag(tags: ["alcohol", "wine", "spirits"])
          }
        }
      }
    }
    deliveryGroups {
      deliveryAddress {
        provinceCode
        countryCode
      }
    }
  }
  validation {
    metafield(namespace: "$app:validation", key: "permitted_regions") {
      value
    }
  }
  buyerJourney {
    step
  }
}
extensions/order-validation/src/run.js
export function cartValidationsGenerateRun(input) {
  const errors = [];

  if (input.buyerJourney?.step === "CART_INTERACTION") {
    return { operations: [{ validationAdd: { errors: [] } }] };
  }

  const permittedMeta = input.validation?.metafield?.value;
  if (!permittedMeta) return { operations: [{ validationAdd: { errors: [] } }] };

  const permitted = JSON.parse(permittedMeta);

  const hasRestrictedItems = input.cart.lines.some(line => {
    if (line.merchandise.__typename !== "ProductVariant") return false;
    return line.merchandise.product.hasAnyTag;
  });

  if (!hasRestrictedItems) {
    return { operations: [{ validationAdd: { errors: [] } }] };
  }

  for (const group of input.cart.deliveryGroups) {
    const { countryCode, provinceCode } = group.deliveryAddress || {};
    const region = `${countryCode}-${provinceCode}`;

    if (!permitted.includes(region)) {
      errors.push({
        message: `We cannot ship alcohol to ${provinceCode || "your region"}. Please remove restricted items or update your shipping address.`,
        target: "$.cart",
      });
    }
  }

  return {
    operations: [{ validationAdd: { errors } }],
  };
}

The function skips validation entirely during CART_INTERACTION, there's no shipping address to check yet. It only runs when the customer has entered an address during checkout.

Permitted regions are stored as a JSON array in an app-owned metafield: ["US-CA", "US-NY", "US-WA", ...]. The compliance team updates this list directly when licensing changes, no code deployment needed. The function checks whether the cart contains restricted items via hasAnyTag first, and only validates the shipping address if restricted items are present. A cart with only non-restricted products passes regardless of destination.


Case Study 3: Product Conflict Prevention

A supplement brand sells products that can have dangerous interactions when taken together. Their medical advisory board maintains a conflict matrix, certain combinations (blood thinners with iron supplements, for example) must never appear in the same order. The validation function cross-references every cart item's conflict group and blocks checkout if conflicting products are found.

extensions/order-validation/input.graphql
query RunInput {
  cart {
    lines {
      id
      merchandise {
        ... on ProductVariant {
          product {
            title
            conflictGroup: metafield(namespace: "$app:validation", key: "conflict_group") {
              value
            }
          }
        }
      }
    }
  }
}
extensions/order-validation/src/run.js
export function cartValidationsGenerateRun(input) {
  const errors = [];
  const conflictGroups = {};

  for (const line of input.cart.lines) {
    if (line.merchandise.__typename !== "ProductVariant") continue;

    const groupMeta = line.merchandise.product.conflictGroup;
    if (!groupMeta) continue;

    const groups = JSON.parse(groupMeta.value);

    for (const group of groups) {
      if (!conflictGroups[group]) conflictGroups[group] = [];
      conflictGroups[group].push(line.merchandise.product.title);
    }
  }

  for (const [group, products] of Object.entries(conflictGroups)) {
    if (products.length > 1) {
      errors.push({
        message: `"${products[0]}" and "${products[1]}" cannot be purchased together due to interaction warnings.`,
        target: "$.cart",
      });
    }
  }

  return {
    operations: [{ validationAdd: { errors } }],
  };
}

Each product's conflict groups are stored as a JSON array metafield, for example, ["blood-thinners", "iron-supplements"]. The function builds a map of which products belong to which conflict groups, then checks for any group containing more than one product. The error message names both conflicting products so the customer knows exactly which item to remove.

This works across every sales channel, the online store, headless storefronts, POS, and wholesale draft orders. The medical advisory board updates conflict group metafields through the Shopify admin or a custom app UI; no developer involvement is needed when the conflict matrix changes.


Case Study 4: B2B Minimum Order Quantities

A B2B electronics distributor requires wholesale customers to order at least 10 units per SKU. Retail customers have no minimum. The validation function uses buyerIdentity to detect wholesale customers and applies the minimum quantity rule only to them, with the threshold configurable per company via metafield.

extensions/order-validation/input.graphql
query RunInput {
  cart {
    lines {
      id
      quantity
      merchandise {
        ... on ProductVariant {
          product { title }
        }
      }
    }
    buyerIdentity {
      customer {
        hasTags(tags: ["wholesale", "b2b"]) {
          hasTag
          tag
        }
      }
      purchasingCompany {
        company {
          metafield(namespace: "$app:validation", key: "min_order_qty") {
            value
          }
        }
      }
    }
  }
  buyerJourney {
    step
  }
}
extensions/order-validation/src/run.js
export function cartValidationsGenerateRun(input) {
  const errors = [];

  const isWholesale = input.cart.buyerIdentity?.customer?.hasTags?.some(t => t.hasTag);
  if (!isWholesale) return { operations: [{ validationAdd: { errors: [] } }] };

  const minQtyMeta = input.cart.buyerIdentity?.purchasingCompany?.company?.metafield?.value;
  const minQty = minQtyMeta ? parseInt(minQtyMeta, 10) : 10;

  for (const line of input.cart.lines) {
    if (line.merchandise.__typename !== "ProductVariant") continue;

    if (line.quantity < minQty) {
      errors.push({
        message: `Wholesale orders require at least ${minQty} units of "${line.merchandise.product.title}". Current quantity: ${line.quantity}.`,
        target: "$.cart",
      });
    }
  }

  return {
    operations: [{ validationAdd: { errors } }],
  };
}

The function checks buyerIdentity.customer.hasTags to determine if the customer has wholesale or B2B tags. If not, validation passes immediately, retail customers are completely unaffected. For wholesale customers, the minimum quantity threshold comes from a company-level metafield, defaulting to 10 if not set. Different wholesale accounts can have different minimums without any code changes.

This applies universally across B2B channels, draft orders created by sales reps, and direct online purchases. The error message specifies both the required minimum and current quantity for each undercount line item, so buyers can adjust without guessing.


Configurable Rules via Metafields

Hardcoding validation rules works for simple cases, but most merchants need to update rules without deploying code. The proven pattern is to store configuration in app-data metafields on the AppInstallation resource, then read that configuration in your function's input query.

A single configuration metafield can hold all your validation rules as structured JSON:

Validation rules JSON structure
{
  "maxQuantityPerProduct": 5,
  "restrictedRegions": ["US-AL", "US-MS", "US-UT"],
  "requireGiftNote": false,
  "minOrderTotal": "50.00",
  "conflictGroups": {
    "blood-thinners": ["product-a", "product-b"],
    "iron-supplements": ["product-c", "product-d"]
  }
}

Set the metafield via the Admin API:

GraphQL Admin API
mutation {
  metafieldsSet(metafields: [{
    ownerId: "gid://shopify/AppInstallation/INSTALLATION_ID"
    namespace: "$app:validation"
    key: "rules"
    type: "json"
    value: "{\"maxQuantityPerProduct\": 5, \"restrictedRegions\": [\"US-AL\"]}"
  }]) {
    metafields { id }
    userErrors { field message }
  }
}

Use app-data metafields for configuration. Metafields on the AppInstallation resource with the $app: namespace prefix are hidden from the Shopify admin UI and owned exclusively by your app. They're the ideal location for validation configuration that merchants shouldn't edit directly, expose a custom UI in your app instead.


The blockOnFailure Flag

When you activate a validation function, the blockOnFailure flag determines whether errors are hard blocks or soft warnings.

blockOnFailure: true is a hard block. Checkout cannot complete until the customer resolves every error. Use this for compliance requirements, geographic restrictions, product conflicts, age verification, where allowing the order through would violate policy or law.

blockOnFailure: false is a soft warning. The error message is displayed, but the customer can proceed with checkout anyway. Use this for recommendations or non-critical suggestions: "You're ordering 50 units, are you sure this isn't a mistake?" or "This product ships separately and may arrive later."

GraphQL Admin API, Activate validation
mutation {
  validationCreate(
    title: "Geographic Restrictions"
    functionId: "FUNCTION_ID"
    enable: true
    blockOnFailure: true
  ) {
    validation { id }
    userErrors { field message }
  }
}

You can change blockOnFailure after activation using validationUpdate. This lets you deploy a new validation rule as a soft warning first, monitor the errors it produces in production, then promote it to a hard block once you're confident it works correctly.


Buyer Journey Steps: When to Validate

Choosing the right buyer journey step for each rule avoids unnecessary errors and ensures your function has the data it needs to evaluate.

Rule Type Best Step Why
Quantity limits CART_INTERACTION Catch early, before checkout
Product conflicts CART_INTERACTION Prevent invalid combos immediately
Shipping restrictions CHECKOUT_INTERACTION Address is needed
Age verification CHECKOUT_COMPLETION Final check before payment
B2B minimums CHECKOUT_INTERACTION After identity is confirmed

Guard your logic by step to avoid running address checks when no address exists:

src/run.js, Step guard pattern
if (input.buyerJourney?.step === "CART_INTERACTION") {
  // Skip address-dependent rules, address not available at this step
}

A single function can handle multiple steps by branching on input.buyerJourney.step. Run quantity checks at CART_INTERACTION for immediate feedback, then run address-dependent checks at CHECKOUT_INTERACTION when the data becomes available.


Building and Deploying

Once your validation logic is written, generate types from your input query, build the WebAssembly module, and deploy the app:

Terminal
shopify app function typegen --path extensions/order-validation
shopify app function build --path extensions/order-validation
shopify app deploy

After deployment, query for your function's ID so you can activate it:

GraphQL Admin API
query {
  shopifyFunctions(first: 25, apiType: "cart_checkout_validation") {
    nodes { id title app { title } }
  }
}

Use the returned id in the validationCreate mutation shown earlier to activate your function. This is the functionId, a stable GID like gid://shopify/ShopifyFunction/123.

Use functionId, not functionHandle. While Shopify supports both identifiers, functionHandle can be unreliable across app versions and environments. Always query for the functionId via the Admin API and use that in your activation mutations.


Testing Locally

Shopify CLI lets you test your function locally by piping sample input through it:

Terminal
shopify app function run --path extensions/order-validation

Provide a JSON input that matches your input.graphql schema. Here's a sample input for the quantity limit case, a customer trying to buy 5 units of a product limited to 2:

Sample input (stdin)
{
  "cart": {
    "lines": [
      {
        "id": "gid://shopify/CartLine/1",
        "quantity": 5,
        "merchandise": {
          "__typename": "ProductVariant",
          "id": "gid://shopify/ProductVariant/123",
          "title": "Size 10",
          "product": {
            "title": "Limited Edition Runner",
            "quantityLimit": {
              "value": "2"
            }
          }
        }
      }
    ]
  },
  "buyerJourney": {
    "step": "CART_INTERACTION"
  }
}

Expected output, the function detects the violation and returns an error:

Expected output
{
  "operations": [
    {
      "validationAdd": {
        "errors": [
          {
            "message": "Maximum 2 units allowed for \"Limited Edition Runner\". You have 5.",
            "target": "$.cart"
          }
        ]
      }
    }
  ]
}

If the cart is valid, the output contains an empty errors array. Test both passing and failing cases for each rule to catch edge cases before deploying to production.


Multiple Validation Functions

With a limit of 25 validation functions per store, you have two architectural choices.

One function per rule category, separate functions for quantity limits, geographic restrictions, product conflicts, and B2B rules:

  • Independent deployment, updating one rule doesn't risk breaking others
  • Selective enable/disable, turn off geographic restrictions during a promotion without touching quantity limits
  • Clear ownership, different teams can own different validation functions
  • Error aggregation, if multiple functions return errors, Shopify shows all of them to the customer

Single function, all rules, one function evaluates everything:

  • Shared configuration loading, read one metafield instead of many
  • Shared customer detection logic, check wholesale status once, apply to all rules
  • One deployment for all rules, simpler CI/CD
  • Harder to selectively enable/disable individual rules without adding feature flags

Start with a single function. Most stores don't need 25 separate validators. Begin with one function that handles all your rules, and split into separate functions when complexity grows or when different teams need independent deployment cycles.


Edge Cases and Production Considerations

  • Express checkout (Shop Pay, Apple Pay, Google Pay): Validation runs after the customer completes the express checkout flow. If validation fails, the customer sees the error and must modify their cart. This creates a frustrating UX, consider client-side pre-validation to catch issues before the customer enters express checkout
  • Draft orders: Validation applies to draft order checkouts. Sales reps creating manual orders will also be blocked by hard validations, plan for this if your support team frequently creates draft orders
  • Order editing: Validation does not run on post-purchase order edits. If a fulfillment team modifies quantities, your validation won't catch violations. Consider webhook-based checks for critical post-order modifications
  • Localization: Error messages should be clear and actionable in every language your store supports. Consider storing translated messages in your configuration metafield or using a translation key approach
  • Execution limits: Functions have a 5-second execution limit and an 11MB memory cap. Complex validations with large conflict matrices or many product lookups must be efficient, avoid nested loops where a map-based lookup works
  • Cart attributes vs. line properties: Use cart attributes for global checks (e.g., "gift note required for gift-wrapped orders"), line properties for per-item rules (e.g., "engraving text must be under 20 characters")

Key Takeaways

  • Client-side validation is UX guidance, not enforcement, the Cart and Checkout Validation Function is the only way to guarantee business rules hold regardless of sales channel
  • The function export name is cartValidationsGenerateRun, not run, this trips up developers who've worked with other Shopify Function types
  • Use buyerJourney.step to run the right validation at the right time, don't check addresses during cart interaction, don't check quantities at checkout completion
  • Store validation configuration in app-data metafields so merchants can update rules without code deployments
  • blockOnFailure: true for compliance requirements, false for recommendations, deploy new rules as soft warnings first
  • Up to 25 functions per store means you can modularize by rule category, but start with a single function until complexity justifies splitting
  • Test locally with shopify app function run and sample JSON inputs before deploying, cover both passing and failing cases for each rule
  • Express checkout, draft orders, and order editing each have unique validation behaviors, plan for them early in your implementation

If your store needs server-side validation rules, quantity limits, geographic compliance, product conflict prevention, or B2B order requirements, I can help you architect and build them. Let's talk about what your commerce model actually needs.

Best Practice CI/CD for Remix Shopify Apps