Skip to main content

Order Validation Functions

An order_validation function runs at order placement time (order placement). Return one or more errors and the order is rejected with a 400 Bad Request. Return nothing and the order proceeds. Use it for purchase limits, geographic blocks, age/compliance gates, required-accessory rules, or anything that needs to block the order itself — not just hide a payment method or warn the customer in the cart.

How It Works

This function does not run at cart verification. It only fires on the order-place endpoint, so customers see the error after pressing “Place order”.

Function Manifest

{
  "handle": "my-validation-app",
  "name": "Order Validator",
  "version": "1.0.0",
  "functions": {
    "order_validation": {
      "handle": "order-rules",
      "name": "Order Validation Rules",
      "config": {
        "maxQuantityPerLine": 10,
        "blockedCountries": ["XX", "YY"]
      }
    }
  }
}

Input Schema

interface OrderValidationInput {
  cart: {
    items: CartItem[];
    totalPrice: number;       // Subtotal in display currency
    itemCount: number;
    currency: string;
  };
  customer?: {
    id: string;
    email: string;
  };
  shippingAddress: {
    firstName: string;
    lastName: string;
    address1: string;
    address2: string;
    city: string;
    province: string;
    country: string;
    zip: string;
    phone: string;
  };
}

interface CartItem {
  id: string;                 // lineItemId
  variantId: string;
  productId: string;
  title: string;
  quantity: number;
  price: number;              // Effective unit price (display currency)
  originalPrice: number;
}
The input is intentionally minimal. There is no paymentMethod, no shop, no tags/productType/vendor on items, and customer carries only id and email. If you need richer data, fetch it from your own backend keyed on the customer/order ids you receive here.

Output Schema

interface OrderValidationOutput {
  errors?: ValidationError[];
}

interface ValidationError {
  message: string;          // Required — shown to the customer
  target: 'cart' | 'line' | 'line_item' | 'shipping' | 'customer';
  //  REQUIRED. An error whose `target` isn't one of these values fails
  //  output validation, so the whole result is dropped and the order is
  //  allowed through. (`line_item` is an alias for `line`.)
  lineId?: string;          // CartItem.id when target='line' / 'line_item'
  code?: string;            // Machine-readable code for analytics
}
The backend treats errors.length > 0 as invalid. There is no valid: true|false boolean — an empty errors array (or an absent field) means the order is allowed. When multiple errors are returned, the first message becomes the HTTP 400 headline. The full list is attached to the response as data.errors so the frontend can render per-line/per-field callouts.
HTTP/1.1 400 Bad Request

{
  "status": "error",
  "message": "Maximum 10 units allowed for \"Premium Widget\". Reduce quantity from 15.",
  "data": {
    "errors": [
      {
        "message": "Maximum 10 units allowed for \"Premium Widget\". Reduce quantity from 15.",
        "target": "line",
        "lineId": "line_42",
        "code": "QUANTITY_LIMIT_EXCEEDED",
        "appId": "my-validation-app"
      }
    ]
  }
}
The platform automatically stamps each error with the originating appId so the frontend can attribute blame.

Examples

Per-line quantity cap

function validateOrder(input, config) {
  const max = config.maxQuantityPerLine || 10;
  const errors = input.cart.items
    .filter(i => i.quantity > max)
    .map(i => ({
      message: `Maximum ${max} units allowed for "${i.title}". Reduce from ${i.quantity}.`,
      target: 'line',
      lineId: i.id,
      code: 'QUANTITY_LIMIT_EXCEEDED'
    }));
  return { errors };
}

Geographic block

function validateOrder(input, config) {
  const blocked = config.blockedCountries || [];
  if (blocked.includes(input.shippingAddress.country)) {
    return {
      errors: [{
        message: `We can't ship to ${input.shippingAddress.country}.`,
        target: 'shipping',
        code: 'RESTRICTED_COUNTRY'
      }]
    };
  }
  return { errors: [] };
}

Guest checkout limit

function validateOrder(input) {
  if (!input.customer && input.cart.totalPrice > 500) {
    return {
      errors: [{
        message: 'Orders over ₹500 require an account. Please sign in to continue.',
        target: 'customer',
        code: 'ACCOUNT_REQUIRED'
      }]
    };
  }
  return { errors: [] };
}

Required-accessory check

function validateOrder(input) {
  const errors = [];
  const ids = new Set(input.cart.items.map(i => i.productId));

  // Console controller (prod_console) needs at least one cable (prod_cable_a/b)
  if (ids.has('prod_console') && !ids.has('prod_cable_a') && !ids.has('prod_cable_b')) {
    errors.push({
      message: 'Add a USB-C cable to your cart to use the console.',
      target: 'cart',
      code: 'MISSING_ACCESSORY'
    });
  }
  return { errors };
}

How errors render in the UI

The checkout converts the 400 into an inline error banner above the place-order button. When target='line' and lineId matches a cart line, the checkout can additionally highlight that specific line (subject to theme support).
⚠ Maximum 10 units allowed for "Premium Widget". Reduce from 15.

[Edit cart] [Continue editing]

Multiple apps

All installed validation functions run in install order. Errors from every function are concatenated, and the order is blocked if any function returns at least one error.

Best Practices

A cart-transform or storefront-side check that prevents the bad state at all is a better customer experience than a 400 after they pressed “Place order”. Use order_validation as the server-side gate, not the primary signal.
“Reduce quantity from 15 to 10” beats “Quantity invalid”. Tell the customer exactly what to change.
Themes that support per-line error rendering rely on these fields. Plain string messages still work, but lose the inline-highlight UX.
Guest checkouts arrive with customer === undefined. Don’t crash — return { errors: [] } if your rule doesn’t apply.
The order-place flow blocks on this dispatch. A slow validator means a slow checkout. If you need to call an external API, cache aggressively and short-circuit on identical inputs.

See Also