,

Building Cart & Checkout Discounts with Shopify Functions in 2026

7 min read
appaza

If you build for Shopify, 2026 is the year the old way of doing discounts finally goes away. Shopify Scripts stop being editable on April 15, 2026 and stop running entirely on June 30, 2026. Anything that still relies on Ruby-based cart or checkout logic has to move to Shopify Functions before that second date — there’s no extension and no fallback.

The good news: Functions are faster, run on Shopify’s own infrastructure, and the discount tooling in 2026 is far more capable than what Scripts ever offered. This post walks through how cart and checkout discounts actually work today, with the current API shape, real code structure, and the gotchas I keep running into on production apps.

What actually changed

For years, Shopify had three separate discount Function APIs — one each for product discounts, order discounts, and shipping discounts. That’s gone. Everything is now consolidated under a single Discount Function API with two run targets:

  • cart.lines.discounts.generate.run — handles product and order discounts (anything that touches line items or the cart subtotal).
  • cart.delivery-options.discounts.generate.run — handles shipping discounts.

One function, one configuration, two entry points. The cart-lines target can return both product-level and order-level discount operations, and the delivery-options target returns shipping operations. Both read from the same discount configuration — usually a single app-owned metafield, which I’ll come back to.

A second important shift: as of the 2026-01 API version, your discount settings UI can read the selected discount method and manage discount classes directly. That ended the old habit of enabling all three classes (product, order, shipping) by default and letting them collide. In 2026 you enable only the classes a given discount actually needs.

How a discount function runs

The mental model is simple. When a buyer hits the cart or checkout, Shopify executes your function on its servers. The function receives a structured input — cart contents, buyer identity, your metafields — runs your logic, and returns an ordered list of operations. Shopify applies those operations.

The single most important thing to understand: functions have no knowledge of each other. If a store runs three discount functions, yours doesn’t see the others. Shopify’s core engine decides how they stack based on the “combines with” settings configured on each discount (product, order, shipping). You write the logic for your discount and let the platform handle combination rules. Trying to coordinate stacking inside your function is the most common architectural mistake I see during migrations.

Setting up the function

Scaffold with the Shopify CLI and you get the structure automatically. The piece worth understanding is the shopify.extension.toml, which declares your targets:

api_version = "2026-04"

[[extensions]]
name = "appaza-discount"
handle = "appaza-discount-js"
type = "function"

  [[extensions.targeting]]
  target = "cart.lines.discounts.generate.run"
  input_query = "src/cart_lines_discounts_generate_run.graphql"
  export = "cartLinesDiscountsGenerateRun"

  [[extensions.targeting]]
  target = "cart.delivery-options.discounts.generate.run"
  input_query = "src/cart_delivery_options_discounts_generate_run.graphql"
  export = "cartDeliveryOptionsDiscountsGenerateRun"

Each target points to a GraphQL input query (what data your function receives) and an export (the function that runs). Keep the input query lean — you’re billed nothing for asking, but a smaller input keeps the function fast and well under the Wasm execution budget.

Functions run as WebAssembly. You can write them in JavaScript (compiled to Wasm via Javy) or Rust. JavaScript is the right default for most apps and fits a Node/TypeScript stack; reach for Rust only when you’re doing heavy iteration over large carts and need the headroom.

Building a cart-line discount

Here’s a worked example: 10% off the order when the subtotal is over $100, plus an extra 15% off any line with quantity 3 or more. It demonstrates both an order discount and a product discount returned from the same target.

First, the input query — ask only for what the logic needs:

# src/cart_lines_discounts_generate_run.graphql
query Input {
  cart {
    lines {
      id
      quantity
      cost {
        subtotalAmount {
          amount
        }
      }
    }
    cost {
      subtotalAmount {
        amount
      }
    }
  }
  discount {
    discountClasses
  }
}

Then the run function:

// src/cart_lines_discounts_generate_run.js
import {
  DiscountClass,
  OrderDiscountSelectionStrategy,
  ProductDiscountSelectionStrategy,
} from "../generated/api";

