Free shipping above a total, excluding a collection

A Shopify discount Function that gives free shipping above a cart total while products in excluded collections don’t count towards it. Tested JavaScript.

Function API
Discount
Target
cart.delivery-options.discounts.generate.run
Discount class
SHIPPING
Plan
Any plan in a public app; Shopify Plus in a custom app
App scope
write_discounts
API version
2026-10
Last checked

In short

This discount Function gives free shipping when the cart reaches a threshold, 1,000 in your store currency by default, counting only products outside the collections you exclude. Collections reach the query as input variables. It uses the cart.delivery-options.discounts.generate.run target of the Discount Function API, version 2026-10, with the shipping discount class.

How to use it

  1. Create an app with Shopify CLI 4.9.0 or later and add the extension: shopify app generate extension --template discount, with JavaScript.
  2. Keep only the delivery target: replace shopify.extension.toml and src/ with the files below.
  3. Run the fixtures with shopify app function run --input tests/above-threshold.input.json.
  4. Deploy with shopify app deploy and create an automatic discount with discountAutomaticAppCreate, discountClasses: [SHIPPING].
  5. Set the discount’s $app:function-configuration metafield, for example {"minimumAmount": 100, "excludedCollectionIds": ["gid://shopify/Collection/1234567890"]}.

Code

shopify.extension.toml
api_version = "2026-10"

[[extensions]]
name = "Free shipping above a total, excluding a collection"
handle = "free-shipping-excluding-collection"
type = "function"

  [[extensions.targeting]]
  target = "cart.delivery-options.discounts.generate.run"
  input_query = "src/cart_delivery_options_discounts_generate_run.graphql"
  input_variables = { namespace = "$app", key = "function-configuration" }
  export = "cart-delivery-options-discounts-generate-run"

  [extensions.build]
  command = ""
  path = "dist/function.wasm"
src/cart_delivery_options_discounts_generate_run.graphql
query CartDeliveryOptionsDiscountsGenerateRunInput($excludedCollectionIds: [ID!]) {
  cart {
    lines {
      cost {
        subtotalAmount {
          amount
        }
      }
      merchandise {
        __typename
        ... on ProductVariant {
          product {
            inAnyCollection(ids: $excludedCollectionIds)
          }
        }
      }
    }
    deliveryGroups {
      id
    }
  }
  presentmentCurrencyRate
  discount {
    discountClasses
    metafield(namespace: "$app", key: "function-configuration") {
      jsonValue
    }
  }
}
src/cart_delivery_options_discounts_generate_run.js
// @ts-check

/**
 * @typedef {import("../generated/api").CartDeliveryOptionsDiscountsGenerateRunInput} CartDeliveryOptionsDiscountsGenerateRunInput
 * @typedef {import("../generated/api").CartDeliveryOptionsDiscountsGenerateRunResult} CartDeliveryOptionsDiscountsGenerateRunResult
 */

/**
 * Settings stored as JSON in the discount's metafield. The same metafield
 * feeds the query's $excludedCollectionIds variable (see input_variables in
 * shopify.extension.toml), so Shopify checks collection membership.
 */
const DEFAULT_CONFIG = {
  // In the store's currency; converted to the customer's currency below.
  minimumAmount: 1000,
  message: "Free shipping",
};

/** @type {CartDeliveryOptionsDiscountsGenerateRunResult} */
const NO_DISCOUNT = { operations: [] };

/**
 * @param {CartDeliveryOptionsDiscountsGenerateRunInput} input
 * @returns {CartDeliveryOptionsDiscountsGenerateRunResult}
 */
