Shopify Cart Validation Functions: Block Checkout

A Shopify cart validation function is a WebAssembly module that runs on Shopify’s servers at the cart.validations.generate.run target and returns errors that block checkout. Shopify enforces it on the cart, in checkout and through express wallets, so the client cannot bypass it. A store can have 25 active at once.

Shopify is direct about why that matters: “To validate a cart and checkout, server-side, you can only use the Cart and Checkout Validation Function API.” A quantity cap enforced in theme JavaScript holds only while the buyer stays inside your theme. This one holds through Shop Pay, PayPal, Google Pay and Apple Pay too.

What a Shopify cart validation function can reach

The function receives an Input object and returns a CartValidationsGenerateRunResult: an ordered list of operations, each carrying a validationAdd with an array of errors. An error is two strings, a message and a target. Everything below is written against API version 2026-07, which has been the latest stable release since 1 July 2026.

Take a rule a regulated catalogue actually needs. Some products cannot legally leave the UK, and checkout should be blocked when one of them is going abroad. The input query, in a function scaffolded by shopify app generate extension --template cart_checkout_validation:

query CartValidationsGenerateRunInput($restrictedCollectionIds: [ID!]! = []) {
  buyerJourney {
    step
  }
  cart {
    lines {
      merchandise {
        __typename
        ... on ProductVariant {
          product {
            isRestricted: inAnyCollection(ids: $restrictedCollectionIds)
          }
        }
      }
    }
    deliveryGroups {
      deliveryAddress {
        countryCode
      }
    }
  }
}

And the JavaScript that consumes it:

// @ts-check

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

/**
 * @param {CartValidationsGenerateRunInput} input
 * @returns {CartValidationsGenerateRunResult}
 */
