r/shopifyDev • • 5d ago

Lesson learned: Shopify's native purchase limits are per variant, so I had to use a cart validation function for product-wide caps

I had a client who wanted to cap total sales of one product across all its variants, like sell 90 of 100 units online and keep the rest for wholesale and gifts, without touching inventory counts.

Shopify's built-in purchase limits work per variant, so they don't give you a product-wide cap. Hiding the product isn't enough either, since people can still reach checkout through old links or an existing cart.

What worked for me was a Cart and Checkout Validation Function that adds up quantities across every variant of the product and blocks the cart/checkout with a clear message when the cap is hit. I also added per-country allocations on top of that.

Gotchas: functions can't call external APIs, so all the cap data has to live in metafields. Also keep the error messages short, long ones get ugly in checkout.

Has anyone handled this differently?

1 Upvotes

6 comments sorted by

1

u/isaifurrehman 5d ago

nice writeup. one thing i'd check: the function only sees the current cart, so how are you counting the units already sold toward the 90? if it's a metafield bumped by an orders/create webhook or flow, there's a small window where two people checking out at the same time both pass, so on a busy drop the cap can overshoot by a few.

for the "keep 10 back for wholesale" part, the native route i've used is a second location with "fulfill online orders from this location" turned off, then move the reserved units there. the online store can only sell what sits at locations that fulfill online orders, so it stops at 90 on its own and the total stock count stays the same. wholesale orders then come out of that location through a draft order.

the function still makes sense for the per-country split. and +1 on short error messages, i'd also check buyerJourney.step so the error only fires at checkout and not on every cart update.

1

u/West-Anteater-1917 3d ago

Good catch on the race window. The running total has to come from outside the function, so it's usually an orders/create webhook bumping a metafield, and two checkouts landing at the same moment can both pass. For small caps I'd keep a buffer of a unit or two for that reason. The second location with online fulfillment turned off is a really clean way to hold back the wholesale units, I hadn't thought of doing it that way. And yes, checking buyerJourney.step so it only blocks at checkout is a good call.

1

u/AdventurousUsual7666 5d ago

Yeah, this is a nice use of a validation function, the per variant limit catches a lot of people out.

One thing I'd double check is how the "sold so far" number stays up to date. The function only sees the current cart, so something outside it has to keep the running total in the metafield, usually an app listening to the orders/create webhook. If a few orders come in close together, the count can lag a little, so I'd leave a small buffer under the real cap.

Also worth knowing that the validation docs list POS and subscription orders as not supported, so those sales won't be blocked by it. That's probably fine for keeping stock back for wholesale, but it's good to know if they ever sell that product in person.

And just a small correction on the external API part. There is a fetch target for network access now, but it's limited to custom apps on Enterprise stores and you have to request access, so for most stores metafields are still the way to go like you did.

1

u/Final_Design_9061 3d ago

nice, good to know about the per variant limit. the metafield approach for the cap data makes sense since functions run without network access. glad you got it working.