WooCommerce Request a Quote Without a Plugin: Quotes as Orders

You can build a WooCommerce request a quote flow without a plugin, using three tools you probably already have. A JetEngine Data Store holds the quote list, a JetFormBuilder form sends it, and a short Call Hook snippet turns each request into a WooCommerce order with the status Pending payment. You answer the quote on the normal order screen. Set the prices, click Send order details to customer, and the customer pays through WooCommerce's own payment link.
- Why a quote should be a WooCommerce order
- What you need
- Step 1: the quote list
- Step 2: the quote page
- Step 3: the form and its actions
- Step 4: the snippet
- Step 5: answering a quote
- Turning the catalogue into a shop later
- Troubleshooting
Why a quote should be a WooCommerce order
Most quote plugins keep requests in their own quotes screen, so you answer in one place and track payment in another. Here each request is a WooCommerce order from the start, so pricing, payment and fulfilment all happen on one record.
A quote plugin is still the better choice if you need quote expiry dates, numbered PDF quotes or customer counter-offers.
What you need
- WooCommerce with your products and at least one payment method turned on, because customers pay quotes through it.
- JetEngine with the Data Stores module active.
- JetFormBuilder.
- Elementor. The Data Store Button also exists for the block editor and Bricks, so the same steps work there.
- The free Data Store Actions add-on from the Crocoblock Free DevTools Kit page, installed under WordPress Dashboard > Plugins > Add New. It adds a Clear Data Store post-submit action to JetFormBuilder.
Step 1: the quote list
- Go to JetEngine > JetEngine > Data Stores and add a store named Quote with the slug
quote. The snippet reads this slug. - Choose a store type the server can read, such as Cookies or Session, and confirm it with the logged-out test in Test it below before going live. The snippet reads the list on the server during the form submission. Local Storage lives only in the visitor's browser, and User Metadata works only for logged-in users, so neither suits guest quotes. See the Data Store feature page for the types.
- Leave the Quote store non-countable. The snippet expects a plain list of product IDs.
- In your Elementor product templates (single product and the product listing item), place a Data Store Button where Add to Cart would go. Connect it to the Quote store and label it, for example Add to quote and In your quote.
Removing the cart button from your templates doesn't close the shop: priced products can still reach the cart through WooCommerce's default pages or an ?add-to-cart= URL. While you run quotes only, leave products unpriced or restrict purchasing another way, and test that nothing reaches checkout.
Step 2: the quote page
- Create a Request a quote page with a Listing Grid of products whose query shows only the items in the Quote store. Check which query options your JetEngine version offers in the Query Builder overview.
- In the listing item, add a Data Store Button that removes the product from the store.
- On the Listing Grid's Advanced tab, set a CSS ID such as
quote-list. - Put the JetFormBuilder form under the grid. JetEngine Dynamic Visibility can hide it when the list is empty, but it works only with the Page Reload submit type, so with AJAX it takes effect on the next page load. The real guard is the snippet's empty-list check, which fails the form and creates nothing.
Step 3: the form and its actions
Create a form with these fields. The snippet reads them by these exact names:
quote_first_nameandquote_last_name: Text, requiredquote_email: Text with the email type, requiredquote_phoneandquote_company: Textquote_message: Textarea, labelled to ask for quantities, variations and options
For drawings or spec sheets, add an upload field, see JetFormBuilder drag and drop file upload. Before going live, turn on the form's captcha or add a honeypot field: every valid submission creates an order, so spam would fill your order list.
Set the Submit Type to AJAX and add the post-submit actions in this order:
- Call Hook, Hook Name
quote-request. This action runs the snippet. - Clear Data Store: Data store slug
quote; Listing to update:quote-list(several CSS IDs can be separated by commas). It must come after Call Hook, or the list is empty when the snippet reads it. See how to clear a Data Store with a form action. - Send Email to the store owner, and a second Send Email to
quote_emailconfirming receipt.
If the snippet can't create the order, it makes the form fail, so the later actions don't run: the visitor keeps the list and gets no confirmation email. For a nicer confirmation on success, use a custom success message popup.
Step 4: the snippet
The snippet reads the list with the JetEngine Data Store API and builds the order with standard WooCommerce order functions, never update_post_meta or SQL, so it works with High-Performance Order Storage. Add it to your child theme's functions.php file or a code snippets plugin, on a staging copy first. It has no opening <?php tag: in functions.php, paste it below the existing tag (add <?php only if the file is empty, and no closing ?>); in a snippets plugin that adds the tag for you, paste it as is.
Code
/**
* Turn a JetFormBuilder quote request into a WooCommerce "Pending payment" order.
*
* Form: Call Hook with Hook Name: quote-request (before Clear Data Store)
* Data Store: slug "quote", a type the server can read (test Cookies or Session), not countable
* Fields: quote_first_name, quote_last_name, quote_email, quote_phone, quote_company, quote_message
*/
add_action( 'jet-form-builder/custom-action/quote-request', 'bmc_quote_request_to_order', 10, 2 );
/**
* Log the reason (with the product IDs once known) and make the form fail.
* Throwing stops the later actions, so the quote list is not cleared
* and no confirmation email is sent.
*/
function bmc_quote_request_fail( $reason, $ids = array() ) {
$line = 'quote-request: ' . $reason;
if ( ! empty( $ids ) ) {
$line .= ' Items: ' . implode( ',', $ids );
}
error_log( $line );
throw new \Jet_Form_Builder\Exceptions\Action_Exception( 'failed' );
}
function bmc_quote_request_to_order( $request, $action_handler ) {
$store_slug = 'quote';
$max_items = 50;
// Stop if WooCommerce or the JetEngine Data Stores module is not available.
if ( ! function_exists( 'wc_create_order' ) || ! function_exists( 'jet_engine' ) ) {
bmc_quote_request_fail( 'WooCommerce or JetEngine is not active.' );
}
if ( ! jet_engine()->modules->is_module_active( 'data-stores' ) ) {
bmc_quote_request_fail( 'the JetEngine Data Stores module is off.' );
}
// Read the quote list on the server, not from a form field.
$store_factory = \Jet_Engine\Modules\Data_Stores\Module::instance()->stores->get_store( $store_slug );
if ( ! $store_factory ) {
bmc_quote_request_fail( 'no Data Store with the slug ' . $store_slug . '.' );
}
$items = $store_factory->get_type()->get( $store_slug );
if ( empty( $items ) || ! is_array( $items ) ) {
bmc_quote_request_fail( 'the quote list is empty or cannot be read on the server.' );
}
// The store is non-countable, so the values are product IDs.
// Reindex in case the keys have gaps after a visitor removed an item.
$items = array_values( $items );
// Validate IDs: integers only, no zeros, no duplicates, capped.
$ids = array_slice( array_unique( array_filter( array_map( 'absint', $items ) ) ), 0, $max_items );
// Keep only published products. Variations and other post types are skipped.
$products = array();
foreach ( $ids as $id ) {
if ( 'product' !== get_post_type( $id ) ) {
continue;
}
$product = wc_get_product( $id );
if ( $product && 'publish' === $product->get_status() ) {
$products[] = $product;
}
}
if ( empty( $products ) ) {
bmc_quote_request_fail( 'no published products in the quote list.', $ids );
}
// Sanitize the contact fields.
$first = isset( $request['quote_first_name'] ) ? sanitize_text_field( $request['quote_first_name'] ) : '';
$last = isset( $request['quote_last_name'] ) ? sanitize_text_field( $request['quote_last_name'] ) : '';
$email = isset( $request['quote_email'] ) ? sanitize_email( $request['quote_email'] ) : '';
$phone = isset( $request['quote_phone'] ) ? sanitize_text_field( $request['quote_phone'] ) : '';
$company = isset( $request['quote_company'] ) ? sanitize_text_field( $request['quote_company'] ) : '';
$message = isset( $request['quote_message'] ) ? sanitize_textarea_field( $request['quote_message'] ) : '';
// The billing email protects the guest payment link, so it is required.
if ( ! is_email( $email ) ) {
bmc_quote_request_fail( 'missing or invalid email address.', $ids );
}
$order = wc_create_order();
if ( is_wp_error( $order ) ) {
bmc_quote_request_fail( 'wc_create_order failed: ' . $order->get_error_message(), $ids );
}
// Quantity 1 per product. The customer gives real quantities in the message.
$flagged = array();
foreach ( $products as $product ) {
$order->add_product( $product, 1 );
if ( $product->is_type( array( 'variable', 'grouped' ) ) ) {
$flagged[] = $product->get_name();
}
}
$order->set_billing_first_name( $first );
$order->set_billing_last_name( $last );
$order->set_billing_email( $email );
$order->set_billing_phone( $phone );
$order->set_billing_company( $company );
$order->set_customer_note( $message );
// Logged-in visitors get the quote in My Account > Orders. Everyone else is a guest.
if ( is_user_logged_in() ) {
$order->set_customer_id( get_current_user_id() );
}
$order->set_created_via( 'quote_request' );
$order->set_status( 'pending' );
$order->calculate_totals();
$order->save();
// Private notes, visible only to shop staff.
$order->add_order_note( 'Quote request via form.' );
if ( ! empty( $flagged ) ) {
$order->add_order_note( 'Check this line and replace it with the chosen variation or child products: ' . implode( ', ', $flagged ) );
}
}How the code works
- The hook name is the part after
jet-form-builder/custom-action/and must match the Call Hook's Hook Name exactly. Submitted fields arrive in$request, keyed by field name. - Failures are never silent. Each one writes a line starting with
quote-request:to the PHP error log, with the product IDs once the list has been read, then throws JetFormBuilder'sAction_Exceptionso the form fails as described in Step 3. - Product IDs are validated on the server. The code reads the store as a plain list of IDs and reindexes it with
array_values(), so gaps left by removed items don't matter. That is why the store must stay non-countable. With a cookie-based store the visitor controls the stored IDs, so the code keeps only published products (not variations), removes duplicates and caps the list at 50. - Customer assignment happens only for logged-in visitors. The code doesn't match the email to an existing account, because that would lock the order to the account and force a login to pay.
Check how variable and grouped products appear on a test order. The snippet lists them in a private note so you can replace those lines with the chosen variation or child products.
To send visitors to a thank-you page, the Call Hook example by MjHead shows a redirect through $action_handler->response_data['redirect'].
Test it
- Log out, add three products to the quote list, remove the middle one and send the form.
- In WooCommerce > Orders you should see a new Pending payment order with the two remaining products, the billing details, the message and the note Quote request via form. The quote list on the page should be empty.
- Repeat logged in as a test customer. The order should appear under My Account > Orders with a Pay button.
- Test the failure path: temporarily change
$store_slugin the snippet to a slug that doesn't exist and send the form. It should show an error, keep the list and send no confirmation email. Change the slug back.
Step 5: answering a quote in WooCommerce > Orders
- Open the order and keep it in Pending payment. Items, fees, coupons and shipping can be edited only while an order is Pending payment or On hold (see editing orders). Once it moves past those, create a new order instead.
- Set each line's quantity and total to your quoted price. Use Add item(s) for shipping or a fee, apply a coupon for a discount, then click Recalculate.
- Leave the billing Payment method on All methods available unless the customer must use one specific method, then save the order.
- Choose Order actions > Send order details to customer. WooCommerce emails the order with a link to the payment page; check the wording in your test email. You can also copy the Customer payment page → link and share it yourself.
- Optional: to explain the price, add an order note after the final prices are saved, set its type to the note-to-customer option rather than private, and add it. WooCommerce emails it to the customer, so write it only when the quote is final.
Payment rules from the WooCommerce paying for orders guide:
- Orders assigned to a registered customer can be paid only by that customer after logging in. Anyone else, including an administrator, sees This order cannot be paid for. Please contact us if you need assistance. That is expected, so test as that customer or with a guest order.
- Guest orders ask for the order's email address when the link is opened more than 10 minutes after creation, so the snippet requires it. The
woocommerce_order_email_verification_grace_periodfilter changes the time; leave the check itself on. - Guest orders can become account orders. With WooCommerce's account-confirmation flow (WooCommerce 11.0 and later), past guest orders with the same billing email are linked to the customer's account once they confirm their email in My Account. After that, they must log in to pay.
- One payment per order. For extra items after payment, create a new order.
- A new order saves the store's current Prices entered with tax setting. Changing it later doesn't change existing quotes.
Turning the catalogue into a shop later
The products, customers and past orders stay as they are. To open a normal shop:
- Add regular prices to the products.
- In the product templates, replace the Data Store Button with Add to Cart, or show both to keep quotes running next to the cart.
- Check your payment methods for checkout.
Quote orders stay identifiable through their created-via value, quote_request.
Troubleshooting
- The form shows an error after sending. Check the debug log (wp-content/debug.log with WP_DEBUG_LOG on, or your server's PHP error log) for a
quote-request:line naming the reason and the product IDs. If it says the list is empty or unreadable, try a server-readable store type such as Cookies or Session and repeat the logged-out test. - The form reports success but no order appears. The Hook Name doesn't match
quote-request, Call Hook isn't in the form, or the snippet isn't active. It belongs in functions.php or a snippets plugin, not a file or folder named after the hook. - The list still shows products after sending. Recheck the action order and the Listing to update setting from Step 3.
- Long quote windows. Leave a test quote unpaid past your Hold stock window and confirm it isn't cancelled before you rely on it.
- Unpriced products. Send a guest test quote for an unpriced product, price it, and complete a payment on staging before going live.
Each quote is now a real WooCommerce order that you answer on the screen you already use. For another JetFormBuilder build tied to listings, see our Crocoblock listing website front-end submission guide. If you'd rather have the quote flow built and tested for you, post a request on BoostMyCroco.
Frequently Asked Questions
Does a customer need an account to pay a WooCommerce quote?
Why does the WooCommerce pay for order page ask for an email address?
Can I let customers set a quantity for each product in the quote list?
What happens if the customer wants to add products after paying the quote?
Will this work if I later install a quote plugin anyway?
This guide answers a question from our community: Request Quote Feature
Sources
- JetEngine Data Store for WordPress. Data Storage Feature (crocoblock.com)
- Data Store Button Widget for Elementor, Gutenberg and Bricks (crocoblock.com)
- Query Builder Overview — JetEngine (crocoblock.com)
- JetFormBuilder: Call Hook Action Overview (jetformbuilder.com)
- How to Clear Data Store With Form Action | Crocoblock (jetformbuilder.com)
- JetEngine Data Store API · GitHub (gist.github.com)
- JetFormBuilder. Example of custom hook with API request and redirect. · GitHub (gist.github.com)
- Single Order Page and Manually Adding an Order Documentation - WooCommerce (woocommerce.com)
- Paying for Orders Documentation - WooCommerce (woocommerce.com)
- Request a Quote for Price - WooCommerce Marketplace (woocommerce.com)
- How to Add WooCommerce Request a Quote (woocommerce.com)
- How to Set Up Request a Quote in WooCommerce: A Complete Guide (webtoffee.com)

