How to Feed Dynamic, Merchant-Configured Data Into a Shopify Function (Input Query Variables Explained)

If you've built a Shopify Function, you already know the input query is static — it's a fixed `.graphql` file compiled at deploy time. That's fine when your logic only needs data Shopify already exposes (cart lines, delivery addresses, discount classes). It breaks down the moment your function needs something the *merchant* configured through your app's admin UI — say, "only apply this shipping discount to products in these three collections," where the collections are chosen per-shop and can change at any time.

Discountify

10 minuts

How to Feed Dynamic, Merchant-Configured Data Into a Shopify Function (Input Query Variables Explained)

You can't just fetch a metafield inside the query and use its value as an argument to another field in the same query. GraphQL doesn't support that kind of dependent, two-stage resolution. A lot of developers hit this wall, assume Functions simply can't do dynamic targeting, and either hardcode values or give up on the feature. They can. You just need the right mechanism: input query variables, wired through shopify.extension.toml.

This post walks through the full setup end to end: the TOML config, the GraphQL query, the JS function logic, and the Ruby backend code that keeps the merchant's selection in sync. This is the exact pattern behind Discountify's collection-targeted shipping discounts, where merchants pick which collections qualify for free or discounted shipping directly in the app's admin UI, and that selection flows into a Shopify Function at checkout using this mechanism. If you'd rather skip building this from scratch, Discountify already ships it as a ready-made rule type.

The mental model

Three pieces work together:

  1. A metafield on the discount node itself (not the shop, not the app installation) holding the merchant's current configuration as JSON.

  2. A block in shopify.extension.toml telling Shopify which metafield to read and which query variable to populate from it.

  3. A GraphQL variable in your input query, declared like any normal GraphQL variable, that Shopify fills in automatically before invoking your function. No manual fetch required inside the query.

The key detail that trips people up: this only works with metafields on the function's owner. For a discount function, the owner is the specific discount node, the one returned when you created the automatic discount via the Admin API. Shop-level or app-level metafields cannot populate input variables this way, even though you can still read them as plain fields elsewhere in the query for other purposes.

Step 1: Wire the TOML

In your function extension's shopify.extension.toml, add [extensions.input.variables] as a sibling section to extensions.build, not nested inside extensions.targeting. This is the part most people get wrong; it's easy to assume it belongs next to the target definition since that's conceptually "where the query lives," but it doesn't.

api_version = "2025-07"

[[extensions]]
name = "t:name"
handle = "discountify-shipping"
type = "function"
description = "t:description"

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

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

  [extensions.input.variables]
  namespace = "$app:discountify_shipping"
  key = "shipping_rules"
api_version = "2025-07"

[[extensions]]
name = "t:name"
handle = "discountify-shipping"
type = "function"
description = "t:description"

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

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

  [extensions.input.variables]
  namespace = "$app:discountify_shipping"
  key = "shipping_rules"
api_version = "2025-07"

[[extensions]]
name = "t:name"
handle = "discountify-shipping"
type = "function"
description = "t:description"

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

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

  [extensions.input.variables]
  namespace = "$app:discountify_shipping"
  key = "shipping_rules"

namespace and key here must exactly match the metafield you're going to write to the discount node in Step 3. The $app: prefix is a reserved namespace pattern scoped to your app, and using it avoids collisions with other apps or merchant-created metafields on the same object.

Step 2: Declare the variable in your input query

Your .graphql input file needs the variable declared on the query itself, then referenced anywhere you need it:

query DeliveryInput($selectedCollectionIds: [ID!]!) {
  cart {
    lines {
      id
      quantity
      merchandise {
        __typename
        ... on ProductVariant {
          id
          product {
            id
            inAnyCollection(ids: $selectedCollectionIds)
            inCollections(ids: $selectedCollectionIds) {
              collectionId
              isMember
            }
          }
        }
      }
    }
    deliveryGroups {
      id
      deliveryAddress {
        countryCode
      }
      deliveryOptions {
        handle
        title
        cost {
          amount
        }
      }
    }
  }
  discount {
    discountClasses
  }
}
query DeliveryInput($selectedCollectionIds: [ID!]!) {
  cart {
    lines {
      id
      quantity
      merchandise {
        __typename
        ... on ProductVariant {
          id
          product {
            id
            inAnyCollection(ids: $selectedCollectionIds)
            inCollections(ids: $selectedCollectionIds) {
              collectionId
              isMember
            }
          }
        }
      }
    }
    deliveryGroups {
      id
      deliveryAddress {
        countryCode
      }
      deliveryOptions {
        handle
        title
        cost {
          amount
        }
      }
    }
  }
  discount {
    discountClasses
  }
}
query DeliveryInput($selectedCollectionIds: [ID!]!) {
  cart {
    lines {
      id
      quantity
      merchandise {
        __typename
        ... on ProductVariant {
          id
          product {
            id
            inAnyCollection(ids: $selectedCollectionIds)
            inCollections(ids: $selectedCollectionIds) {
              collectionId
              isMember
            }
          }
        }
      }
    }
    deliveryGroups {
      id
      deliveryAddress {
        countryCode
      }
      deliveryOptions {
        handle
        title
        cost {
          amount
        }
      }
    }
  }
  discount {
    discountClasses
  }
}

Two things worth calling out:

  • inAnyCollection and inCollections are targeted membership checks, not enumeration fields. Pass them an empty array and they will always return false/empty, that's correct behavior, not a bug. You have to give them the specific collection IDs you want checked, which is exactly what $selectedCollectionIds supplies.

  • The variable name (selectedCollectionIds here) is arbitrary, but it has to match a JSON property name inside the metafield value, which we set up next. Get this mismatched and the variable resolves to null, which, because the type is [ID!]! (non-nullable), crashes function execution with an INVALID_VARIABLE error rather than failing silently.

Step 3: Write the metafield onto the discount node

This is backend work in your app. Every time the merchant changes their collection selection, your app needs to push that value onto the discount's metafield. Two operations: find the discount node's ID, then set the metafield on it.

module Shopify
  module Graphql
    module DiscountNode
      extend Formatter
      class << self
        def fetch(discount_title: ShippingMetafield::NAME, shop:)
          query = <<~QUERY
            query {
              discountNodes(query: "#{discount_title}", first: 1) {
                edges {
                  node {
                    id
                  }
                }
              }
            }
          QUERY

          response = shop.with_shopify_session do
            Shopify::Graphql::Connector.client.query(query: query)
          end

          response.body.dig('data', 'discountNodes', 'edges', 0, 'node', 'id')
        end

        def set_metafield(discount_node_id, collection_ids, shop:)
          query = <<~QUERY
            mutation SetMetafield {
              metafieldsSet(metafields: [
                {
                  ownerId: "#{discount_node_id}",
                  namespace: "$app:discountify_shipping",
                  key: "shipping_rules",
                  type: "json",
                  value: #{collection_ids.to_json.to_json}
                }
              ]) {
                metafields { id namespace key value }
                userErrors { field message }
              }
            }
          QUERY

          response = shop.with_shopify_session do
            Shopify::Graphql::Connector.client.query(query: query)
          end
        end
      end
    end
  end
end
module Shopify
  module Graphql
    module DiscountNode
      extend Formatter
      class << self
        def fetch(discount_title: ShippingMetafield::NAME, shop:)
          query = <<~QUERY
            query {
              discountNodes(query: "#{discount_title}", first: 1) {
                edges {
                  node {
                    id
                  }
                }
              }
            }
          QUERY

          response = shop.with_shopify_session do
            Shopify::Graphql::Connector.client.query(query: query)
          end

          response.body.dig('data', 'discountNodes', 'edges', 0, 'node', 'id')
        end

        def set_metafield(discount_node_id, collection_ids, shop:)
          query = <<~QUERY
            mutation SetMetafield {
              metafieldsSet(metafields: [
                {
                  ownerId: "#{discount_node_id}",
                  namespace: "$app:discountify_shipping",
                  key: "shipping_rules",
                  type: "json",
                  value: #{collection_ids.to_json.to_json}
                }
              ]) {
                metafields { id namespace key value }
                userErrors { field message }
              }
            }
          QUERY

          response = shop.with_shopify_session do
            Shopify::Graphql::Connector.client.query(query: query)
          end
        end
      end
    end
  end
