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