export function cartLinesDiscountsGenerateRun(input) {
  const { cart, discount } = input;
  const operations = [];

  const allowsOrder = discount.discountClasses.includes(DiscountClass.Order);
  const allowsProduct = discount.discountClasses.includes(DiscountClass.Product);

  const subtotal = parseFloat(cart.cost.subtotalAmount.amount);

  // Order discount: 10% off when subtotal > 100
  if (allowsOrder && subtotal > 100) {
    operations.push({
      orderDiscountsAdd: {
        selectionStrategy: OrderDiscountSelectionStrategy.First,
        candidates: [
          {
            message: "10% off orders over $100",
            targets: [{ orderSubtotal: { excludedCartLineIds: [] } }],
            value: { percentage: { value: 10 } },
          },
        ],
      },
    });
  }

  // Product discount: extra 15% off lines with quantity >= 3
  if (allowsProduct) {
    const bulkLines = cart.lines
      .filter((line) => line.quantity >= 3)
      .map((line) => ({ cartLine: { id: line.id } }));

    if (bulkLines.length > 0) {
      operations.push({
        productDiscountsAdd: {
          selectionStrategy: ProductDiscountSelectionStrategy.All,
          candidates: [
            {
              message: "15% off when you buy 3 or more",
              targets: bulkLines,
              value: { percentage: { value: 15 } },
            },
          ],
        },
      });
    }
  }

  return { operations };
}

A few things to notice. Each operation carries candidates (the discounts you propose) and a selectionStrategy that tells Shopify how to choose between them — First, All, or Maximum. The value is either a percentage or a fixedAmount. And critically, the function checks discountClasses before doing anything: if the merchant didn’t enable order discounts for this discount, your order logic simply shouldn’t run. That guard is what keeps a single function reusable across different merchant configurations.

Checkout (shipping) discounts

Free or discounted shipping lives in the delivery-options target. Same pattern, different operation:

// src/cart_delivery_options_discounts_generate_run.js
import { DeliveryDiscountSelectionStrategy } from "../generated/api";

export function cartDeliveryOptionsDiscountsGenerateRun(input) {
  const group = input.cart.deliveryGroups[0];
  if (!group) return { operations: [] };

  return {
    operations: [
      {
        deliveryDiscountsAdd: {
          selectionStrategy: DeliveryDiscountSelectionStrategy.All,
          candidates: [
            {
              message: "Free shipping",
              targets: [{ deliveryGroup: { id: group.id } }],
              value: { percentage: { value: 100 } },
            },
          ],
        },
      },
    ],
  };
}

A 100% shipping discount is free shipping. The same target handles partial shipping discounts by adjusting the value.

Validating and rejecting discount codes

The 2026 Discount API can also accept or reject entered discount codes — useful when codes are validated against an external system (a loyalty platform, a regional eligibility check, a one-time-use ledger). Your input query reads enteredDiscountCodes, and you return an accept or reject operation with a custom message the customer sees at checkout:

return {
  operations: [
    {
      enteredDiscountCodesReject: {
        codes: [{ code: enteredCode }],
        message: "This code isn't valid in your region.",
      },
    },
  ],
};

This is far cleaner than the old workarounds, and the rejection message surfaces natively in the checkout UI instead of failing silently.

Configuration: one metafield, not ten

The pattern I standardize on across every discount app:

  • Store all durable config in a single app-owned JSON metafield. Both run targets read the same metafield, so your function stays stateless and your settings stay in one place.
  • Enable only the discount classes the discount actually uses. A “10% off” order discount has no business enabling product and shipping classes — that just invites accidental combination behavior.
  • Render method-specific UI when code-based and automatic discounts need different settings.
  • Avoid kitchen-sink schemas. Two or three narrow knobs beat a sprawling config object that’s hard to validate and harder to support.

This keeps the function fast, the merchant UI honest about what the discount does, and your support queue quiet.

Gotchas worth knowing

  • Wasm size and execution limits are real. Keep input queries minimal and avoid pulling whole product catalogs into the function. If you’re iterating over very large carts, profile it.
  • The fetch (network access) target is restricted. It’s limited to custom apps on Plus and Enterprise stores and isn’t available on development stores or in dev preview, so don’t design a public-app discount around calling your backend mid-checkout.
  • Functions are versioned quarterly and supported for 12 months. Develop against the current stable version (2026-04 at the time of writing), test against the next release candidate early, and set up deprecation alerts — Shopify warns nine months out, but it’s easy to miss.
  • Stacking is the platform’s job. Configure “combines with” correctly on the discount and stop trying to reason about other functions from inside yours.

Don’t wait on the migration

If you maintain a store or an app that still leans on Scripts for cart or checkout discounts, the clock is genuinely running: no new Scripts after April 15, 2026, and Scripts stop working after June 30, 2026. Migrating isn’t a one-to-one port — Functions are a different model — so budget real time to rebuild and test the logic, not just translate it.

The upside is that once you’re on Functions, you get server-side performance, native checkout integration, code rejection with proper messaging, and a configuration model that scales across merchants. For new builds, there’s no decision to make: this is the foundation now.

Md Zoynul Abedin Avatar