Shopify's New Discount Function API: How to Migrate

The Shopify discount function API migration folds three deprecated targets, product, order and shipping, into two: cart.lines.discounts.generate.run and cart.delivery-options.discounts.generate.run. The run function returns an operations array instead of a discounts array paired with a discountApplicationStrategy, and shopify.extension.toml needs api_version = "2025-04" or later. One function can now discount products, the order subtotal and shipping in a single pass.

That last sentence is the reason to do the work rather than filing it under housekeeping. Under the old APIs, “spend £50, get 10% off and free delivery” meant two functions, two discount records in the admin, and two chances for someone to switch half of the promotion off by accident.

Shopify shipped the unified API in version 2025-04 and announced it on 21 May 2025. The Product Discount Function API now carries a deprecation notice pointing at the replacement, as do its order and shipping siblings.

All of this is app-side and runs regardless of what draws the storefront, which is a separate decision we picked apart in Hydrogen versus Next.js for a storefront.

What the discount function API migration actually changes

Start with the extension config, because it maps out the rest of the work. The generated template for the new API looks like this:

api_version = "2025-04"

[[extensions]]
name = "t:name"
handle = "my-discount"
type = "function"

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

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

  [extensions.build]
  command = ""
  path = "dist/function.wasm"

Two targets, two input queries, two exports, one Wasm binary. purchase.product-discount.run does not appear anywhere.

The second change is the return shape. The deprecated APIs returned a discountApplicationStrategy of FIRST, MAXIMUM or ALL alongside a flat discounts array. The new one returns operations, where each entry is a productDiscountsAdd, orderDiscountsAdd or deliveryDiscountsAdd, and each of those carries its own candidates list and its own selectionStrategy. The strategy moved down a level. A product discount can now pick its winner one way while an order discount in the same run picks another.

The third change is discountClasses, and it is the one that quietly produces nothing if you skip it. Your input query asks for discount { discountClasses }, the merchant’s discount record declares which classes it uses when it is created through discountAutomaticAppCreate, and the function has to check before it emits an operation. Return a product discount for a record configured as order-only and it will not apply. No error, no discount.

Use Admin API version 2025-10 or later and you can reference the function by handle in that mutation instead of querying for its ID first. The migration guide sets 2025-04 as the floor.

What does the new run function look like?

Shopify’s own JavaScript template, trimmed to the cart lines target:

import {
  DiscountClass,
  OrderDiscountSelectionStrategy,
  ProductDiscountSelectionStrategy,
} from '../generated/api';

export function cartLinesDiscountsGenerateRun(input) {
  if (!input.cart.lines.length) {
    throw new Error('No cart lines found');
  }

  const hasOrderDiscountClass = input.discount.discountClasses.includes(
    DiscountClass.Order,
  );
  const hasProductDiscountClass = input.discount.discountClasses.includes(
    DiscountClass.Product,
  );

  if (!hasOrderDiscountClass && !hasProductDiscountClass) {
    return {operations: []};
  }

  const maxCartLine = input.cart.lines.reduce((maxLine, line) => {
    if (line.cost.subtotalAmount.amount > maxLine.cost.subtotalAmount.amount) {
      return line;
    }
    return maxLine;
  }, input.cart.lines[0]);

  const operations = [];

  if (hasOrderDiscountClass) {
    operations.push({
      orderDiscountsAdd: {
        candidates: [
          {
            message: '10% OFF ORDER',
            targets: [{orderSubtotal: {excludedCartLineIds: []}}],
            value: {percentage: {value: 10}},
          },
        ],
        selectionStrategy: OrderDiscountSelectionStrategy.First,
      },
    });
  }

  if (hasProductDiscountClass) {
    operations.push({
      productDiscountsAdd: {
        candidates: [
          {
            message: '20% OFF PRODUCT',
            targets: [{cartLine: {id: maxCartLine.id}}],
            value: {percentage: {value: 20}},
          },
        ],
        selectionStrategy: ProductDiscountSelectionStrategy.First,
      },
    });
  }

  return {operations};
}

