# 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.

Page: https://lintelagency.com/resources/shopify-functions/free-shipping-above-total-excluding-collection

## 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.

## Facts

- **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:** 2 November 2026

## 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

```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

```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

```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

```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

```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

```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

```json
{ "operations": [] }
```

### tests/no-shipping-class.input.json

```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

```json
{ "operations": [] }
```

## 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

- [Discount Function API](https://shopify.dev/docs/api/functions/latest/discount)
- [Variables in input queries](https://shopify.dev/docs/apps/build/functions/input-queries/use-variables-input-queries)

## Related

- [Turn on a discount Function as an automatic discount](https://lintelagency.com/resources/graphql/function-backed-automatic-discount)
- [One discount Function, configured by metafield](https://lintelagency.com/resources/shopify-functions/metafield-configured-discount)
- [Selling in many currencies from one Shopify store](https://lintelagency.com/journal/shopify-markets-multi-currency)
- [Configuring Functions with metafields, so rules change without deploys](https://lintelagency.com/journal/configuring-functions-with-metafields)

License: MIT. Free to use in your own store and in client work.
