Shopify Functions Discount: Build a Custom Coupon Code for 15% Off + Free Ground Shipping
If you’ve tried to build a Shopify discount that does two things at once — 15% off products AND free shipping — you’ve probably hit a wall. The built-in discount rules in Shopify Admin don’t let you combine a percentage discount with free shipping under a single coupon code. The customer ends up needing to enter two separate codes, which is a terrible checkout experience and kills conversion.
This is exactly the problem Shopify Functions were built to solve. They let you write custom discount logic server-side — compiled to WebAssembly and plugged directly into Shopify’s checkout pipeline. In this guide, I’ll walk you through the entire Shopify Functions discount setup from scratch: creating the app, writing the Function extension, deploying it, and activating the coupon code on a production store. I’ll explain things the way I wish someone had explained them to me when I was starting out.
What You’ll Learn in This Guide
- How Shopify Functions work and when to use them for discounts
- Setting up a Shopify app with a combined discount Function extension
- Writing JavaScript logic that targets both products and shipping options
- Creating and managing coupon codes via the GraphQL Admin API
- Deploying to production and safely pre-creating a discount before a sale goes live
What Are Shopify Functions, and Why Do You Need Them?
Shopify Functions are small pieces of server-side code that run inside Shopify’s infrastructure at certain points in the checkout process. Unlike theme customizations (which run in the browser), Functions execute on Shopify’s servers before the customer sees anything. This makes them perfect for discount logic.
There are different types of Functions for different purposes. For discounts, Shopify gives you two targets:
- cart.lines.discounts.generate — this runs on the products in the cart (line items). Use this for percentage off or fixed amount off products.
- cart.delivery-options.discounts.generate — this runs on shipping options. Use this for free shipping or shipping discounts.
To apply a 15% product discount AND free shipping with a single code, you need both targets in the same extension. That’s what we’re building.
What We’re Building: A Dual-Target Shopify Functions Discount
By the end of this post, you’ll have a working Shopify app with a custom Function that:
- Applies a 15% discount to all products in the cart when the customer enters a specific code
- Makes Ground shipping free (exact title match — so “Economy” shipping stays at full price)
- Only activates for a specific discount code (in our case,
SHOP15) - Can be created on the store and kept inactive until you’re ready to go live
Prerequisites: What You Need Before Writing Any Code
Before writing a single line of code, make sure you have these things set up:
- Node.js installed (version 18 or higher). Check by running
node -vin your terminal. - A Shopify Partner account — sign up free at partners.shopify.com.
- A development store — create one from your Partner Dashboard. This is your sandbox where you test things without touching real orders.
- Shopify CLI — the command-line tool for building Shopify apps. You don’t need to install it globally; we’ll use
npxto run it.
One thing to be clear about from the start: Shopify Functions are only available inside a Shopify app. You can’t deploy a Function directly to a theme or through the admin UI. You need to build a proper app, even if that app has no frontend UI at all (which ours won’t).
Step 1 — Create the Shopify App
Open your terminal, navigate to the folder where you want to create the project, and run:
npx shopify app init
The CLI will ask you a few questions. Here’s what to select:
- App name: give it any name you want. Something descriptive like
shop-discount-appworks. - Start with template: choose “Start by adding your first extension”
- Which extension?: you can skip this for now — we’ll add extensions manually.
Once it’s done, you’ll have a new folder with a shopify.app.toml file inside. This file is the main configuration for your app. It contains a client_id which ties the local code to an app in your Partner Dashboard. Don’t share this publicly.
Now cd into your new app directory:
cd shop-discount-app
Step 2 — Add the Discount Function Extension
Extensions are the actual pieces of functionality you add to Shopify. For our discount, we need to create a Function extension. Run this command inside your app directory:
npx shopify app generate extension
When it asks what type of extension to create, select Discount — Combined (sometimes listed as “Discount with Function”). This creates an extension that targets both product discounts and delivery discounts.
Give the extension a name — I’ll use shop15-discount. The CLI will generate a folder at extensions/shop15-discount/ with this structure:
extensions/
shop15-discount/
src/
cart_lines_discounts_generate_run.js
cart_lines_discounts_generate_run.graphql
cart_delivery_options_discounts_generate_run.js
cart_delivery_options_discounts_generate_run.graphql
shopify.extension.toml
schema.graphql
The two .graphql files define what data your function receives from Shopify. The two .js files contain the logic that decides what discounts to apply. You’ll edit all four files.
Step 3 — Understand What Data You’re Working With
Before writing any logic, you need to understand how Shopify gives you data. Each Function receives an “input” — a GraphQL response that describes the current state of the cart. You control what fields you ask for in the .graphql file, and Shopify sends you exactly that.
The most important field for code-based discounts is triggeringDiscountCode. This is the exact code the customer typed at checkout. Without this, you have no way of knowing which code was entered.
There’s also a discount.discountClasses field that tells you what types of discounts are enabled on this particular discount code (PRODUCT, SHIPPING, or ORDER). Always check this — it prevents your function from running in contexts where it shouldn’t.
GraphQL Query for Product Discounts
Open src/cart_lines_discounts_generate_run.graphql and replace its contents with:
query CartLinesInput {
cart {
lines {
id
quantity
merchandise {
__typename
... on ProductVariant {
id
}
}
}
}
triggeringDiscountCode
discount {
discountClasses
}
}
This fetches all line items in the cart, the discount code the customer entered, and the discount classes. We need the line item IDs to target them with the discount.
GraphQL Query for Delivery Discounts
Open src/cart_delivery_options_discounts_generate_run.graphql and replace it with:
query DeliveryInput {
cart {
deliveryGroups {
id
deliveryOptions {
handle
title
}
}
}
triggeringDiscountCode
discount {
discountClasses
}
}
Notice that for delivery, we fetch deliveryOptions with their handle and title. The handle is a unique identifier for each shipping option — we’ll use it to target specific options. The title is the human-readable label shown to the customer (“Ground”, “Economy”, “Express”, etc.).
Step 4 — Write the Product Discount Logic
Open src/cart_lines_discounts_generate_run.js and replace its contents with this:
import {
DiscountApplicationStrategy,
DiscountClass,
} from '../generated/api';
/**
* @typedef {import("../generated/api").CartLinesInput} RunInput
* @typedef {import("../generated/api").CartLineDiscountsGenerateRunResult} CartLineDiscountsGenerateRunResult
*/
/**
* Applies 15% off all line items when the customer enters code SHOP15.
*
* @param {RunInput} input
* @returns {CartLineDiscountsGenerateRunResult}
*/
export function cartLinesDiscountsGenerateRun(input) {
// Get the code the customer entered, converted to uppercase for comparison
const code = input.triggeringDiscountCode?.toUpperCase();
// If the code isn't SHOP15, return nothing — no discounts
if (code !== 'SHOP15') {
return { operations: [] };
}
// Double-check that the PRODUCT discount class is enabled on this discount
const hasProductClass = input.discount.discountClasses.includes(
DiscountClass.Product
);
if (!hasProductClass) {
return { operations: [] };
}
// Get all line items from the cart
const lines = input.cart.lines;
if (!lines.length) {
return { operations: [] };
}
// Apply 15% off to every line item
return {
operations: [
{
lineDiscountsAdd: {
candidates: lines.map((line) => ({
message: '15% Off — SHOP15',
targets: [{ cartLine: { id: line.id } }],
value: { percentage: { value: 15 } },
})),
selectionStrategy: DiscountApplicationStrategy.All,
},
},
],
};
}
Let’s walk through what this does step by step:
- We read
triggeringDiscountCode— this is whatever the customer typed in the discount field at checkout. - We convert it to uppercase and check if it equals
SHOP15. If not, we return an empty array which means “apply no discounts.” - We check that this discount has the
PRODUCTclass enabled. This is a safety check — you’ll set this when creating the discount code via GraphQL later. - We map over every line item and tell Shopify to give each one a 15% discount. The
messagetext appears in the cart/checkout summary.
Step 5 — Write the Delivery Discount Logic
This is where it gets slightly more interesting. We don’t want to make ALL shipping free — only the option labeled exactly “Ground”. “Economy” and “Express” should stay at full price.
Open src/cart_delivery_options_discounts_generate_run.js and replace it with:
import {
DeliveryDiscountSelectionStrategy,
DiscountClass,
} from '../generated/api';
/**
* @typedef {import("../generated/api").DeliveryInput} RunInput
* @typedef {import("../generated/api").CartDeliveryOptionsDiscountsGenerateRunResult} CartDeliveryOptionsDiscountsGenerateRunResult
*/
/**
* Applies 100% off (free) ONLY to shipping options whose title is
* exactly "Ground" (case-insensitive). "Economy", "Express", "Ground Delivery"
* — none of these will match.
*
* @param {RunInput} input
* @returns {CartDeliveryOptionsDiscountsGenerateRunResult}
*/
export function cartDeliveryOptionsDiscountsGenerateRun(input) {
const code = input.triggeringDiscountCode?.toUpperCase();
if (code !== 'SHOP15') {
return { operations: [] };
}
const hasShippingClass = input.discount.discountClasses.includes(
DiscountClass.Shipping
);
if (!hasShippingClass) {
return { operations: [] };
}
const operations = [];
for (const group of input.cart.deliveryGroups) {
// Filter: only options where the title is EXACTLY "Ground" (case-insensitive)
const groundOptions = group.deliveryOptions.filter(
(option) => option.title?.trim().toLowerCase() === 'ground'
);
if (!groundOptions.length) {
continue;
}
operations.push({
deliveryDiscountsAdd: {
candidates: groundOptions.map((option) => ({
message: 'Free Ground Shipping — SHOP15',
targets: [{ deliveryOption: { handle: option.handle } }],
value: { percentage: { value: 100 } },
})),
selectionStrategy: DeliveryDiscountSelectionStrategy.All,
},
});
}
return { operations };
}
The key line is this filter:
option.title?.trim().toLowerCase() === 'ground'
This is a strict equality check, not a “contains” check. So “Ground Delivery” won’t match. “ground” (all lowercase) will match because we convert both sides to lowercase first. The trim() removes any accidental spaces around the title.
We target the option using its handle — not the delivery group ID. This is intentional. Targeting the whole group would make ALL shipping methods in that group free. Targeting individual options lets us be selective.
Step 6 — Run the App in Development Mode
Now let’s see it working. Start the development server:
npx shopify app dev --store your-dev-store.myshopify.com
Replace your-dev-store.myshopify.com with your actual development store domain. The first time you run this, the CLI might ask:
- Which organization to use — pick the Partner org your app belongs to
- Whether to create a new app or connect to an existing one — create new for a first-time setup
Once it’s running, you’ll see something like:
shop15-discount │ Building function shop15-discount...
shop15-discount │ Done!
app-preview │ ✅ Ready, watching for changes in your app
The app is now installed on your dev store and the Function is active. Any code changes you make will automatically rebuild — you don’t need to restart the server.
You’ll also get a local GraphiQL URL that looks like:
http://localhost:3457/graphiql?key=...
Keep this URL handy — you’ll need it in the next step.
Step 7 — Find Your Function ID
Before you can create a discount code, you need the ID of the Function you just deployed. Open the GraphiQL interface (the URL from above) and run this query:
{
shopifyFunctions(first: 25) {
nodes {
id
title
}
}
}
You’ll get a list of all Functions installed on your store. Find yours by its title — in our case it should appear as “SHOP15 Combined Discount” or whatever title you set in the extension. The ID looks something like:
01b4d7e2-af83-7c6b-d291-3e8f50a2bc74
Copy this ID. You’ll need it in the next mutation.
Step 8 — Create the Discount Code via GraphQL
You can’t create a code-based Function discount from the Shopify admin UI — you have to do it via the Admin GraphQL API. This might feel unfamiliar at first, but it’s actually straightforward once you understand the mutation.
In GraphiQL, run this mutation (replace the function ID with yours):
mutation {
discountCodeAppCreate(codeAppDiscount: {
title: "SHOP15 - 15% Off + Free Shipping"
functionId: "gid://shopify/ShopifyFunction/01b4d7e2-af83-7c6b-d291-3e8f50a2bc74"
code: "SHOP15"
startsAt: "2026-06-01T00:00:00Z"
appliesOncePerCustomer: false
discountClasses: [PRODUCT, SHIPPING]
combinesWith: {
orderDiscounts: false
productDiscounts: false
shippingDiscounts: true
}
}) {
codeAppDiscount {
discountId
title
status
}
userErrors { field message }
}
}
Let me explain each field so you know what you’re doing:
- title: the name that appears in the Shopify admin Discounts list.
- functionId: the ID you found in the previous step. Note that you need to prefix it with
gid://shopify/ShopifyFunction/. - code: the actual code customers will type at checkout. This is case-insensitive in the checkout UI, but your Function logic should handle lowercase too (which ours does via
toUpperCase()). - startsAt: when the discount becomes valid. You can set this to a past date to make it active immediately, or a future date for a scheduled sale.
- appliesOncePerCustomer: if true, each customer can only use the code once. False means unlimited uses.
- discountClasses: this is important — it tells Shopify which types of discounts your function will apply. We set both PRODUCT and SHIPPING. If you leave out SHIPPING, the delivery discount won’t run at all.
- combinesWith: controls whether this discount can stack with other discounts. We allow combining with shipping discounts but not order or product discounts to prevent double-dipping.
If the mutation succeeds, you’ll see the discountId in the response. It looks like:
"discountId": "gid://shopify/DiscountCodeApp/7384920165831"
Save this ID. You’ll need it to deactivate the discount in the next step.
Step 9 — Deactivate the Discount Until You’re Ready
The discount is now live, which means if anyone enters “SHOP15” at checkout right now, it’ll work. If you’re on a production store and you’re not ready to run the sale yet, deactivate it immediately:
mutation {
discountCodeDeactivate(id: "gid://shopify/DiscountCodeApp/7384920165831") {
codeAppDiscount {
title
status
}
userErrors { field message }
}
}
After running this, the status will change to EXPIRED. The code still exists in the system — customers just can’t use it until you activate it again.
Activating It When the Sale Starts
When you’re ready to go live, you have two options:
Option A — Via Shopify Admin (no code required):
Go to your Shopify Admin → Discounts → find “SHOP15” → click the three-dot menu → Activate. Anyone on your team can do this, no developer needed.
Option B — Via GraphQL mutation:
mutation {
discountCodeActivate(id: "gid://shopify/DiscountCodeApp/7384920165831") {
codeAppDiscount {
title
status
}
userErrors { field message }
}
}
Pro tip: if you know the exact date and time the sale starts, you can set a future startsAt date when creating the discount and skip the deactivate/activate cycle entirely. The discount will turn itself on automatically at the scheduled time.
Step 10 — Deploy to Production
Development mode (shopify app dev) only keeps your Function active as long as the terminal is running. For production, you need to deploy a released version:
npx shopify app deploy --allow-updates
This compiles your Function, uploads it to Shopify, and creates a released version. Once deployed, the Function runs on Shopify’s servers — no server, no terminal, no running process required on your end.
After deploying, go to your Partner Dashboard → your app → Distribution. You’ll find a custom install link there. Share this link with the production store admin so they can install the app on the live store.
Once the app is installed on production, repeat Steps 7 and 8 using the production store’s GraphiQL (Admin → Apps → Shopify GraphQL App) to create the discount code there. The Function ID will be different on production — always query shopifyFunctions first to get the correct ID for that store.
A Few Things That Can Trip You Up
I ran into several issues while building this and I want to save you the time:
1. You must include discountClasses in the mutation
If you omit the discountClasses: [PRODUCT, SHIPPING] field, you’ll get a validation error: “Functions configured to use the discounts API type require the discountClasses field to be set.” This is not documented clearly, but it’s required for any Function that uses the discounts API type.
2. functionId needs the full GID prefix
The shopifyFunctions query returns IDs like 01b4d7e2-af83-7c6b-d291-3e8f50a2bc74. In the discountCodeAppCreate mutation, you need to pass gid://shopify/ShopifyFunction/01b4d7e2-af83-7c6b-d291-3e8f50a2bc74. Without the prefix, you’ll get a validation error.
3. The Function ID changes between stores
Every store where your app is installed gets its own Function ID for that installation. The ID you use on your dev store will NOT work on the production store. Always run shopifyFunctions query on the target store before creating the discount there.
4. triggeringDiscountCode not discountNode
Older blog posts and documentation examples might show you querying discountNode to get the discount details. This doesn’t work for code-based app discounts. Use triggeringDiscountCode instead — it returns the actual string the customer typed.
5. Use deliveryOption targets, not deliveryGroup targets
If you target a deliveryGroup, ALL shipping options in that group become free. If you want to selectively make only “Ground” free, you must target individual deliveryOption handles. This requires fetching deliveryOptions { handle title } in your GraphQL query.
Theme-Specific Enhancements
Everything we’ve built so far runs server-side through Shopify’s checkout and requires no theme changes at all. The discount applies automatically when the customer enters the code — Shopify handles displaying the discount in the checkout summary.
However, if you want to show a promo banner or badge on the product page, collection page, or cart that advertises the discount code before checkout, that requires Liquid template changes. These modifications are theme-specific — the code I’d write for one theme won’t work as-is on a different theme because the structure and CSS class names differ.
If you’re interested in adding frontend promo messaging specific to your theme — like a “Use code SHOP15 for 15% off + free ground shipping” banner — feel free to reach out and I can help with that.
Testing Your Discount
Before going live, test thoroughly on your dev store:
- Add any products to the cart
- Go to checkout
- Enter
SHOP15in the discount field - Verify the order subtotal shows 15% off
- Verify that “Ground” shipping shows $0.00
- Verify that “Economy” or any other shipping method still shows its regular price
- Remove the code — both discounts should disappear
- Try entering a wrong code like
TEST123— nothing should happen
If the shipping discount isn’t working, the most common reason is that the shipping option title doesn’t exactly match “Ground”. Double-check the exact name in Shopify Admin → Settings → Shipping and delivery. Even a single space difference will break the match.
The Full File Reference
Here’s a summary of all four files you edited and what each one does:
| File | What it does |
|---|---|
cart_lines_discounts_generate_run.graphql | Tells Shopify what cart data to send to your product discount function |
cart_lines_discounts_generate_run.js | Applies 15% off to all line items when code is SHOP15 |
cart_delivery_options_discounts_generate_run.graphql | Tells Shopify what shipping data to send to your delivery discount function |
cart_delivery_options_discounts_generate_run.js | Makes Ground shipping free (exact title match) when code is SHOP15 |
Frequently Asked Questions About Shopify Functions Discounts
Do I need a paid Shopify plan to use Functions?
Shopify Functions are available on all Shopify plans. Discount Functions — which is what this guide covers — work on Basic, Shopify, Advanced, and Plus. Some other extension types (like certain checkout UI extensions) are Plus-only, but discount logic is not one of them.
Can I use this with automatic discounts instead of a coupon code?
Yes. Instead of discountCodeAppCreate, you’d use discountAutomaticAppCreate. Automatic discounts apply to everyone at checkout without requiring them to enter a code. The Function JavaScript logic stays exactly the same — only the GraphQL mutation to create the discount changes.
What happens to the discount code if I uninstall the app?
If the app is uninstalled, the Function is removed and the discount stops working — even if the code still appears in the Shopify admin Discounts list. The code becomes an empty shell. Always communicate this to store owners before uninstalling any app that powers discount Functions.
Can I change the discount percentage without redeploying?
Not directly — the percentage is hardcoded in the JavaScript logic. To change it, update the code and run shopify app deploy. For a more flexible approach, you can store the percentage in a metafield on the discount object and read it inside the Function. This lets you update the value from the Shopify admin without any redeployment.
Is building a custom Shopify Function better than using a third-party discount app?
For simple use cases, a third-party app is faster to set up. But Shopify Functions give you complete control: no monthly subscription for the discount logic, no dependency on another vendor’s uptime, and you can implement business rules that no off-the-shelf app supports. For agencies and developers building custom solutions for clients, Shopify Functions are the professional long-term choice.
Wrapping Up
Shopify Functions have a learning curve — the tooling, the GraphQL input/output model, and the deployment process are all different from what you’d do in a theme. But once you’ve built one, the pattern clicks and you can adapt it to almost any discount scenario you can think of: tiered discounts, customer-tag-based pricing, BOGO deals, shipping rules based on product type — all of it is possible.
The approach we used here — separating product and delivery logic into two distinct function targets, using exact string matching for shipping options, and creating the discount code via mutation rather than the admin UI — gives you full control and keeps the implementation clean and maintainable.
If you run into issues or want to extend this to handle more complex scenarios, feel free to get in touch. Happy to help.