Stacking Two Product Discounts on One Shopify Cart Line (2026-04)

You ship an automatic app discount — volume break, bundle, gift-with-purchase. The merchant runs a coupon code on top of it. Your function fires, the code applies, but at checkout only one of them survives. Shopify silently picks the larger discount and discards the other. No error. No warning. Marketing finds out from a confused customer.

That used to be the rule: one product discount per cart line. On April 30, 2026 Shopify shipped Admin API 2026-04 and a new field on combinesWith called productDiscountsWithTagsOnSameCartLine. Two product discounts can now apply to the same line — if both sides match a tag the other declares, and both sides allow the right combinesWith classes. The second clause is not documented anywhere and cost us a couple of days.

This is a build log of the sync layer we put on top of that field. The stack: a Shopify Remix app on Node.js 20, TypeScript, the Admin GraphQL API at 2026-04, two webhook handlers, an in-process node-cron backstop, and a small embedded admin page for the manual overrides. Shopify Plus only.

1. The problem before 2026-04

Our checkout runs an automatic discount produced by a Shopify Function — call it Discounts Function. It owns:

  • volume breaks (buy 3+, get 10% off)
  • bundle pricing (pack A + B at a bundle price)
  • gift-with-purchase line additions priced at zero
  • upsell discounts on the cart drawer

The merchant also runs marketing code discounts: BLACKFRIDAY, SUMMER15, the usual mix. Both are product-class discounts in Shopify’s taxonomy. Both target the same cart line.

Pre-2026-04, the engine’s behavior on a line that two product discounts both want to touch:

input:  app discount    -> 15% off (volume break)
        coupon code     -> 11% off
output: 15% applied, 11% silently dropped

combinesWith.productDiscounts: true on the code did nothing for this case. That boolean only allowed non-overlapping product discounts on different lines to coexist in the same cart. Same-line collision still resolved to “pick the bigger one.” Our support inbox was about 60% “why didn’t my code work” by Q1 2026.

2. What 2026-04 actually shipped

The Shopify changelog: Multiple product discounts can apply on a single cart line. The new input lives on DiscountCombinesWithInput:

input ProductDiscountsWithTagsOnSameCartLineInput {
  add: [String!]
  remove: [String!]
}

The matching rule, paraphrased from the input docs: two discounts apply together when each one allows at least one tag the other is tagged with. Bidirectional.

Side-by-side, what the two discounts have to look like:

Sidetags (what this discount IS)combinesWith.productDiscountsWithTagsOnSameCartLine (what it ALLOWS)
Our app discount["myshop-app-discount"]["myshop-stackable"]
Code discount (e.g. BLACKFRIDAY)["myshop-stackable"]["myshop-app-discount"]

The app discount is tagged myshop-app-discount and allows anything tagged myshop-stackable. The code is tagged myshop-stackable and allows anything tagged myshop-app-discount. Both sides match → the engine applies both on the line, in order, subtracting one then the other.

Constraints worth pinning before you write anything:

  • Shopify Plus only. The whole feature is gated on shop.plan.shopifyPlus == true. There is no fallback for Shopify, Advanced, or Plus-Lite.
  • Product-class discounts only. DiscountCodeBxgy and DiscountCodeFreeShipping are different classes and don’t participate.
  • Same cart line. Discounts on different lines never needed this field — that path was already allowed by productDiscounts: true on combinesWith.

3. The undocumented trap — orderDiscounts also has to be true

This one is not in the changelog, not in the input docs, and not in any Shopify post I could find. We discovered it by binary-searching combinesWith booleans against a live test cart in dev. If you only learn one thing from this article, learn this:

For the app discount to stack with a code discount on the same line, the code’s combinesWith.orderDiscounts must also be true.

Why. Our app discount is created with discountClasses: ["PRODUCT", "ORDER", "SHIPPING"] — the function’s manifest declares it can emit any of those three classes at runtime (even though, in practice today, it only emits PRODUCT). Empirically the engine’s combination rule is: for two discounts to combine on a line, side B must allow every class side A declares — for the PRODUCT and ORDER classes. SHIPPING is exempt (verified: a code with shippingDiscounts: false still combined.).

What that means concretely:

// Code discount with the "obvious" combinesWith — DOES NOT STACK
combinesWith: {
  productDiscounts: true,
  orderDiscounts: false,   // ← silently kills stacking
  shippingDiscounts: true,
  productDiscountsWithTagsOnSameCartLine: ["myshop-app-discount"],
}

// Code discount that actually stacks
combinesWith: {
  productDiscounts: true,
  orderDiscounts: true,    // ← required because the app declares the ORDER class
  shippingDiscounts: true, // doesn't matter, leave as merchant set it
  productDiscountsWithTagsOnSameCartLine: ["myshop-app-discount"],
}

Symptom when you get it wrong: the code looks correct in the admin Combinations panel (“Product discounts” is ticked, the cross-tag is there), the GraphQL response confirms the cross-tag is set, the cart at checkout still applies only one of the two discounts. No log line, no userError, no anything. Shopify picks the larger and moves on.

If your app discount’s discountClasses is narrowed to ["PRODUCT"] only, this whole orderDiscounts: true requirement goes away. We didn’t narrow ours — see Section 7 for why.

4. The architecture

The merchant should not have to think about any of this. They create a code in Shopify admin the way they always have. Stacking should “just work.” That means the app has to keep every code discount in sync — set the tag, set the cross-tag, force both booleans, and re-check it forever.

┌───────────────────────────────────────────────────────────────────┐
│  Merchant creates / edits a code in Shopify admin                 │
│         │                                                          │
│         ▼                                                          │
│  Webhook  discounts/create   →  default-on   (always tag it)      │
│  Webhook  discounts/update   →  reconcile    (follow tag state)   │
│         │                                                          │
│         │ (writes via discountCodeBasicUpdate)                    │
│         ▼                                                          │
│  Shopify discount engine — applies both on the cart line          │
│         ▲                                                          │
│         │                                                          │
│  In-process node-cron (twice daily) — reconcile every code        │
│         │                                                          │
│  /app/discount-stacking — embedded admin UI for manual overrides  │
└───────────────────────────────────────────────────────────────────┘

Three layers of sync, in priority order:

LayerTriggerModeSpeed
Webhook discounts/createMerchant creates a new codedefault-on (tag + cross-tag + booleans)~few seconds
Webhook discounts/updateMerchant edits a codereconcile (follow current tag state)Real-time
In-process cron0 */12 * * * in productionreconcile over all codes≤12h backstop
UI page buttonManual clickforce-enable / force-disableOn-demand

Why three layers, not just the webhook:

  • Webhook delivery is at-least-once. Shopify retries failed deliveries up to ~19 times across ~48h, but after that the event is dropped. The cron is the backstop.
  • The merchant can also create codes via Admin API (some agencies do bulk imports). Those bypass admin UI, but they still fire discounts/create. Belt-and-braces with the cron.
  • The UI page exists because someone always needs a manual lever — for a single code that drifted, you don’t want to wait 12 hours for the cron.

5. The webhook handler (the real one)

The whole sync logic is in app/services/discountStacking.server.ts. The webhook routes are thin shells around syncCodeDiscount(admin, id, mode). Two policies:

  • discounts/create → mode default-on. New code is stackable unless the merchant strips the tag later.
  • discounts/update → mode reconcile. If the tag is on, ensure cross-tag + booleans are right. If the tag was removed, treat that as opt-out and strip the cross-tag too.

The core decision function — planUpdate — looks at a snapshot of the discount and emits one of enable, disable, or noop:

// app/services/discountStacking.server.ts

export const APP_DISCOUNT_TAG = "myshop-app-discount"
export const STACKABLE_TAG    = "myshop-stackable"

function planUpdate(snapshot: DiscountCodeBasicSnapshot, mode: SyncMode) {
  const hasTag       = snapshot.tags.includes(STACKABLE_TAG)
  const crossTags    = snapshot.combinesWith.productDiscountsWithTagsOnSameCartLine ?? []
  const hasCrossTag  = crossTags.includes(APP_DISCOUNT_TAG)
  const productOn    = snapshot.combinesWith.productDiscounts === true
  const orderOn      = snapshot.combinesWith.orderDiscounts   === true

  const wantEnable =
    mode === "default-on"   ? true  :
    mode === "force-enable" ? true  :
    mode === "force-disable"? false :
    /* reconcile */           hasTag

  if (wantEnable) {
    const needsTag           = !hasTag
    const needsCrossTag      = !hasCrossTag
    const needsProductOn     = !productOn
    const needsOrderOn       = !orderOn
    if (!needsTag && !needsCrossTag && !needsProductOn && !needsOrderOn) {
      return { kind: "noop" as const }
    }
    return { kind: "enable" as const, needsTag, needsCrossTag, needsProductOn, needsOrderOn }
  }

  if (!hasTag && !hasCrossTag) return { kind: "noop" as const }
  return { kind: "disable" as const, hasTag, hasCrossTag }
}

The mutation it issues, when the plan says enable:

const basicCodeDiscount: Record<string, unknown> = {}

if (plan.needsTag) {
  basicCodeDiscount.tags = uniq([...snapshot.tags, STACKABLE_TAG])
}

if (plan.needsCrossTag || plan.needsProductOn || plan.needsOrderOn) {
  basicCodeDiscount.combinesWith = {
    // preserve merchant's shippingDiscounts verbatim — not needed for stacking
    shippingDiscounts: snapshot.combinesWith.shippingDiscounts,
    // force the two that the engine actually requires
    productDiscounts: true,
    orderDiscounts: true,
    // only when we need to add the cross-tag
    ...(plan.needsCrossTag && {
      productDiscountsWithTagsOnSameCartLine: { add: [APP_DISCOUNT_TAG] },
    }),
  }
}

await admin.graphql(
  `#graphql
   mutation updateCodeDiscountStacking($id: ID!, $basicCodeDiscount: DiscountCodeBasicInput!) {
     discountCodeBasicUpdate(id: $id, basicCodeDiscount: $basicCodeDiscount) {
       codeDiscountNode { id }
       userErrors { field message code }
     }
   }`,
  { variables: { id, basicCodeDiscount } },
)

Two non-obvious things in that mutation body:

1. Always pass all three combinesWith booleans when you touch combinesWith. DiscountCombinesWithInput defaults each boolean to false when omitted. So if you send combinesWith: { productDiscountsWithTagsOnSameCartLine: { add: [...] } } without the booleans, Shopify resets productDiscounts and orderDiscounts and shippingDiscounts to false — which (a) violates Shopify’s rule that productDiscounts must be true whenever the cross-tag list is non-empty (you’ll get INVALID_PRODUCT_DISCOUNTS_FALSE_WITH_EXISTING_TAGS_ON_SAME_CART_LINE), and (b) silently clobbers the merchant’s order- and shipping-discount settings. The fix is mechanical: always echo back all three.

2. Use { add: [APP_DISCOUNT_TAG] }, not a bare array. The input is ProductDiscountsWithTagsOnSameCartLineInput, not [String!]. It takes add and remove lists, which Shopify diff-applies against the current state. A bare array won’t even parse.

6. The echo loop — why our own writes were undoing themselves

We hit a fun race once the webhook went live in dev. The flow:

  1. Merchant creates a code. discounts/create fires. Our handler tags + cross-tags + flips both booleans. Good.
  2. Within 1–2s, Shopify re-fires the same discount as discounts/update (because our mutation modified it).
  3. Our update handler does a fresh codeDiscountNode query.
  4. The query comes back with stale state — tags still showing as empty, combinesWith showing the pre-mutation values. Shopify’s eventual consistency for tag writes is observable for a few seconds.
  5. reconcile reads “tag absent” as “merchant opted out.” It strips the cross-tag.
  6. Stacking is now broken on a code we just successfully enabled.

The fix: a process-local recentSelfUpdates map. Every successful mutation records id → now(). syncCodeDiscount checks the map only in reconcile mode and skips if the entry is less than 10 seconds old. Explicit modes (default-on, force-enable, force-disable) bypass the dedup — they represent user intent and shouldn’t be filtered.

const recentSelfUpdates = new Map<string, number>()
const ECHO_SUPPRESSION_MS = 10_000

function markSelfUpdate(id: string) {
  recentSelfUpdates.set(id, Date.now())
  if (recentSelfUpdates.size > 1000) {                // keep the map bounded
    const cutoff = Date.now() - 60_000
    for (const [key, ts] of recentSelfUpdates) {
      if (ts < cutoff) recentSelfUpdates.delete(key)
    }
  }
}

function isRecentSelfUpdate(id: string) {
  const ts = recentSelfUpdates.get(id)
  if (!ts) return false
  if (Date.now() - ts > ECHO_SUPPRESSION_MS) {
    recentSelfUpdates.delete(id)
    return false
  }
  return true
}

Known limitation: the map is per-dyno. On a multi-dyno deployment, an echo can land on a different dyno that never saw our original write, and slip through. We accepted it on staging because staging is single-web-dyno. If we see echo issues in production, the next step is backing the dedup with Redis — same shape, same 10s window, just cross-dyno safe.

7. The app-side activation (and why we didn’t narrow discountClasses)

The app discount itself is created once, when the merchant clicks Activate on the embedded Functions page:

// app/routes/app.functions.tsx — discountAutomaticAppCreate input
{
  title: "Discounts Function",
  functionId,
  tags: ["myshop-app-discount"],
  combinesWith: {
    productDiscounts: true,
    orderDiscounts: true,
    shippingDiscounts: true,
    productDiscountsWithTagsOnSameCartLine: { add: ["myshop-stackable"] },
  },
  discountClasses: ["PRODUCT", "ORDER", "SHIPPING"],
}

Two design choices that are easy to second-guess:

discountClasses: ["PRODUCT", "ORDER", "SHIPPING"], not ["PRODUCT"]. The function actually emits only PRODUCT-class operations today. Narrowing the manifest to ["PRODUCT"] would let code discounts stack with productDiscounts: true alone — no orderDiscounts: true requirement, no Section 3 trap. We left it wide because:

  • The function template includes a cart.delivery-options.discounts.generate.run target. We may eventually emit SHIPPING-class discounts there.
  • The webhook already flips orderDiscounts: true on every code. Marketing never sees the extra checkbox. The trap is a non-issue in practice as long as nobody hand-edits a code’s Combinations panel.
  • Narrowing the manifest after the fact requires calling discountAutomaticAppUpdate on every live discount node — feasible, but on a Plus store with hundreds of historical automatic discounts, it’s not zero risk.

Net: the activation payload as written is intentional, not a leftover.

No replace on the cross-tag list. The Admin API offers add and remove on ProductDiscountsWithTagsOnSameCartLineInput. We never replace. The reason: another app could have written its own cross-tag onto the merchant’s discount. Replacing would nuke their integration. Add-only is safe.

8. The cron — twice daily, in-process

// app/services/cronService.ts
import cron from "node-cron"

export function startCronJobs() {
  if (process.env.NODE_ENV !== "production") return

  cron.schedule("0 */12 * * *", async () => {
    await triggerDiscountStackingCron()
  })
}

