How to Add a Request a Quote Flow to WooCommerce
A complete WooCommerce request a quote flow: replace add-to-cart, capture the enquiry as a real order, and reply with a price the customer can accept and pay.
You sell things that do not have one fixed price. Maybe the price depends on volume, on the customer’s contract, on shipping a pallet to a specific postcode, or on a spec you have to read before you can answer. So the Buy button is wrong, and you want a WooCommerce request a quote flow instead: the customer tells you what they want, you work out a number, and they pay it.
Most shops get halfway there. They drop a contact form on a page, the enquiries arrive as e-mail, and then the whole thing falls apart in the follow-up — nobody can remember which SKU the customer meant, the quoted price lives in a sent-items folder, and when the customer says yes there is no order to pay for. This article walks the full loop end to end, explains where each common approach leaks context, and shows why a quote is best stored as a native WooCommerce order rather than as a row in some plugin’s private table.
What a WooCommerce request a quote flow actually needs#
Before choosing tools, be honest about the shape of the process. A working quote loop has five steps, and skipping any one of them creates manual work somewhere else:
- Suppress the price. If the product page still shows £0.00 or a stale figure, the customer anchors on it and your quote looks like a markup. This has to happen server-side, everywhere the price is rendered.
- Replace the action. Add to cart has to become something else — an enquiry button that carries the product identity with it, so you never have to ask “which model were you looking at?”
- Capture the request where you can work it. Somewhere with a status, an owner, a timestamp, and a place to write notes. An inbox is not that place.
- Reply with a price. Per line item, ideally, with an expiry date so an old quote does not come back to haunt you six months later.
- Let them accept and pay. The step everyone forgets. If accepting means the customer e-mails “yes please” and you then build an order by hand, you have automated the easy half.
Steps one and two are pure WooCommerce and you can do them with a snippet today. Steps three to five are where the design decision lives.
Step one: hide the price and remove add to cart#
WooCommerce renders prices through a small number of filters, and add-to-cart through template hooks. For a shop-wide quote flow on a classic theme, this is genuinely a few lines:
add_filter( 'woocommerce_get_price_html', function ( $html, $product ) {
return '<span class="price-on-request">Price on request</span>';
}, 100, 2 );
add_filter( 'woocommerce_is_purchasable', '__return_false' );
remove_action( 'woocommerce_after_shop_loop_item', 'woocommerce_template_loop_add_to_cart', 10 );
remove_action( 'woocommerce_single_product_summary', 'woocommerce_template_single_add_to_cart', 30 );Two notes on that snippet, because it is not the whole job. woocommerce_is_purchasable is the important line — it is what makes WC_Cart::add_to_cart() refuse a crafted request to ?add-to-cart=123, whereas removing the template action only takes the button off the page. And on a block theme the remove_action calls do nothing, because the blockified templates render add to cart through their own blocks rather than through woocommerce_after_shop_loop_item. The price filter fares a little better than people expect — the Store API’s price_html field is produced by get_price_html(), so your replacement text does appear there — but the same response also carries a raw prices object with price, regular_price and sale_price in minor units, and no price-HTML filter touches those numbers.
That gap is the single most common way a “hidden” price is still readable. There is a longer treatment of the catalog-mode side of this in WooCommerce catalog mode without a plugin, and of the price side in hiding the Add to Cart button. If you only need to gate prices behind a login rather than run a full quote process, hiding prices until customers log in is a smaller job than what follows.
Step two: choosing how to capture the request#
Here are the three approaches shops actually use to run a WooCommerce request a quote process, judged on the thing that matters — how much context survives from the customer’s click to the moment money changes hands.
| Approach | What it costs you | Reasonable when |
|---|---|---|
| Plain contact form (CF7, Gravity, Fluent) | Product identity is free text. No status, no line items, no price field, no path to payment. Every accepted quote becomes a manually built order. | You get two or three enquiries a month and you know your catalog by heart. |
| Manual e-mail thread | Everything above, plus the record lives in one person’s mailbox. No handover, no reporting, no way to answer “what did we quote them in March?” | Never, past the first month. It feels fastest and ages worst. |
| Quote plugin | Depends entirely on where it stores the request. A plugin with its own private table gives you a second, weaker admin screen. One that writes native orders gives you WooCommerce’s whole toolkit. | Any shop where quoting is a repeating process rather than an occasional favour. |
The contact form option deserves a fair hearing, because it is free and it works. If your form plugin lets you pre-fill a hidden field, you can pass the product ID into it from the button and recover most of the identity problem:
add_action( 'woocommerce_single_product_summary', function () {
global $product;
printf(
'<a class="button" href="%s">Request a quote</a>',
esc_url( add_query_arg( 'product', $product->get_id(), site_url( '/quote-request/' ) ) )
);
}, 30 );Read that product parameter on the target page, look up the name, and drop it into a hidden field. You now have a form submission that names the SKU. What you still do not have is a status you can filter on, a place to record the price you quoted, or a payment link — and that is the part that turns into spreadsheet work as volume grows.
Why a quote belongs in the orders table#
This is the design argument, and it holds regardless of which plugin you pick. A quote and an order are the same object at different stages of its life. Both have a customer, an address, line items with quantities, a total, a history of who did what to it, and a state machine. WooCommerce already ships all of that, tested, translated, and integrated with everything else you have installed.
Store a quote as a WooCommerce order with a custom status and you inherit, for free:
- Search that works. The Orders screen searches customer name, e-mail, address and item names. A custom table gets whatever search box its author found time to write.
- Order notes. Timestamped, attributed, private or customer-facing. This is your audit trail when someone disputes what was agreed, and it is the reason a colleague can pick up a quote you started.
- The e-mail system. Templates, the WooCommerce header and footer, the preview tool, and whatever transactional mail service you already configured.
- HPOS compatibility. High-Performance Order Storage is the indexed orders table modern WooCommerce uses. An object that lives there scales with your order volume and is covered by Woo’s own migration tooling.
- An accept path that is just checkout. When the customer says yes, the order already exists with the right lines and the right totals. It moves to pending payment and goes through your normal gateway. Nothing is rekeyed.
There is one trap, and it is worth stating plainly because it is where naive implementations go wrong. A custom order status does not stay out of your numbers by itself. WooCommerce Analytics reports on every status except the ones you exclude, so a pile of unanswered quotes quietly inflates the revenue chart you use to make decisions. Registering the status is the easy half:
register_post_status( 'wc-quote-request', array(
'label' => 'Quote Request',
'public' => false,
'show_in_admin_status_list' => true,
) );
add_filter( 'wc_order_statuses', function ( $statuses ) {
$statuses['wc-quote-request'] = 'Quote Request';
return $statuses;
} );The other half is exclusion, and it is a setting rather than a hook: in WooCommerce > Settings > Analytics, add the new status to Excluded statuses so reports ignore it. You do not need to touch woocommerce_order_is_paid_statuses — that filter defaults to processing and completed, so a custom status is only ever treated as paid if you add it there yourself. Just make sure nothing in your own code does. This is exactly the behaviour PriceVeil implements: quote requests become native WooCommerce orders with a Quote Request status, HPOS-compatible, with their own filter link in the Orders list, and explicitly excluded from paid statuses and from reports so revenue stays honest.
Step three: replying with a price and getting paid#
Once the request is an order, replying is mostly a question of how much structure you want. At minimum you edit the line item prices in the order screen and send the customer a payment link — WooCommerce generates one for pending orders, keyed on the order key, and it works without an account.
That is a legitimate free workflow and plenty of shops stop there. What it does not give you is the part customers actually respond to: a document that looks like a quote, with an expiry, optional add-ons they can tick, and an obvious accept or decline. PriceVeil Pro adds that layer — a requests console with New / Quote sent / Accepted / Declined tabs where you price each line, add more products without reloading, mark lines required, optional or alternative, set a validity of 7, 14 or 30 days or a fixed date, then send. The customer gets an order-key-authenticated page with a live total that updates as they tick options, an expiry countdown, and Accept and pay, which rebuilds the order and drops them into normal WooCommerce checkout. Pro can also deliver the quote over WhatsApp using an API-free click-to-chat link with a message template you save as your default, which is useful in markets where buyers answer a chat message and ignore e-mail.
Whatever you build, put an expiry on the quote. Material costs move, and a customer producing a nine-month-old quote is an argument you will lose.
Spam protection for a public quote form#
A form that creates a database record on every submission is a target. Treat it as such from day one, because clearing junk orders out of your order table afterwards is far more painful than preventing them. Three cheap layers stop nearly everything:
- A nonce. Ties the submission to a session and a form render. It stops the crudest direct POSTs to your endpoint.
- A honeypot. A field hidden with CSS that a human never sees. Automated submitters fill every input they find; if it has a value, drop the request silently. Silently matters — an error message teaches the bot to try again.
- A per-IP rate limit. The one that does the real work. A transient keyed on the hashed IP, allowing a handful of submissions per hour, caps the damage from anything that gets through the other two.
$key = 'quote_rl_' . md5( $_SERVER['REMOTE_ADDR'] ?? '' );
$hits = (int) get_transient( $key );
if ( $hits >= 5 ) {
wp_send_json_error( array( 'message' => 'Too many requests. Try again later.' ), 429 );
}
set_transient( $key, $hits + 1, HOUR_IN_SECONDS );Note that behind a reverse proxy or Cloudflare, REMOTE_ADDR is the proxy, not the visitor — you will need to read the forwarded header your host sets, and only trust it if your host actually sets it. PriceVeil’s built-in quote form ships all three layers on its [priceveil_form] shortcode and block. Whether you use it or build your own, do not ship a public form without a rate limit.
Resist the urge to reach for a CAPTCHA first. It taxes every legitimate B2B buyer to stop a problem the three layers above already handle, and on a quote form your conversion rate is the whole point.
What this approach does not solve#
A quote flow changes what the storefront shows and what happens after a customer enquires. It does not reach outside WooCommerce, and there are four leaks worth checking on your own site.
- Product feeds. If you run CTX Feed, Google Product Feed or a merchant integration, it reads prices from the database directly and will keep exporting them. No price-hiding plugin can stop that from the inside. Disable the feed yourself.
- Themes that call
get_price()directly. A theme that echoes the raw value instead of going throughget_price_html()bypasses every filter above. Grep your theme; the fix is one line, but you have to find it. - Cached pages. A page cached before you made the change still serves the old price. Purge once after switching on, and remember edge caches are separate from your page cache.
- The other read paths. Prices are also served by the Store API, the REST API, GraphQL and JSON-LD structured data. Filtering
woocommerce_get_price_htmldoes not touch the raw numeric values any of those return. If you want to see how to test all of this properly, we wrote up how we leak-test every WooCommerce plugin; the PriceVeil documentation covers the surfaces it handles, and the plugin’s own settings screen prints a coverage report naming what is protected automatically and what you still have to check yourself.
A quote flow is also a process change, not just a code change. Someone has to answer the requests, and answer them quickly — on a quote, the reply time is most of what the customer is buying.
What to do next#
Start narrow. Pick the products that genuinely need quoting rather than switching the whole catalog — there is a walkthrough of hiding prices for specific products or categories if you want it scoped. Add the price filter and the is_purchasable line from the top of this article, put a button where add-to-cart was, and point it at whatever capture method fits your volume today.
Then check the leaks before you announce it: open a product page in a private window, view source and search for the number, and hit /wp-json/wc/store/v1/products/<id> to see what the Store API returns — both price_html and the prices object. If both are clean, you have a working WooCommerce request a quote flow. When the enquiry volume gets past what one inbox can track, that is the moment to move the requests into the orders table — and it is much easier to do that before you have three hundred of them scattered across e-mail.