export function cartDeliveryOptionsDiscountsGenerateRun(input) {
  // A Function may only return operations for the classes the discount was created with.
  if (!input.discount.discountClasses.includes("SHIPPING")) return NO_DISCOUNT;
  const config = { ...DEFAULT_CONFIG, ...input.discount.metafield?.jsonValue };

  // Products in an excluded collection (gift cards, sale items, oversized goods)
  // don't count towards the threshold.
  const qualifyingSubtotal = input.cart.lines
    .filter((line) => !(line.merchandise.__typename === "ProductVariant" && line.merchandise.product.inAnyCollection))
    .reduce((sum, line) => sum + Number(line.cost.subtotalAmount.amount), 0);
  const minimum = config.minimumAmount * Number(input.presentmentCurrencyRate);
  if (qualifyingSubtotal < minimum || input.cart.deliveryGroups.length === 0) return NO_DISCOUNT;

  return {
    operations: [
      {
        deliveryDiscountsAdd: {
          candidates: [
            {
              message: config.message,
              targets: input.cart.deliveryGroups.map((group) => ({ deliveryGroup: { id: group.id } })),
              value: { percentage: { value: "100" } },
            },
          ],
          selectionStrategy: "ALL",
        },
      },
    ],
  };
}
Test input and expected output
tests/above-threshold.input.json
{
  "cart": {
    "lines": [
      { "cost": { "subtotalAmount": { "amount": "80.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "inAnyCollection": false } } },
      { "cost": { "subtotalAmount": { "amount": "35.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "inAnyCollection": false } } }
    ],
    "deliveryGroups": [{ "id": "gid://shopify/CartDeliveryGroup/1" }]
  },
  "presentmentCurrencyRate": "1.0",
  "discount": {
    "discountClasses": ["SHIPPING"],
    "metafield": { "jsonValue": { "minimumAmount": 100, "excludedCollectionIds": ["gid://shopify/Collection/1234567890"] } }
  }
}
tests/above-threshold.output.json
{
  "operations": [
    {
      "deliveryDiscountsAdd": {
        "candidates": [
          {
            "message": "Free shipping",
            "targets": [{ "deliveryGroup": { "id": "gid://shopify/CartDeliveryGroup/1" } }],
            "value": { "percentage": { "value": "100" } }
          }
        ],
        "selectionStrategy": "ALL"
      }
    }
  ]
}
tests/excluded-items-dont-count.input.json
{
  "cart": {
    "lines": [
      { "cost": { "subtotalAmount": { "amount": "80.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "inAnyCollection": false } } },
      { "cost": { "subtotalAmount": { "amount": "60.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "inAnyCollection": true } } }
    ],
    "deliveryGroups": [{ "id": "gid://shopify/CartDeliveryGroup/1" }]
  },
  "presentmentCurrencyRate": "1.0",
  "discount": {
    "discountClasses": ["SHIPPING"],
    "metafield": { "jsonValue": { "minimumAmount": 100, "excludedCollectionIds": ["gid://shopify/Collection/1234567890"] } }
  }
}
tests/excluded-items-dont-count.output.json
{ "operations": [] }
tests/no-shipping-class.input.json
{
  "cart": {
    "lines": [
      { "cost": { "subtotalAmount": { "amount": "1500.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "inAnyCollection": false } } }
    ],
    "deliveryGroups": [{ "id": "gid://shopify/CartDeliveryGroup/1" }]
  },
  "presentmentCurrencyRate": "1.0",
  "discount": { "discountClasses": ["ORDER"], "metafield": null }
}
tests/no-shipping-class.output.json
{ "operations": [] }

Written by Lintel. MIT license: free to use in your own store and in client work.

Limits and edge cases

  • Line subtotals are in the customer’s currency; the minimum is converted with presentmentCurrencyRate.
  • Every delivery group gets the discount, including all its options. To limit it to the cheapest rate, target deliveryOption handles instead.
  • An input query list variable can hold at most 100 collection IDs.

Questions

How do I exclude products from Shopify’s free shipping threshold?
Use a shipping discount Function that leaves out lines from chosen collections when it adds up the total, as this example does.
Can free shipping apply only to standard shipping?
Yes. Query deliveryOptions with handle and title and target only the matching option with deliveryOption: {handle}.
Does the threshold work with multiple currencies?
Yes. Set it in your store currency; the Function multiplies it by presentmentCurrencyRate before comparing with the cart.
  • Written for Function API version 2026-10 and checked against Shopify’s reference on the date shown. Shopify releases a new version every quarter.
  • Custom apps that contain Functions only run on Shopify Plus stores; a public app with the same Function runs on any plan unless the page says otherwise.
  • Every example ships with test fixtures, and we run them before publishing. Run them again after you change the code: shopify app function run.
  • MIT license. Use the code in your own store or in client work.

Need this built, tested and maintained for your store? See Shopify Functions development.

Have something to build? Let’s talk it through.

Taking a limited number of projects at a time. Every brief is read by an engineer, not a salesperson.

Gencer KarakayaShopify Engineer

An engineer reads your brief, and we reply by email.