Some catalogues should never end in a payment form. Industrial parts, made-to-order furniture, anything quoted per project: the visitor wants to assemble a list, send it over, and get a price back. WooCommerce already gives you the list-building part for free. What you replace is the ending.
This is the process I use: keep the cart, replace checkout with an enquiry form, store the submission as an order in its own status, notify both sides, and give the admin a place to work the quote.
Step 0 - Decide where the enquiry lives
| Storage | What you get for free | What you have to build |
|---|---|---|
| Order with a custom status | Line items, totals, addresses, order notes, admin screens, customer emails | Nothing structural |
| Custom post type | A clean separate list | Re-implementing line items, totals, and every admin view |
| Custom table | Full control | All of the above, plus migrations |
Use an order with a custom status. You inherit the item snapshot, the currency handling, and the whole admin UI, and it survives plugin updates because you are using the public CRUD methods rather than writing post meta directly.
Step 1 - Two statuses
Enquiry for something new, Quote sent for something you have priced.
add_action('init', function () {
register_post_status('wc-enquiry', [
'label' => 'Enquiry',
'public' => false,
'internal' => false,
'exclude_from_search' => false,
'show_in_admin_all_list' => true,
'show_in_admin_status_list' => true,
'label_count' => _n_noop('Enquiry <span class="count">(%s)</span>', 'Enquiries <span class="count">(%s)</span>'),
]);
register_post_status('wc-quoted', [
'label' => 'Quote sent',
'public' => false,
'internal' => false,
'exclude_from_search' => false,
'show_in_admin_all_list' => true,
'show_in_admin_status_list' => true,
'label_count' => _n_noop('Quote sent <span class="count">(%s)</span>', 'Quotes sent <span class="count">(%s)</span>'),
]);
});
add_filter('wc_order_statuses', function (array $statuses): array {
$out = [];
foreach ($statuses as $slug => $label) {
$out[$slug] = $label;
if ($slug === 'wc-pending') {
$out['wc-enquiry'] = 'Enquiry';
$out['wc-quoted'] = 'Quote sent';
}
}
return $out;
});
Do not reuse pending payment for this. It shows up in revenue reports, in the "awaiting payment" counts, and in every stock and email rule that assumes a payment is coming.
Step 2 - Point every checkout link at the enquiry page
Create a page with the slug request-a-quote, then redirect the checkout URL at the filter level. Every core "proceed to checkout" button - cart page, mini-cart widget, the AJAX fragments - asks wc_get_checkout_url(), so one filter covers them.
add_filter('woocommerce_get_checkout_url', function (string $url): string {
return home_url('/request-a-quote/');
});
// And a hard stop if someone lands on the old checkout page from a bookmark.
add_action('template_redirect', function () {
if (is_checkout() && !is_wc_endpoint_url('order-received')) {
wp_safe_redirect(home_url('/request-a-quote/'), 302);
exit;
}
});
Leave the payment gateways disabled. With no gateway enabled, nothing in the flow can produce a payment link, including the order-details email you will send later.
Step 3 - The form
Fields that earn their place on a B2B enquiry: company, contact name, work email, country, phone (optional), VAT or registration number if you sell cross-border, required-by date, free-text notes, and one file upload for a drawing or spec sheet. Everything else is friction.
Handle the POST on the front end rather than through admin-post.php. On a front-end request the WooCommerce session and cart are loaded, which is exactly what you need to rebuild the order.
add_shortcode('enquiry_form', function (): string {
if (WC()->cart->is_empty()) {
return '<p>Your list is empty. Add products first.</p>';
}
ob_start(); ?>
<form method="post" enctype="multipart/form-data" class="enquiry-form">
<?php wp_nonce_field('aum_enquiry_submit', 'aum_enquiry_nonce'); ?>
<input type="hidden" name="aum_enquiry" value="1">
<p><label>Company *<br><input type="text" name="company" required></label></p>
<p><label>Contact name *<br><input type="text" name="first_name" required></label></p>
<p><label>Work email *<br><input type="email" name="email" required></label></p>
<p><label>Country *<br><input type="text" name="country" required></label></p>
<p><label>VAT / registration number<br><input type="text" name="vat"></label></p>
<p><label>Needed by<br><input type="date" name="needed_by"></label></p>
<p><label>Notes<br><textarea name="notes" rows="5"></textarea></label></p>
<p><label>Spec sheet (PDF or XLSX)<br><input type="file" name="spec" accept=".pdf,.xlsx"></label></p>
<p class="hp" aria-hidden="true"><label>Website<input type="text" name="aum_hp" tabindex="-1" autocomplete="off"></label></p>
<button type="submit">Send enquiry</button>
</form>
<?php return ob_get_clean();
});
.hp { position: absolute; left: -9999px; } in your stylesheet. Bots fill it, humans never see it.
Step 4 - Handle the submission: cart becomes order
add_action('template_redirect', function () {
if (!is_page('request-a-quote') || empty($_POST['aum_enquiry'])) {
return;
}
aum_handle_enquiry();
});
function aum_handle_enquiry(): void
{
check_admin_referer('aum_enquiry_submit', 'aum_enquiry_nonce');
if (!empty($_POST['aum_hp'])) {
wp_safe_redirect(home_url('/request-a-quote/?sent=1'));
exit; // silent drop, do not teach the bot what failed
}
$email = sanitize_email(wp_unslash($_POST['email'] ?? ''));
if (!is_email($email) || WC()->cart->is_empty()) {
wc_add_notice('Please check the email address and your product list.', 'error');
wp_safe_redirect(wp_get_referer() ?: home_url('/request-a-quote/'));
exit;
}
$order = wc_create_order(['customer_id' => get_current_user_id()]);
foreach (WC()->cart->get_cart() as $item) {
$order->add_product($item['data'], $item['quantity']);
}
$order->set_address([
'first_name' => sanitize_text_field(wp_unslash($_POST['first_name'] ?? '')),
'company' => sanitize_text_field(wp_unslash($_POST['company'] ?? '')),
'email' => $email,
'country' => sanitize_text_field(wp_unslash($_POST['country'] ?? '')),
], 'billing');
$order->update_meta_data('_enquiry_company', sanitize_text_field(wp_unslash($_POST['company'] ?? '')));
$order->update_meta_data('_enquiry_vat', sanitize_text_field(wp_unslash($_POST['vat'] ?? '')));
$order->update_meta_data('_enquiry_needed_by', sanitize_text_field(wp_unslash($_POST['needed_by'] ?? '')));
$order->update_meta_data('_enquiry_notes', sanitize_textarea_field(wp_unslash($_POST['notes'] ?? '')));
if (!empty($_FILES['spec']['name'])) {
require_once ABSPATH . 'wp-admin/includes/file.php';
$upload = wp_handle_upload($_FILES['spec'], [
'test_form' => false,
'mimes' => [
'pdf' => 'application/pdf',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
]);
if (isset($upload['file'])) {
$order->update_meta_data('_enquiry_file', esc_url_raw($upload['url']));
}
}
$order->calculate_totals();
$order->update_status('enquiry', 'Submitted from the enquiry form.');
aum_send_enquiry_emails($order);
WC()->cart->empty_cart();
wp_safe_redirect(add_query_arg(['sent' => 1, 'ref' => $order->get_order_number()], home_url('/request-a-quote/')));
exit;
}
Two notes. calculate_totals() gives you a number for reference - it is what the list would cost at catalogue prices, not a quote. And stock: WooCommerce reduces stock on the statuses it treats as paid, so an enquiry status leaves inventory alone. Verify that on your own install with a test product before you rely on it.
Step 5 - Notifications
Two emails: one to the sales inbox with Reply-To set to the customer, one to the customer confirming receipt and stating plainly that no payment has been taken.
function aum_send_enquiry_emails(WC_Order $order): void
{
$items = [];
foreach ($order->get_items() as $item) {
$items[] = sprintf(' - %s x %d', $item->get_name(), $item->get_quantity());
}
$body = implode("\n", array_merge([
'Reference: ' . $order->get_order_number(),
'Company: ' . $order->get_meta('_enquiry_company'),
'Contact: ' . $order->get_billing_first_name() . ' <' . $order->get_billing_email() . '>',
'Country: ' . $order->get_billing_country(),
'Needed by: ' . $order->get_meta('_enquiry_needed_by'),
'',
'Items:',
], $items, [
'',
'Notes:',
$order->get_meta('_enquiry_notes'),
'',
'Attachment: ' . ($order->get_meta('_enquiry_file') ?: 'none'),
]));
$to = (string) apply_filters('aum_enquiry_recipient', get_option('admin_email'));
wp_mail($to, 'Enquiry ' . $order->get_order_number(), $body, [
'Reply-To: ' . $order->get_billing_email(),
]);
wp_mail(
$order->get_billing_email(),
'We received your enquiry (' . $order->get_order_number() . ')',
"Thanks - your list is with us and no payment has been taken.\n\n" . $body
);
}
wp_mail is only as good as the sending domain. If the confirmation lands in spam, the customer assumes the form is broken and sends the same list again. Set up SPF and DKIM for the domain you send from, and route mail through an SMTP service rather than PHP's local mail transfer agent.
If you want branded HTML instead of plain text, register a proper email class through the woocommerce_email_classes filter and trigger it from woocommerce_order_status_enquiry. Plain text is fine to start with and much easier to debug.
Step 6 - The admin side
This is the part people skip, and then the enquiry sits in a list nobody opens.
Show the enquiry fields on the order screen.
add_action('woocommerce_admin_order_data_after_billing_address', function (WC_Order $order) {
if (!in_array($order->get_status(), ['enquiry', 'quoted'], true)) {
return;
}
echo '<p><strong>Needed by:</strong> ' . esc_html($order->get_meta('_enquiry_needed_by')) . '</p>';
echo '<p><strong>VAT:</strong> ' . esc_html($order->get_meta('_enquiry_vat')) . '</p>';
echo '<p><strong>Notes:</strong><br>' . nl2br(esc_html($order->get_meta('_enquiry_notes'))) . '</p>';
$file = $order->get_meta('_enquiry_file');
if ($file) {
echo '<p><a href="' . esc_url($file) . '" target="_blank" rel="noopener">Spec sheet</a></p>';
}
}, 10, 1);
Work the quote in order notes, not in your inbox. Every reply you send should be a note on the order as well. Six weeks later the only reliable record of what you promised is the order timeline.
Quote flow. Open the order, adjust the line item totals to your quoted prices, set the status to Quote sent, then use the order action that emails the order details to the customer. With no payment gateway enabled there is no pay button in that email, so it reads as a quote. Then: Quote sent becomes Won (reuse completed) or Cancelled.
Filter the list. The status filter in the orders screen picks up both new statuses automatically because of show_in_admin_status_list. Bookmark the filtered URL.
Step 7 - What breaks silently if you skip it
- Coupon field. Disable it on the enquiry page - a coupon on a quote is confusing and gets applied to the reference total.
- Tax display. Decide whether the reference total shows VAT-inclusive or exclusive and state it under the list. B2B buyers read totals literally.
-
Analytics. No purchase event fires, ever. If you tracked checkout conversions before, that funnel goes to zero. Fire a custom event on
?sent=1instead. - Mini-cart wording. "Checkout" as a button label on a page that asks for a quote reads wrong. Change it with a translation filter or a string swap.
- Abandoned-cart style emails that assume a checkout will follow. Turn them off.
- Rate limiting. One submission per IP per minute, stored in a transient. Honeypots catch dumb bots; rate limits catch the rest.
-
Logged-in customers.
wc_create_orderlinks the order to the user, which is fine, but the enquiry should still send email - people switch devices.
Test checklist
- Add two products, submit the form, confirm the order exists with status
Enquiryand both items at the right quantities. - Confirm the cart is empty afterwards and the thank-you state shows the reference.
- Confirm both emails arrive, and that hitting reply in the admin email goes to the customer, not to your own address.
- Submit with an invalid email - expect a notice, not a blank page.
- Submit twice in a row from the same IP - expect the second one to be rate-limited.
- Try to reach
/checkout/directly - expect a redirect. - Upload a PDF and a
.phpfile - one is stored, one is rejected. - Change the line totals, set status to
Quote sent, send the email, confirm there is no payment link. - Confirm stock did not move on the enquiry status.
- Repeat the whole flow as a logged-out visitor in a private window.
Disclosure: I work at AumCreate. We build catalogue-and-enquiry sites on WordPress, and the inquiry template at aumcreate.com/templates/inquiry is our own demo build rather than a client project - it is there if you want to compare the field list against yours.
Top comments (0)