Why Shipping Should Depend on the Payment Gateway
Shipping and payment are usually treated as independent checkout concerns. For many merchants they are not. The payment gateway a customer picks changes what the order costs to fulfil, and the shipping line is the cheapest place to recover that difference.
Three cases come up repeatedly:
- Direct bank transfer (BACS). There is no percentage processing fee, so some merchants pass part of the saving back as a shipping discount to steer customers away from card payments.
- Cash on Delivery (COD). COD carries failed-delivery risk, courier cash-handling fees, and delayed settlement. A flat surcharge on shipping offsets that exposure.
- Card gateways. Stripe, PayPal and similar processors take a percentage plus a fixed fee per transaction. On low-margin, high-AOV orders that fee is material.
The rule is simple to state: if the customer selects gateway X, shipping costs Y. Implementing it so the number on screen is the number charged is where most implementations break. The canonical way to update woocommerce shipping based on payment method ajax is a three-part synchronization: a client-side event, a server-side session write, and a rate filter that is guaranteed to re-run. Skip any one of them and the checkout shows stale totals.
The Race Condition Most Tutorials Miss
Ask a generic AI assistant or skim most forum answers and you will get a single snippet: a PHP callback on woocommerce_package_rates that checks the chosen payment method and edits $rate->cost. The snippet is syntactically valid, passes a quick review, and fails in production. It misses two architectural facts.
1. Switching gateways does not trigger a recalculation
WooCommerce deliberately decouples the shipping calculation engine from payment gateway initialization. Shipping is expensive: it can involve zone matching, table lookups, and live carrier API calls. The checkout therefore avoids recomputing it unless something that plausibly affects shipping changes, such as the address, the cart contents, or the selected shipping method.
Choosing a different payment radio button is not one of those triggers. In the stock checkout.js, a gateway change toggles the visible payment box and fires payment_method_selected. That updates the DOM instantly, so the COD instructions appear, but no update_checkout AJAX shipping recalculation event is dispatched. The server is never asked to re-evaluate shipping, and the order review table keeps the old totals. This behavior is part of the classic WooCommerce checkout state machine, where frontend events such as update_checkout initiate server-side recalculation through update_order_review. If the payment gateway selection does not trigger this event lifecycle, the checkout state and displayed shipping totals can become inconsistent.
2. Even when the request fires, cached rates can bypass your filter
This is the part that bites developers who think the JavaScript trigger alone solves it. WC_Shipping::calculate_shipping_for_package() stores computed rates in the customer session under a key like shipping_for_package_0, alongside a hash of the package. On each request WooCommerce rebuilds the hash from the package contents, destination, coupons and the shipping transient version. If the hash matches, the stored rates are reused and the calculation block, including the woocommerce_package_rates filter, is skipped.
So a payment method change that does not alter the package hash returns cached rates, and your conditional logic never executes. A correct implementation has to make the payment gateway part of what the package hash is computed from.
To summarize the failure modes of the filter-only approach:
| Layer | What happens | Result |
|---|---|---|
| DOM | COD box appears on gateway change | Looks responsive |
| Frontend events | No update_checkout fired |
Server never re-queried |
| Session cache | Package hash unchanged | Cached rates reused |
woocommerce_package_rates |
Not invoked | Surcharge never applied |
The fix is a multi-stage sync: capture the gateway event in the browser, make the backend aware of the choice, and force a recalculation that cannot be satisfied from cache.
Stage 1: Frontend Event Listener and Dispatcher
Bind a delegated listener to form.checkout. Delegation matters because WooCommerce replaces fragments of the checkout markup after every update_checkout; handlers attached directly to the radio inputs would be destroyed on the first refresh. This AJAX fragment refresh allows WooCommerce to replace the order review and payment sections dynamically without reloading the entire checkout page. The ?wc-ajax=update_order_review endpoint returns updated HTML fragments that keep shipping charges, taxes, and order totals synchronized with the latest checkout selections.
The listener does two things in order: it posts the selected gateway to the server so the session is updated, and only after that request settles does it trigger update_checkout. Chaining them removes the race where the recalculation request reads the session before the write lands.
jQuery( function( $ ) {
var lastGateway = null;
// Delegated: survives checkout fragment replacement.
$( 'form.checkout' ).on( 'change', 'input[name="payment_method"]', function() {
var gateway = $( this ).val();
// Ignore redundant events (WooCommerce re-fires change after refresh).
if ( gateway === lastGateway ) {
return;
}
lastGateway = gateway;
// wc-ajax endpoints are lighter than admin-ajax.php.
var url = wc_checkout_params.wc_ajax_url
.toString()
.replace( '%%endpoint%%', 'sync_gateway_to_session' );
$.ajax( {
type: 'POST',
url: url,
data: {
payment_method: gateway,
security: dcs_params.nonce
}
} ).always( function() {
// Fire on success AND failure so totals never stay stale.
$( document.body ).trigger( 'update_checkout' );
} );
} );
} );
A few implementation notes:
wc_checkout_params.wc_ajax_urluses WooCommerce's?wc-ajax=routing, which bootstraps a smaller part of WordPress thanadmin-ajax.php. If you prefer the classic route,wc_checkout_params.ajax_urlworks withwp_ajax_hooks.dcs_params.nonceis passed withwp_localize_script()in the PHP section below.update_checkoutis debounced and aborts an in-flight update, so rapid gateway toggling resolves to the last selection.
Stage 2: Backend Session Storage and Rate Filtering
The server side has four responsibilities:
- Enqueue the script and expose a nonce.
- Persist the gateway in
WC()->session. - Make the gateway part of the shipping package so the hash changes and the cache is invalidated.
- Apply the conditional adjustment inside
woocommerce_package_rates.
One detail worth knowing: when update_checkout runs, WooCommerce posts the serialized checkout form to update_order_review, and core itself writes the submitted payment_method to chosen_payment_method in the session. The update_order_review payload typically includes a serialized post_data string containing the checkout form fields, alongside a separate payment_method parameter. WooCommerce processes this AJAX request to synchronize the selected gateway with the customer session before recalculating checkout totals. The custom active_payment_gateway key is therefore redundant for the final request, but it is still useful. It guarantees the value is already in the session before any recalculation begins, and it lets you read the choice in contexts where the form payload is not available. The code below reads the custom key first and falls back to the core one. This approach relies on WooCommerce session persistence to maintain the selected payment gateway across separate AJAX requests. By storing the gateway in WC()->session, the backend can retrieve the customer's latest payment selection during shipping calculations without depending exclusively on temporary frontend form data.
<?php
/**
* Gateway-aware shipping rates for the classic (shortcode) checkout.
*/
// 1. Enqueue the dispatcher and expose a nonce.
add_action( 'wp_enqueue_scripts', function () {
if ( ! is_checkout() ) {
return;
}
wp_enqueue_script(
'dcs-gateway-sync',
plugins_url( 'assets/gateway-sync.js', __FILE__ ),
array( 'jquery', 'wc-checkout' ),
'1.0.0',
true
);
wp_localize_script( 'dcs-gateway-sync', 'dcs_params', array(
'nonce' => wp_create_nonce( 'dcs-sync-gateway' ),
) );
} );
// 2. Persist the selected gateway in the WooCommerce session.
add_action( 'wc_ajax_sync_gateway_to_session', 'dcs_update_gateway_session_state' );
function dcs_update_gateway_session_state() {
check_ajax_referer( 'dcs-sync-gateway', 'security' );
if ( WC()->session && isset( $_POST['payment_method'] ) ) {
$gateway = sanitize_text_field( wp_unslash( $_POST['payment_method'] ) );
$valid = array_keys( WC()->payment_gateways()->get_available_payment_gateways() );
if ( in_array( $gateway, $valid, true ) ) {
WC()->session->set( 'active_payment_gateway', $gateway );
}
}
wp_die();
}
// Helper: resolve the active gateway from session.
function dcs_get_active_gateway() {
if ( ! WC()->session ) {
return '';
}
$gateway = WC()->session->get( 'active_payment_gateway' );
if ( ! $gateway ) {
$gateway = WC()->session->get( 'chosen_payment_method' );
}
return (string) $gateway;
}
// 3. Bust the shipping cache: the gateway becomes part of the package hash.
add_filter( 'woocommerce_cart_shipping_packages', function ( $packages ) {
$gateway = dcs_get_active_gateway();
foreach ( $packages as $index => $package ) {
$packages[ $index ]['active_gateway'] = $gateway;
}
return $packages;
} );
// 4. Apply the conditional adjustment.
add_filter( 'woocommerce_package_rates', 'dcs_apply_conditional_gateway_shipping', 10, 2 );
function dcs_apply_conditional_gateway_shipping( $rates, $package ) {
// Avoid running in the administrative backend.
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
return $rates;
}
// Example: $5.00 surcharge on flat rate methods when COD is active.
if ( 'cod' !== dcs_get_active_gateway() ) {
return $rates;
}
foreach ( $rates as $rate_key => $rate ) {
if ( 'flat_rate' !== $rate->get_method_id() ) {
continue;
}
$new_cost = (float) $rate->get_cost() + 5.00;
$rate->set_cost( $new_cost );
// Keep shipping tax consistent with the new cost.
if ( ! empty( $rate->get_taxes() ) ) {
$rate->set_taxes(
WC_Tax::calc_shipping_tax( $new_cost, WC_Tax::get_shipping_tax_rates() )
);
}
$rates[ $rate_key ] = $rate;
}
return $rates;
}
Why each piece is necessary
The woocommerce_cart_shipping_packages filter is the cache fix. WooCommerce hashes the package array. Adding active_gateway to it means a switch from stripe to cod yields a different hash, the stored rates are discarded, and woocommerce_package_rates runs again. Switch back and the hash reverts to the previously stored value, so you do not pay for needless recalculation. This is a targeted alternative to global shipping transient cache invalidation. Instead of clearing all cached shipping rates or changing WooCommerce's global shipping transient version, the implementation modifies the shipping package hash based on the selected payment gateway, forcing recalculation only when the relevant package state changes.
The guard clause. is_admin() && ! defined( 'DOING_AJAX' ) prevents the filter from touching rates when WordPress is rendering a genuine admin screen, for example when an order is edited in the dashboard. Legacy admin-ajax.php calls are is_admin() and define DOING_AJAX, so they pass through. The wc-ajax routes used by checkout are not is_admin(), so they pass naturally.
Using set_cost() and get_cost(). Mutating $rate->cost directly relies on legacy property access. The setter and getter are the supported API on WC_Shipping_Rate and keep tax data coherent.
Taxes. If your shipping is taxable, adjusting cost without recalculating the tax array produces a mismatch between the displayed shipping line and the tax line. The set_taxes() call handles that.
Extending the rule set
A surcharge for COD and a discount for BACS is a small change. Replace the single condition with a map:
$adjustments = array(
'cod' => 5.00, // Surcharge for cash handling and failed-delivery risk.
'bacs' => -2.00, // Discount: no percentage gateway fee.
);
$gateway = dcs_get_active_gateway();
if ( ! isset( $adjustments[ $gateway ] ) ) {
return $rates;
}
$delta = $adjustments[ $gateway ];
// ...then clamp: $new_cost = max( 0, (float) $rate->get_cost() + $delta );
Always clamp discounts at zero. A negative shipping rate will produce confusing totals and can break some gateways' validation.
Verification Checklist
Before shipping this to production, test the following paths:
- Toggle sequence. Card, COD, BACS, card. Each switch must visibly change the shipping line without a reload.
- Place-order consistency. The shipping amount on the order review table must equal the amount on the created order. Confirm the order's shipping line item, not just the front-end display.
- Cache behavior. Enable WooCommerce shipping debug mode (WooCommerce, Settings, Shipping, Options) to bypass rate caching while developing, then disable it and re-test. The package hash fix has to work with caching on, because that is how production runs.
- Persistent object cache. On Redis or Memcached setups, confirm the session data is shared across all PHP workers. A sticky-session mismatch will look like intermittent stale totals.
- Guest checkout. Guests rely on the WooCommerce session cookie. If the cart is non-empty the cookie exists, but confirm the sync endpoint receives it behind your CDN or full-page cache. The checkout and
wc-ajaxURLs must be excluded from page caching. - Plugin interactions. Other plugins hooked to
woocommerce_package_ratesat the same priority can override your adjustments. Adjust the priority if needed and confirm the order of execution.
Scope Caveat: Block Checkout
Everything above targets the classic shortcode checkout. The WooCommerce Checkout Block does not use update_checkout or form.checkout; it talks to the Store API. For that checkout, the equivalent flow uses the block checkout's extensionCartUpdate on the client with a matching callback registered through woocommerce_store_api_register_update_callback() on the server. The architectural principle is identical (client event, session write, forced recalculation), but the transport differs.
Conclusion
Conditional shipping by payment gateway fails when treated as a pure filter problem. The failure is architectural: WooCommerce separates shipping calculation from payment gateway handling, caches computed rates against a package hash, and does not re-evaluate shipping when a customer changes the payment radio button.
The reliable pattern has three parts that work together. A delegated JavaScript listener on input[name="payment_method"] captures the selection and, once the server acknowledges it, triggers update_checkout. A backend handler writes the gateway into WC()->session, and a woocommerce_cart_shipping_packages filter makes that value part of the package hash so cached rates are invalidated. Finally, the woocommerce_package_rates filter, protected by the is_admin() && ! defined( 'DOING_AJAX' ) guard, applies the surcharge or discount against the freshly computed rates.
With those pieces in place, the shipping total the customer sees always matches the gateway they have selected, and the amount charged matches the amount displayed. The race condition is eliminated before the transaction is processed, so your margin protection rules apply to every order rather than only to those placed after a lucky page refresh.


0 Comments