export function cartValidationsGenerateRun(input) {
  if (input.buyerJourney.step === "CART_INTERACTION") {
    return { operations: [] };
  }

  const hasRestrictedItem = input.cart.lines.some(
    (line) =>
      line.merchandise.__typename === "ProductVariant" &&
      line.merchandise.product.isRestricted,
  );

  const errors = [];

  if (hasRestrictedItem) {
    for (const group of input.cart.deliveryGroups) {
      const country = group.deliveryAddress?.countryCode;
      if (country && country !== "GB") {
        errors.push({
          message: "One item in your basket can only be delivered inside the UK.",
          target: "$.cart.deliveryGroups[0].deliveryAddress.countryCode",
        });
        break;
      }
    }
  }

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

Note what is missing. No clock, no random number, no HTTP call. Shopify’s Functions documentation is blunt about it: “Shopify doesn’t allow nondeterminism in functions, which means that you can’t use any randomizing or clock functionality in your functions.” A fetch target does exist for reaching an external service. It “is limited to custom apps installed on Enterprise stores”, so for everyone else the input query is the only source of truth, and anything else has to be pre-loaded into metafields or cart attributes.

The surface list matters more than people expect. The Cart and Checkout Validation Function API reference marks eight surfaces supported: B2B, the cart, checkout, draft orders from both the admin and checkout, the Shopify admin, the Storefront API and accelerated checkout. Six are listed as not supported: the Create Order API, order editing in the admin, order editing in checkout, POS, pre-order and try before you buy, and subscription (recurring) orders. Check your rule against that second list before you write any code, because a compliance rule that has to survive a subscription renewal needs enforcing somewhere else as well.

Where do the validation errors actually appear?

Three places, according to the reference: “Errors from validation functions are exposed to the Storefront API’s Cart object, in themes that use the cart template and during checkout.”

For a headless storefront that phrasing is thinner than it sounds. The 2026-07 Storefront Cart object reference lists no validation-errors field, and Shopify’s own tutorial reads them somewhere else entirely, off a failed cart mutation.

{
  "data": {
    "cartLinesAdd": {
      "cart": null,
      "userErrors": [
        {
          "code": "VALIDATION_CUSTOM",
          "field": ["cartId"],
          "message": "There's an order maximum of $1,000 for customers without established order history"
        }
      ]
    }
  }
}

VALIDATION_CUSTOM is a real member of the CartErrorCode enum in the 2026-07 Storefront API, described there as “Validation failed”. That is the case a custom storefront has to handle, and cart comes back null, so an optimistic UI update has nothing to reconcile against.

In checkout, the target string decides where the message lands. Use $.cart for a message about the order as a whole, or one of the field targets for something specific: $.cart.buyerIdentity.email, $.cart.deliveryGroups[0].deliveryAddress.zip, and so on. Shopify added billing address and PO number targets in API version 2026-04, which is what makes a B2B rule like “this customer must supply a PO number” enforceable without a checkout UI extension. The same changelog notes that those new input fields “return null outside of Cart and Checkout Validation”.

How do I stop it firing on every cart change?

Read buyerJourney.step. It is one of CART_INTERACTION, CHECKOUT_INTERACTION or CHECKOUT_COMPLETION, and Shopify’s own examples gate on it so the check only runs at completion. The sample above bails out during cart interaction, which keeps the message away from someone who is still shopping and leaves the instruction budget for the check that counts.

Position in the queue is worth knowing. Shopify runs the functions that change cart pricing and presentation first, then the discount functions, then the validations, and says plainly that “a cart validation function can’t run until after discount calculations are complete”. A rule about the discounted subtotal is therefore fine. A rule that needs to change a price is not, and belongs in a cart transform function.

How do I switch the validation on?

Deploying the extension is not enough. A validation has to be installed with validationCreate, which requires the write_validations scope:

mutation CreateValidation {
  validationCreate(
    validation: {
      functionHandle: "cart-checkout-validation"
      title: "UK-only products"
      enable: true
      blockOnFailure: false
    }
  ) {
    validation {
      id
      enabled
      blockOnFailure
    }
    userErrors {
      field
      message
      code
    }
  }
}

Both booleans default to false, so omitting enable leaves you with a validation that exists and does nothing. blockOnFailure is the one to think about. Shopify’s description: “Validation errors always block checkout progress. The blockOnFailure field controls whether runtime exceptions, such as timeouts, also block checkout.” Leave it false and a function that blows its instruction budget lets the order through. Set it true and a bug in your Wasm stops the merchant taking money. Merchants get the same choice in the admin under Settings, Checkout, Checkout Rules, by selecting or deselecting “Allow all customers to submit checkout” on the validation.

Which limit will you hit first?

Probably the instruction count. Functions get 11 million instructions for carts of up to 200 line items, scaling proportionally above that, and Shopify publishes the measured cost of its own PO Box example in both languages: 17,177 instructions in Rust, 222,699 in JavaScript. Both sit well inside the budget. The ratio is the thing to carry away, because the same logic costs roughly thirteen times as much in JavaScript.

The rest of the envelope: 256 kB compiled binary, 128 kB of function input, 20 kB of output, and an input query capped at 3000 bytes excluding comments with a calculated cost of 30. That cost is charged per field in the query document, not per cart line. inAnyCollection, hasAnyTag and any field returning a Metafield cost 3 each, ordinary leaf nodes cost 1, so by that table the query above costs 5. Metafields with values over 10,000 bytes are not returned at all, so a configuration blob above that size never reaches the function.

Where the docs disagree with themselves

The error target format, first. Shopify’s checkout validation tutorial ships code with target: "cart", while the tip printed directly above that code, and every example in the API reference, uses the JSONPath form $.cart. Follow the reference. It is the document that defines the supported target list, and the tutorial contradicts its own prose.

The reference then contradicts itself on localized fields. Its target table gives $.cart.localizedField.key, singular, while its two worked examples emit $.cart.localizedFields.TAX_CREDENTIAL_USE_MX, plural. Test that one against a real checkout before shipping it.

A third sits in the Admin API. The validationCreate description says the function is provided “using functionId or functionHandle”, but validating a mutation against the 2026-07 schema returns “The input field ValidationCreateInput.functionId is deprecated. Use functionHandle instead.” The schema wins.

Build the validation function when the rule must survive a hostile client and hold across express wallets and the Storefront API, and when everything it needs fits in one input query. Skip it when the rule needs the current time, a third-party lookup, or has to apply to subscription renewals, POS or order edits, because none of those are supported. Write it in Rust if the merchant sells to trade buyers with hundred-line carts, and target the specific checkout field rather than $.cart, so the customer can see which box to fix.

We build Shopify apps and checkout extensions, including server-side rules like these. If a cap or a restriction on your store only holds in the theme, Shopify development work is what closes the gap.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch