Need help with automatic bundle product addition in Shopify cart

I’m working on a Shopify store using the Horizon theme and need to create an automatic product pairing system. Here’s what I have:

Service Membership (main product)

  • Basic Package (variant)
  • Premium Package (variant)
  • Trial Package (variant)

Deposit (secondary product)

  • Refundable (Basic) (variant)
  • Refundable (Premium) (variant)
  • Refundable (Trial) (variant)

I want to automatically add the matching Deposit variant when someone adds a Service Membership variant to their cart. For example, when Basic Package is added, it should automatically include the Basic Deposit variant.

I also want to hide the remove button for the deposit item and make it so removing the main membership automatically removes the paired deposit.

Here’s my current code for the product page:

document.addEventListener("DOMContentLoaded", function () {
  const depositMapping = {
    46531128230137: 46586197672185, // Basic
    46531128262905: 46586197704953, // Premium
    46569399288057: 46586197737721, // Trial
  };

  const addForm = document.querySelector('form[action^="/cart/add"]');
  if (!addForm) return;
  
  const variantSelect = addForm.querySelector('input[name="id"]');
  if (!variantSelect) return;

  addForm.addEventListener("submit", function (event) {
    const chosenVariant = parseInt(variantSelect.value);
    const depositVariant = depositMapping[chosenVariant];
    
    if (!depositVariant) return;

    event.preventDefault();
    
    fetch("/cart/add.js", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        items: [{ id: chosenVariant, quantity: 1 }]
      }),
    })
    .then(response => response.json())
    .then(result => {
      return fetch("/cart/add.js", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          items: [{ id: depositVariant, quantity: 1 }]
        }),
      });
    })
    .then(response => response.json())
    .then(result => {
      window.location.href = '/cart';
    })
    .catch(error => {
      console.error("Error adding products:", error);
      addForm.submit();
    });
  });
});

And for the cart page:

{% if item.product.tags contains 'Membership' %}
  <script>
    document.addEventListener("DOMContentLoaded", function () {
      const depositMapping = {
        46531128230137: 46586197672185, // Basic
        46531128262905: 46586197704953, // Premium
        46569399288057: 46586197737721, // Trial
      };

      function findItemKey(cartData, variantId) {
        const foundItem = cartData.find(item => item.variant_id === variantId);
        return foundItem ? foundItem.key : null;
      }

      function cleanupOrphanedDeposits() {
        fetch("/cart.js")
          .then((response) => response.json())
          .then((cartData) => {
            const cartItems = cartData.items;
            const presentVariants = cartItems.map((item) => item.variant_id);

            Object.entries(depositMapping).forEach(([membershipVariant, depositVariant]) => {
              const membershipExists = presentVariants.includes(parseInt(membershipVariant));
              const depositExists = presentVariants.includes(depositVariant);

              if (!membershipExists && depositExists) {
                const depositKey = findItemKey(cartItems, depositVariant);
                if (depositKey) {
                  fetch("/cart/change.js", {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({
                      id: depositKey,
                      quantity: 0,
                    }),
                  })
                  .then(() => {
                    window.location.reload();
                  })
                  .catch(error => console.error("Failed to remove deposit:", error));
                }
              }
            });
          })
          .catch(error => console.error("Failed to fetch cart data:", error));
      }

      document.addEventListener('click', function(event) {
        if (event.target.closest('[on\\:click^="/onLineItemRemove"]')) {
          setTimeout(cleanupOrphanedDeposits, 1500);
        }
      });
    });
  </script>
{% endif %}

The issue is that sometimes only the deposit gets added to the cart, not both products. Any ideas on how to fix this?

This is probably a timing issue with your fetch requests. When you fire off two API calls back-to-back, the second one can fail if the first hasn’t finished yet - especially during busy periods. I ran into the same thing with bundled products and fixed it by using one cart/add.js request with multiple items in the array. Instead of two separate fetch calls, send both products at once: fetch(“/cart/add.js”, { method: “POST”, headers: { “Content-Type”: “application/json” }, body: JSON.stringify({ items: [ { id: chosenVariant, quantity: 1 }, { id: depositVariant, quantity: 1 } ] }) }); This kills the race condition and makes sure both products get added together. I’ve used this for six months now - no more partial additions.

Your error handling logic might be the issue. When the first fetch works but the second fails, you’re calling addForm.submit() which only adds the membership product - not the deposit. I’ve hit this same problem. Network timeouts or server hiccups during that second API call leave you with incomplete bundles. Try bundling both products in one request like the previous answer suggested, but add proper error recovery too. If the bundled addition completely fails, fall back to adding just the membership and show a notification to manually add the deposit. Also check your server logs - Shopify’s API sometimes returns success codes even when items don’t actually get added due to inventory issues or other constraints. Your cart cleanup logic looks solid though. That part should work fine once you get the initial addition more reliable.

Check your browser’s network tab during the add process - you’re probably getting intermittent 422 errors on the second request. Same thing happened to me with product bundles. Turns out it’s a cart state sync issue.

Shopify doesn’t update the cart state right away after adding the first product. When the second fetch fires, it conflicts with the pending cart update. Don’t chain promises - refetch the cart state between additions or poll to verify the first item actually got added before hitting the second one.

Also check if your theme has other cart scripts running that might mess with your timing. Theme updates love to introduce conflicting JavaScript that breaks custom cart logic.

Add a small delay between your fetch calls instead of chaining them back-to-back. Shopify’s cart API sometimes needs a moment to process the first item before it’ll accept the second one. I had the same problem and a 200ms setTimeout before the second fetch fixed it. Also check that your variant IDs are still right - Shopify changes these when you update products and it’ll fail silently.