async function triggerDiscountStackingCron() {
  const url = `${process.env.SHOPIFY_APP_URL}/internal/discount-stacking-cron`
  await fetch(url, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.INTERNAL_API_KEY}` },
  })
}

The cron exists only to catch the long tail: webhook deliveries Shopify gave up on, codes created via Admin API outside business hours, codes edited manually in the UI Combinations panel by someone who didn’t know the order-discount rule. Twelve-hour cadence is plenty for those.

Three things worth knowing about the cron:

  • It cooperates with the bulk UI button via a module-level bulkStatus flag. If the cron is running and someone clicks “Re-sync all” in the admin page, the UI sees state: running and shows the in-progress banner instead of starting a duplicate pass.
  • It runs only in NODE_ENV === "production". Staging cron-runs were noisy and competed with manual test edits, so we shut them off and rely on webhook + manual triggers there.
  • It calls an authenticated internal endpoint rather than running the work directly inside the cron callback. That keeps the actual workload behind the same auth + logging path as the UI button — easier to debug, easier to trigger manually with curl.

9. The merchant-facing UI

Embedded admin page at /app/discount-stacking. Default view shows the 10 most-recently-created code discounts. Each row carries:

  • a green Stackable badge or a grey Not stackable badge
  • the tag/cross-tag/booleans state at a glance
  • a one-click Make stackable / Disable stacking toggle
  • a server-side search (returns up to 25) for older codes not in the recent 10

Why only 10 rows by default: a Plus store can have 600+ code discounts. Loading them all per page visit is wasteful when the webhook and cron already handle the catalog automatically. The page exists for spot-checks, not for batch work.

For batch work there’s Re-sync all (background). Click it, the route returns immediately with "Sync started in background", the work continues serially in the Node process after the HTTP response. A module-level counter exposes progress on refresh. On a 600-code store the run takes 3–5 minutes.

The button refuses to start a second run while one is in flight (cron-aware). The state lives in memory only, so dyno cycling will lose progress — acceptable because the cron picks back up within 12 hours, and the user can re-trigger from the UI.

10. What stacks and what doesn’t

Discount typeStacks on same line with app discount?
Code: Amount off products (percentage)Yes
Code: Amount off products (fixed, per-item)Yes
Code: Amount off products (fixed, once per order)Yes — engine still combines on the line
Code: Amount off orderYes — same DiscountCodeBasic type; webhook tags it
Code: Buy X get Y (Bxgy)No — different class
Code: Free shippingNo — different class, separate calculation
Automatic: app function discountAlways present on qualifying lines

We initially tried filtering Bxgy and free-shipping codes out and filtering down to specific DiscountCodeBasic value types (only percentage, only per-item fixed). Real-store testing showed every DiscountCodeBasic variant combines on a cart line as long as the class-permission booleans line up. We removed the value-type filter; the only filter left is “is the codeDiscount typename DiscountCodeBasic?”

11. Troubleshooting checklist (the one we actually use)

When a stacking issue gets reported, run through this in order. Most reports stop at step 2 or 3.

# 1. Plus check — the entire feature is Plus-gated
{ shop { plan { shopifyPlus } } }

# 2. Code-side state
{
  codeDiscountNodeByCode(code: "YOUR_CODE") {
    codeDiscount {
      ... on DiscountCodeBasic {
        tags
        combinesWith {
          productDiscounts
          orderDiscounts
          shippingDiscounts
          productDiscountsWithTagsOnSameCartLine
        }
      }
    }
  }
}

Required state on the code:

  • tags contains "myshop-stackable"
  • combinesWith.productDiscountsWithTagsOnSameCartLine contains "myshop-app-discount"
  • combinesWith.productDiscounts: true
  • combinesWith.orderDiscounts: true

shippingDiscounts can be either value; it doesn’t matter for cart-line stacking.

# 3. App-side state
{
  discountNodes(first: 50, query: "method:automatic") {
    edges {
      node {
        id
        discount {
          __typename
          ... on DiscountAutomaticApp {
            title
            status
            tags
            discountClasses
            combinesWith {
              productDiscounts
              productDiscountsWithTagsOnSameCartLine
            }
          }
        }
      }
    }
  }
}

Required state on the app discount:

  • status: "ACTIVE"
  • tags contains "myshop-app-discount"
  • combinesWith.productDiscounts: true
  • combinesWith.productDiscountsWithTagsOnSameCartLine contains "myshop-stackable"

If the app discount has lost its config (rare — usually only if the merchant manually edited it), re-activate the function from /app/functions. That deletes and recreates the discount node with the correct payload.

Two specific symptoms and what they almost always mean:

  • “The code looks correct at checkout but Shopify still picks only one discount.” Almost always step 2’s orderDiscounts is false. Click Make stackable on the row in /app/discount-stacking, or just edit and save the code in admin to re-fire the webhook.
  • INVALID_PRODUCT_DISCOUNTS_FALSE_WITH_EXISTING_TAGS_ON_SAME_CART_LINE from my own mutation.” You sent a combinesWith block that left productDiscounts: false on a discount that still has cross-tags. Include productDiscounts: true whenever your mutation touches combinesWith.

12. Operational takeaways

If you’re building a similar same-line stacking layer for your own Shopify Plus app:

  • Be explicit about productDiscounts: true and orderDiscounts: true every time you write combinesWith. Defaults are false; omission resets them. The orderDiscounts: true requirement is undocumented but real when your app discount’s discountClasses includes "ORDER".
  • Treat tags as the single source of truth for opt-out. The cross-tag and the booleans are derived state. If the merchant removes the tag, strip the rest. Simpler than carrying a separate “is stacking enabled” flag in your own database.
  • Add echo suppression on every webhook handler that mutates the resource it watches. A 10-second per-id dedup window is enough to swallow Shopify’s eventual-consistency follow-up read. Back it with Redis when you go multi-dyno.
  • Always have a cron backstop. Webhook delivery is at-least-once with eventual give-up. Twelve hours is fine for sync this small; tighter only if your business actually requires it.
  • Never replace the cross-tag list — use { add: [...] } / { remove: [...] }. Other apps may have written their tags too. Replacing nukes their work and they will not appreciate it.
  • Decide early whether to narrow discountClasses on the app side or compensate on the code side. Narrowing avoids the orderDiscounts: true requirement entirely but requires updating every live discount node. Compensating in the sync layer (what we did) is cheaper to ship and invisible to merchants.

The migration off Shopify Scripts forced a lot of rewrites — see Migrating Shopify Scripts to Functions: What Actually Breaks for the rest of it. Same-line discount stacking is one of the few cases where the new API actually does something the old one couldn’t. Worth using. Just budget a day for the orderDiscounts trap.

aharonyan
aharonyan
Articles: 3

Newsletter Updates

Enter your email address below and subscribe to our newsletter

Leave a Reply

Your email address will not be published. Required fields are marked *