The export name in TOML is kebab-case, the JavaScript export is camelCase, and src/index.js re-exports both run functions so the single Wasm module exposes them. The paired input query is deliberately short:

query CartInput {
  cart {
    lines {
      id
      cost {
        subtotalAmount {
          amount
        }
      }
    }
  }
  discount {
    discountClasses
  }
}

Every field you add there costs input budget at runtime. Ask for what you use.

What limits does a discount function have to fit inside?

Functions compile to WebAssembly and run on Shopify’s hardware inside the checkout, on a budget you cannot raise by upgrading a plan.

The fixed ceilings are a 256 kB compiled binary, 10,000 kB of runtime linear memory, 512 kB of stack, and 1 kB of logs before truncation. The dynamic ones scale with cart size: for carts up to 200 line items you get 11 million instructions, 128 kB of input and 20 kB of output, and past 200 lines those values scale proportionally.

The input query has separate rules. Maximum 3000 bytes excluding comments, a calculated query cost of no more than 30, list-type field arguments capped at 100 elements, and metafields whose values exceed 10,000 bytes are not returned at all. That last one bites in a specific way: someone stores a JSON pricing table in a metafield, it grows past the cutoff, and the function starts reading null in production while every local test still passes.

A store can have 25 discount functions active at once. They run concurrently and, in Shopify’s words, “have no knowledge of each other”. A single function handles one discount, code-based or automatic, but can apply savings across all three classes.

Shopify recommends Rust and does not hedge about it, calling it “the most performant language choice to avoid your function failing with large carts”. JavaScript compiles to Wasm through the same toolchain and is fine for logic that is a handful of comparisons. Loops over a 400-line B2B cart are where 11 million instructions stops being a theoretical number.

Three CLI commands carry most of the loop:

shopify app function typegen   # regenerate ../generated/api from the input query
shopify app function build     # compile to dist/function.wasm
shopify app function replay    # re-run against a real captured input

replay is the one people miss. It replays input captured from an actual run against your local build, which beats hand-writing a cart JSON that does not match what checkout really sends.

Can a function call your pricing API?

By default, no. There is a fetch target that adds network access, and it runs in two phases: the fetch target builds an HTTP request from your input query, Shopify performs the request, and the response is passed to the run target holding your logic.

Eligibility is the catch. For discount functions, network access is limited to custom apps on Shopify for enterprises, it has to be enabled by Shopify, and as of August 2026 it is not available on development stores or in a feature preview. Storefront API types are supported only alongside the @defer directive, and the online store cart Ajax endpoints are refused with 502 - Not supported without the request leaving Shopify at all.

Shopify’s own advice is to keep the data in metafields instead of fetching it during checkout, and that is the right default even where the fetch target is available. Sync your pricing table into a shop or product metafield on a schedule, keep each value under 10,000 bytes, and read it through the input query. Latency you do not add to the checkout path is latency you never have to defend.

When is a Shopify Function the wrong tool?

When the discount depends on live data that cannot be mirrored into metafields, and the merchant is not on a plan where network access is available. That is not a coding problem and no amount of clever input querying solves it.

When the rule needs to see the other discounts on the cart. Functions run concurrently and independently, so a function cannot decide “apply this one only if the loyalty discount did not fire”. Combination behaviour belongs to the Discounts Allocator API, whose documentation still sits under the unstable API version as of August 2026, so treat anything you build on it as subject to change.

And when the calculation is genuinely heavy. A per-customer contract price computed across hundreds of lines is work for a pre-computed price list, not for an 11 million instruction budget evaluated on every cart update.

If you are still on purchase.product-discount.run, move one function first, the simplest one you own, and pin it to 2025-04 rather than jumping to the newest version in the same change. Get typegen and replay working against a real captured cart before you touch any logic, then port the rules. On the Shopify projects we run, the port itself is usually a morning. The afternoon goes on the discount classes check that nobody wrote down.

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