end
module Shopify
  module Graphql
    module DiscountNode
      extend Formatter
      class << self
        def fetch(discount_title: ShippingMetafield::NAME, shop:)
          query = <<~QUERY
            query {
              discountNodes(query: "#{discount_title}", first: 1) {
                edges {
                  node {
                    id
                  }
                }
              }
            }
          QUERY

          response = shop.with_shopify_session do
            Shopify::Graphql::Connector.client.query(query: query)
          end

          response.body.dig('data', 'discountNodes', 'edges', 0, 'node', 'id')
        end

        def set_metafield(discount_node_id, collection_ids, shop:)
          query = <<~QUERY
            mutation SetMetafield {
              metafieldsSet(metafields: [
                {
                  ownerId: "#{discount_node_id}",
                  namespace: "$app:discountify_shipping",
                  key: "shipping_rules",
                  type: "json",
                  value: #{collection_ids.to_json.to_json}
                }
              ]) {
                metafields { id namespace key value }
                userErrors { field message }
              }
            }
          QUERY

          response = shop.with_shopify_session do
            Shopify::Graphql::Connector.client.query(query: query)
          end
        end
      end
    end
  end
end

Call it like:

discount_node_id = Shopify::Graphql::DiscountNode.fetch(shop: shop)

Shopify::Graphql::DiscountNode.set_metafield(
  discount_node_id,
  { "selectedCollectionIds" => selected_collections || [] },
  shop: shop

discount_node_id = Shopify::Graphql::DiscountNode.fetch(shop: shop)

Shopify::Graphql::DiscountNode.set_metafield(
  discount_node_id,
  { "selectedCollectionIds" => selected_collections || [] },
  shop: shop

discount_node_id = Shopify::Graphql::DiscountNode.fetch(shop: shop)

Shopify::Graphql::DiscountNode.set_metafield(
  discount_node_id,
  { "selectedCollectionIds" => selected_collections || [] },
  shop: shop

That produces a metafield value of:

{"selectedCollectionIds": ["gid://shopify/Collection/493130481888"]}
{"selectedCollectionIds": ["gid://shopify/Collection/493130481888"]}
{"selectedCollectionIds": ["gid://shopify/Collection/493130481888"]}

The collection_ids.to_json.to_json double-call matters. The first .to_json turns the Ruby hash into a JSON string. If you then interpolate that string directly between manual quotes (value: "#{collection_ids.to_json}"), the quote characters already inside the JSON string break out of your GraphQL string literal and the query fails to parse, regardless of whether the array is empty or not. The second .to_json re-encodes that string as a properly escaped literal, safe to drop straight into the query text without wrapping it in quotes yourself.

If your GraphQL client supports passing real variables instead of string-interpolating the whole query, that's the more robust long-term fix. It sidesteps this escaping problem entirely, since the client library handles JSON encoding for you.

Step 4: Read it in your function code

On the function side, input.selectedCollectionIds now arrives already populated. No fetch logic needed in your Rust/JS function itself. In JavaScript:

// @ts-check

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

/**
 * @param {RunInput} input
 * @returns {RunResult}
 */
export function run(input) {
  const targetCollectionIds = input.selectedCollectionIds ?? [];

  const eligibleLine = input.cart.lines.find((line) => {
    if (line.merchandise.__typename !== "ProductVariant") return false;
    return line.merchandise.product.inAnyCollection;
  });

  if (!eligibleLine) {
    return { operations: [] };
  }

  const firstDeliveryGroup = input.cart.deliveryGroups[0];
  if (!firstDeliveryGroup) {
    return { operations: [] };
  }

  return {
    operations: [
      {
        deliveryDiscountsAdd: {
          candidates: [
            {
              message: "Free shipping on qualifying products",
              targets: [
                {
                  deliveryGroup: {
                    id: firstDeliveryGroup.id,
                  },
                },
              ],
              value: {
                percentage: {
                  value: 100,
                },
              },
            },
          ],
          selectionStrategy: "FIRST",
        },
      },
    ],
  };
}
// @ts-check

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

/**
 * @param {RunInput} input
 * @returns {RunResult}
 */
export function run(input) {
  const targetCollectionIds = input.selectedCollectionIds ?? [];

  const eligibleLine = input.cart.lines.find((line) => {
    if (line.merchandise.__typename !== "ProductVariant") return false;
    return line.merchandise.product.inAnyCollection;
  });

  if (!eligibleLine) {
    return { operations: [] };
  }

  const firstDeliveryGroup = input.cart.deliveryGroups[0];
  if (!firstDeliveryGroup) {
    return { operations: [] };
  }

  return {
    operations: [
      {
        deliveryDiscountsAdd: {
          candidates: [
            {
              message: "Free shipping on qualifying products",
              targets: [
                {
                  deliveryGroup: {
                    id: firstDeliveryGroup.id,
                  },
                },
              ],
              value: {
                percentage: {
                  value: 100,
                },
              },
            },
          ],
          selectionStrategy: "FIRST",
        },
      },
    ],
  };
}
// @ts-check

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

/**
 * @param {RunInput} input
 * @returns {RunResult}
 */
export function run(input) {
  const targetCollectionIds = input.selectedCollectionIds ?? [];

  const eligibleLine = input.cart.lines.find((line) => {
    if (line.merchandise.__typename !== "ProductVariant") return false;
    return line.merchandise.product.inAnyCollection;
  });

  if (!eligibleLine) {
    return { operations: [] };
  }

  const firstDeliveryGroup = input.cart.deliveryGroups[0];
  if (!firstDeliveryGroup) {
    return { operations: [] };
  }

  return {
    operations: [
      {
        deliveryDiscountsAdd: {
          candidates: [
            {
              message: "Free shipping on qualifying products",
              targets: [
                {
                  deliveryGroup: {
                    id: firstDeliveryGroup.id,
                  },
                },
              ],
              value: {
                percentage: {
                  value: 100,
                },
              },
            },
          ],
          selectionStrategy: "FIRST",
        },
      },
    ],
  };
}

Note input.selectedCollectionIds ?? [] as a defensive fallback. If the metafield hasn't been set yet (e.g. a merchant who hasn't configured anything), you want a graceful empty-array default here in your own code, separate from whether your GraphQL variable type is nullable. Speaking of which: during development, it's worth declaring the query variable as nullable ([ID!] instead of [ID!]!) so a missing metafield produces null instead of a hard function-execution error while you're still testing the wiring. Tighten it to non-nullable once you're confident the metafield is always set before the function can run in production.

Common mistakes, in order of how often they show up

  1. Putting [extensions.input.variables] inside [[extensions.targeting]]. It's a sibling section, not a nested one.

  2. Setting the metafield on the shop instead of the discount node. Shop-level metafields cannot feed input variables. Only metafields on the function's owner can.

  3. Mismatched JSON key vs. query variable name. {"selectedCollectionIds": [...]} in the metafield only populates $selectedCollectionIds in your query, not $collectionIds, not $collections. They have to match character-for-character.

  4. Manually quoting an already-serialized JSON string when building the mutation via interpolation. Leads to a GraphQL syntax error that looks unrelated to the actual cause. Double-encode (.to_json.to_json) or use real GraphQL variables in your HTTP client instead.

  5. Using [ID!]! (non-nullable) before confirming the metafield is reliably set. A missing metafield resolves to null, and a non-nullable type turns that into a hard INVALID_VARIABLE crash instead of a value you can handle gracefully in code.

Once these four pieces are lined up (TOML config, query variable, owner-scoped metafield, and correctly escaped mutation), the function receives merchant-configured, fully dynamic data on every run, with zero extra API calls inside the function itself.

Why this matters if you're building or hiring for Shopify Functions

Shopify Functions run in a sandboxed WASM environment with strict input size limits and no runtime network access. That's exactly why patterns like this one matter so much: you can't just call an API from inside the function the way you would in a regular Rails controller or webhook handler. Every piece of dynamic, merchant-specific data has to be pre-resolved into the input payload before the function ever executes. Getting comfortable with metafield-driven input variables, GraphQL Admin API mutations, and the full Shopify Function lifecycle (build, deploy, target, input query, run) is core to building anything beyond a fixed-logic discount, shipping, or checkout customization on Shopify today.

This is the kind of implementation work we do daily at Essenify, a Shopify app development company based in Ahmedabad, India, building and maintaining a portfolio of public Shopify apps on Ruby on Rails: Discountify for automated and rule-based discounting, including Shopify Functions-driven shipping rules like the one in this post, AddOn Suggest for product add-on and cross-sell recommendations, AI Product Bundle Builder for bundle and "build your box" experiences, Product Quiz Builder (QuizBuddy) for guided-selling quizzes, FAQ Expert for merchant FAQ management, and Bulk Price Updater for large-scale catalog pricing changes.

If your team is evaluating custom Shopify Function development, whether it's discount logic, cart transforms, delivery customizations, or payment customizations, or needs help debugging a function that's deployed but silently not running (a surprisingly common issue), this is the kind of Shopify Functions and app architecture work our team handles end to end: from the Admin API mutation that creates the discount, through the metafield wiring covered here, to the deployed WASM function itself. If you'd rather see the finished feature than build it, Discountify already ships collection-targeted shipping discounts using exactly this pattern.

Share this Blog
Ruby on Rails Developer
Written by
Ashok Damaniya

Ashok Damaniya is the Chief Technical Officer (CTO) at Essenify, where he leads the development of Shopify apps focused on helping merchants increase sales and improve the customer experience. His expertise spans Shopify app architecture, e-commerce solutions, and scalable cloud infrastructure. He is passionate about building practical tools that solve real merchant problems and enjoys turning insights from the Shopify ecosystem into products that are simple, reliable, and impactful.

  • LET’S BUILD TOGETHER • CONTACT ESSENIFY • SAY HELLO •

  • LET’S BUILD TOGETHER • CONTACT ESSENIFY • SAY HELLO •

  • LET’S BUILD TOGETHER • CONTACT ESSENIFY • SAY HELLO •

  • LET’S BUILD TOGETHER • CONTACT ESSENIFY • SAY HELLO •

  • LET’S BUILD TOGETHER • CONTACT ESSENIFY • SAY HELLO •

  • LET’S BUILD TOGETHER • CONTACT ESSENIFY • SAY HELLO •

  • STORE GROWTH • SHOPIFY STORIES • BETTER CHECKOUTS •

  • APP GROWTH • SHOPIFY STORIES • BETTER CHECKOUTS •

  • STORE GROWTH • SHOPIFY STORIES • BETTER CHECKOUTS •

  • APP GROWTH • SHOPIFY STORIES • BETTER CHECKOUTS •

  • STORE GROWTH • SHOPIFY STORIES • BETTER CHECKOUTS •

  • APP GROWTH • SHOPIFY STORIES • BETTER CHECKOUTS •

Say Hello To

Say Hello To

Say Hello To

Essenify

Essenify

Essenify

Tell us what you’re building, where it feels stuck, and what you want customers to feel next. We’ll shape the next move with you.

Tell us what you’re building, where it feels stuck, and what you want customers to feel next. We’ll shape the next move with you.

Tell us what you’re building, where it feels stuck, and what you want customers to feel next. We’ll shape the next move with you.

Usually replies within a day