WooCommerce recalculates shipping automatically when a customer changes the country, state, postcode, city, or street address, or picks a different shipping method. Core's checkout.js listens for changes on those inputs and fires an order-review refresh. A custom field is not on that list. If you add a "Delivery Zone" select to the billing section, changing it does nothing until some other field or event triggers a refresh. The customer sees shipping rates that don't match their selection, and in the worst case the order is placed with the wrong rate.
Fixing this takes two small pieces: a frontend event that tells WooCommerce to refresh, and a backend filter that reads the new value when rates are calculated. The backend half has one caching trap that most tutorials skip, and it is covered below.
The Flawed Generic Approach: A Custom admin-ajax.php Handler
The common first attempt is to write a standalone AJAX endpoint. A change listener posts the selected zone to admin-ajax.php, a handler recalculates a shipping cost, and a callback patches the DOM with the result. It appears to work in a demo, but it duplicates architecture that WooCommerce already provides and bypasses most of it:
- It skips the cart calculation pipeline. Native refreshes run
WC()->cart->calculate_totals(), which applies coupons, taxes, fees fromwoocommerce_cart_calculate_fees, and rounding rules. A custom handler that touches only the shipping row leaves tax and fee lines stale. The total shown at checkout then differs from the total calculated when the order is created. - You have to rebuild the fragment layer. The native
update_order_reviewresponse returns HTML fragments for the order review table and payment section, filterable throughwoocommerce_update_order_review_fragments. A custom endpoint means writing your own markup replacement and keeping it in sync with theme overrides ofreview-order.php. - It races with core requests. Core debounces its refresh and aborts any in-flight request before sending a new one. A parallel custom request has no such coordination, so responses can arrive out of order and overwrite each other.
- It loads the wrong bootstrap.
admin-ajax.phploads the WordPress admin environment on every call. WooCommerce uses the lighter?wc-ajax=endpoint for its frontend requests for this reason. - It breaks on updates. Any change to core markup, theme templates, or another plugin's checkout JavaScript can silently desynchronize a parallel implementation.
The better approach is to leave the request cycle alone and give it a reason to run.
The Frontend Solution: Trigger the Native Refresh with jQuery
Register the field first. The key matters because the jQuery selector and the later PHP both depend on it:
add_filter( 'woocommerce_checkout_fields', 'mcf_register_delivery_zone_field' );function mcf_register_delivery_zone_field( $fields ) {$fields['billing']['billing_custom_delivery_zone'] = array('type' => 'select','label' => __( 'Delivery Zone', 'my-textdomain' ),'required' => true,'class' => array( 'form-row-wide' ),'priority' => 125,'options' => array('' => __( 'Select a zone', 'my-textdomain' ),'zone_a' => __( 'Zone A (City Center)', 'my-textdomain' ),'zone_b' => __( 'Zone B (Suburbs)', 'my-textdomain' ),'zone_c' => __( 'Zone C (Outer Region)', 'my-textdomain' ),),);return $fields;}
Fields prefixed with billing_ are saved to the order as _billing_custom_delivery_zone meta automatically. Now bind the event:
jQuery( function( $ ) {$( 'form.checkout' ).on( 'change', '#billing_custom_delivery_zone', function() {$( document.body ).trigger( 'update_checkout' );} );} );
Triggering update_checkout on document.body hands control to WooCommerce's own state machine. That frontend event ultimately causes WooCommerce to call the update_order_review AJAX endpoint, which recalculates totals, shipping methods, taxes, and checkout fragments. It debounces the call, aborts any pending request, blocks the order review with the loading overlay, posts the serialized form to ?wc-ajax=update_order_review, and swaps in the returned fragments. WooCommerce's internal debounce function helps prevent rapid field changes from sending overlapping update_order_review requests. You get the same behavior as a native field with about five lines of JavaScript.
Delegating from form.checkout rather than binding directly to the element keeps the handler working if a theme or plugin re-renders the field. Bind to change, not input, for text fields. Firing a refresh on every keystroke produces needless request traffic.
Load the script only on the checkout page, attached to WooCommerce's own handle so it runs after jQuery and checkout.js are available:
add_action( 'wp_enqueue_scripts', 'mcf_enqueue_zone_trigger' );function mcf_enqueue_zone_trigger() {if ( ! is_checkout() ) {return;}wp_add_inline_script('wc-checkout',"jQuery( function( $ ) {$( 'form.checkout' ).on( 'change', '#billing_custom_delivery_zone', function() {$( document.body ).trigger( 'update_checkout' );} );} );");}
For simple select, radio, and checkbox fields, core also supports an update_totals_on_change class on the field wrapper, which needs no custom JavaScript. The explicit trigger works for any field type and gives you control over timing, so it's the more flexible choice.
The Backend Solution: Adjust Rates with woocommerce_package_rates
When update_checkout fires, core serializes the entire checkout form into a single post_data parameter, so your custom field is already in the payload. This is effectively the same form-data pattern produced by jQuery's serialize() method, which converts checkout inputs into a URL-encoded query string. No extra frontend plumbing is needed to send it. In the update_order_review request, that serialized form is available inside $_POST['post_data'], rather than exposing every custom field as its own top-level $_POST value. The catch is that the value arrives as part of that serialized string, not as its own $_POST key. The reliable pattern is to capture it in woocommerce_checkout_update_order_review, which receives the raw string, and make it part of the shipping package.
That last step matters because of how WooCommerce caches rates. Internally, WooCommerce uses the shipping package data to generate a rate-cache key before woocommerce_package_rates is applied, so any custom value that affects price should also affect the package being hashed. During calculation, WooCommerce hashes the shipping package (cart contents, destination, applied coupons) and stores the resulting rates in the session. If the hash matches on the next request, the stored rates are reused and woocommerce_package_rates does not run again. A customer who changes only the delivery zone leaves the package hash unchanged, so your filter never sees the new value and the old rate stays on screen. The fix is to add the zone to the package itself, so a different zone produces a different hash.
/*** Zone surcharges. Keys must match the select options registered above.*/function mcf_get_zone_surcharges() {return array('zone_a' => 0.00,'zone_b' => 4.50,'zone_c' => 9.00,);}/*** 1. Capture: parse the serialized form on every order-review refresh.* Fires before totals are recalculated in the same request.*/add_action( 'woocommerce_checkout_update_order_review', 'mcf_capture_delivery_zone' );function mcf_capture_delivery_zone( $post_data ) {parse_str( $post_data, $parsed );$zone = isset( $parsed['billing_custom_delivery_zone'] )? sanitize_key( $parsed['billing_custom_delivery_zone'] ): '';// Whitelist against known zones; discard anything else.if ( ! array_key_exists( $zone, mcf_get_zone_surcharges() ) ) {$zone = '';}WC()->session->set( 'mcf_delivery_zone', $zone );}/*** 2. Invalidate the cache: add the zone to the package so the* package hash changes whenever the zone changes.*/add_filter( 'woocommerce_cart_shipping_packages', 'mcf_add_zone_to_package' );function mcf_add_zone_to_package( $packages ) {$zone = WC()->session ? (string) WC()->session->get( 'mcf_delivery_zone', '' ) : '';foreach ( $packages as $index => $package ) {$packages[ $index ]['mcf_delivery_zone'] = $zone;}return $packages;}/*** 3. Adjust: add the zone surcharge to flat rate methods.*/add_filter( 'woocommerce_package_rates', 'mcf_adjust_flat_rates', 10, 2 );function mcf_adjust_flat_rates( $rates, $package ) {$zone = isset( $package['mcf_delivery_zone'] ) ? $package['mcf_delivery_zone'] : '';$surcharges = mcf_get_zone_surcharges();if ( '' === $zone || empty( $surcharges[ $zone ] ) ) {return $rates;}foreach ( $rates as $rate_id => $rate ) {if ( 'flat_rate' !== $rate->get_method_id() ) {continue;}$new_cost = (float) $rate->get_cost() + $surcharges[ $zone ];$rate->set_cost( $new_cost );// Recalculate shipping tax if the original rate was taxable.if ( ! empty( $rate->get_taxes() ) ) {$rate->set_taxes(WC_Tax::calc_shipping_tax( $new_cost, WC_Tax::get_shipping_tax_rates() ));}}return $rates;}
Three details in this code are worth noting:
- Reading from
$packagein the rate filter ties the surcharge to the exact package that was hashed, so the cost and the cache key can't drift apart. - The tax recalculation is required because
set_cost()does not touch the rate's tax array. Without it, a taxable flat rate shows an updated cost with the old tax amount. - The whitelist check keeps arbitrary submitted values out of the session and out of your pricing logic.
While developing, enable WooCommerce → Settings → Shipping → Shipping options → Debug mode. It bypasses the rate cache so you can see whether a stale rate comes from the cache or from your filter logic. Turn it off before going live, since it recalculates rates on every request.
The final checkout request submits the field as a normal top-level POST value, but the session already holds the latest zone from the most recent refresh, so the rate used to create the order matches the rate the customer saw.
Related Architecture Guides: While custom address fields can dispatch update_checkout directly, payment gateway changes require invalidating the shipping package hash—see our full walkthrough on updating WooCommerce shipping based on payment method selection via AJAX. If you need to apply dynamic surcharges or tariffs alongside shipping recalculations, see how to prevent woocommerce_cart_calculate_fees from dropping custom POST data on AJAX reload.
0 Comments