How to limit a WooCommerce product to one per customer (the right way)
If you've ever tried to enforce a "one per customer" rule on a WooCommerce limited drop, you've probably discovered that WooCommerce's built-in tools look like they do this — and then someone checks out twice in five minutes and you find out they don't.
This post explains exactly where the gap is, and walks through three solutions, ranked from quick-hack to production-grade. We'll also cover the parts most guides miss: guest checkout, variations, refunds, and HPOS.
What WooCommerce actually does out of the box
Edit any product in WooCommerce and open the Inventory tab. You'll see a field called Sold individually. Toggling it limits a customer to one of that product per order. That's the entire mechanism.
If you have the official Min/Max Quantities extension installed, you get a "Maximum quantity" field too. Same scope: per order.
So a determined customer who wants two of your limited-edition shirt just opens two browser tabs, or checks out once and comes back the next day, or refreshes the page and orders again. Nothing in core stops them.
Why this is a real problem (and not just a theoretical one)
Three patterns show up in nearly every drop we've helped customers run:
- Honest re-order. A customer thinks the first order failed, tries again, both succeed. Now you have to manually refund and apologize.
- "Just one more." A collector buys one, decides they want a backup, places a second order. You either let them keep both (and disappoint the next ten customers) or refund one (and make an enemy).
- Reseller scoop. A handful of resellers spin up multiple browser sessions, multiple emails, multiple cards, and buy 5–10 each in the first sixty seconds. By the time your real customers see the product, it's sold out.
Pattern #1 and #2 are stoppable with a per-customer lifetime limit. Pattern #3 needs more than that — we'll cover what's actually possible later.
The three solutions, ranked
Solution 1: WooCommerce "Sold individually" toggle
What it does: Caps the cart at quantity 1 for that product.
What it doesn't do: Stop the same customer from placing a second order.
When to use it: When you genuinely only care about per-cart limits, and you're OK letting the same customer come back later for more.
Time to set up: 10 seconds.
Solution 2: A custom code snippet
You can write a snippet that hooks into woocommerce_add_to_cart_validation, queries the customer's previous orders for the same product, and blocks the add-to-cart if they've already bought it.
The basic shape looks like this:
add_filter( 'woocommerce_add_to_cart_validation', function( $passed, $product_id, $quantity ) {
if ( ! is_user_logged_in() ) return $passed;
$customer_id = get_current_user_id();
$orders = wc_get_orders( array(
'customer_id' => $customer_id,
'status' => array( 'completed', 'processing', 'on-hold' ),
'limit' => -1,
) );
foreach ( $orders as $order ) {
foreach ( $order->get_items() as $item ) {
if ( (int) $item->get_product_id() === (int) $product_id ) {
wc_add_notice( 'You have already purchased this limited product.', 'error' );
return false;
}
}
}
return $passed;
}, 10, 3 );
This almost works. The problems show up when you put it in production:
- It doesn't catch guest customers. Guests don't have a
customer_id. You need to wait until checkout and read the billing email. - It runs on every product page load.
limit => -1means "every order this customer has ever placed." On a store with thousands of orders per customer, that's a slow query running on a public page. - It doesn't check checkout. A customer can add to cart in another session before your snippet was deployed and then check out after.
- It doesn't handle variations. If you sell a variable product, the snippet only matches the parent product ID; a customer who bought "Red" can still buy "Blue."
- It doesn't handle HPOS.
wc_get_orders()is the right API for HPOS, but a lot of snippets you'll find online useget_posts()directly, which silently breaks on HPOS-enabled stores. - It has no admin bypass. When you try to manually place an order for a VIP customer, you get blocked.
When to use it: One product, one launch, you'll personally babysit it, and you're going to delete the snippet after. Fine for a hackathon, not fine for a real store.
Solution 3: A dedicated plugin
This is what we built DropLock for. The plugin version handles all six problems above and adds a few things you'd otherwise build yourself:
- Validation at three points: add-to-cart, cart quantity update, and checkout (using the submitted billing email, so guests are covered).
- Order lookups are scoped to that customer and that product, and cached per request, so a store with 100k orders doesn't slow down the cart page.
- Variations roll up to their parent product automatically — one limit, all variations.
- HPOS-compatible because we use
wc_get_orders()end to end. - Users with
manage_woocommercebypass automatically — you can still place manual orders. - A blocked-attempt log so you can see who's hitting the wall, useful for follow-up emails and reseller pattern detection.
- Per-product custom message and product page badge.
DropLock for WooCommerce
One toggle per product, then it does its job quietly. Works with HPOS, Block Checkout, and classic checkout. 14-day refund.
See pricing — from $49What about guest checkout?
This is where most snippet-based solutions fall over. Guests don't have a user ID. At add-to-cart we don't know who they are. The only reliable signal is the billing email they enter at checkout.
The correct pattern is two-phase:
- At add-to-cart, allow the item into the cart (no signal to validate against).
- At checkout, hook
woocommerce_after_checkout_validation, read the submitted billing email, query previous orders matching that email, and block order creation if the limit is exceeded.
Important: the block must happen before the order is created. Otherwise you charge the customer's card, decrement stock, and then have to reverse it — which is worse than not blocking at all.
What about determined resellers?
If your problem is a reseller running ten browsers with ten emails and ten cards, a per-customer limit alone won't stop them. They can always create a new email. The honest answer:
- Per-customer limits handle the 80% case (honest re-orders and casual second purchases).
- Cloudflare Bot Management or hCaptcha Enterprise at the checkout step handles the bot-driven version.
- Manual review of orders placed within the first 60 seconds of a drop, looking for suspicious billing address or payment patterns, is what most serious drop stores actually do.
- Bait-and-cancel (oversell intentionally, then cancel reseller-looking orders) is a tactic some collectible stores use. Risky, but effective if you're willing to take the support load.
No software stops a state-level adversary. Software lowers the floor.
What about variations?
Default expectation: if a customer buys "Red," they can't also buy "Blue" of the same limited product. That requires the limit to live on the parent product and the lookup to count any variation of that parent as the same product.
If you actually want per-variation limits (each color has its own quota), that's a separate use case. In DropLock, parent-level rollup is the default; per-variation limits are available in DropLock Pro 1.1.
What about refunds?
A refunded order doesn't change status by default — it stays "Completed" with a refund record attached. So if you refund a customer, they'll still be blocked from re-buying.
The simplest fix: change the order status to Cancelled after refunding. That removes it from the counted set and frees the customer's allowance.
The more "correct" fix (treating refunds as a release-of-allowance) is more nuanced — partial refunds, partial cancellations, line-item-level refunds — and we deliberately defer that to a Pro feature rather than try to guess your intent.
What about WooCommerce HPOS?
If you've enabled High-Performance Order Storage, orders no longer live in wp_posts. Any code that queries orders via get_posts() or WP_Query with post_type => 'shop_order' silently returns no results.
The fix is to use wc_get_orders() exclusively (the modern WC abstraction) and to declare HPOS compatibility in your plugin's main file via the FeaturesUtil API. If you're using snippets, audit them for direct post-type queries before flipping HPOS on a live store.
Quick checklist before your next drop
- Per-customer limit is enforced at both add-to-cart and checkout.
- Guest customers are handled (checkout email matching).
- Variations roll up to the parent product (or you've decided per-variation is what you want).
- Admin and shop manager bypass works — test by trying to place a manual order.
- Refund behavior matches what you want. Test by refunding an order and trying to re-buy.
- The block happens before the order is created. Test with a real card if you can.
- HPOS compatibility tested if your store uses HPOS.
- You have a clear, on-brand error message. Default WooCommerce messages look like a bug.
Wrapping up
The built-in WooCommerce tools cover one specific case (one per cart). Everything beyond that — lifetime limits, guest matching, variation rollup, HPOS, admin bypass, logs — you either build yourself or use a plugin built for it.
If you're running one launch, write the snippet. If you're running drops as a real part of your business, the time saved on the third one pays for the plugin.
Try DropLock on your next drop
One toggle per product. Lifetime per-customer limits. Guest checkout, HPOS, and Block Checkout supported out of the box. 14-day refund, no questions asked.
Get DropLock — from $49