WooCommerce doesn't ship with conditional shipping messages out of the box. You get a method label and a cost — nothing more. But with the right hooks (woocommerce_cart_totals_after_shipping and woocommerce_review_order_after_shipping), you can inject dynamic HTML notices based on the customer's detected shipping zone, the product categories in their cart, or how close they are to a free shipping threshold.
What hooks control WooCommerce shipping message output?
Two hooks sit directly beneath the shipping rows in WooCommerce's cart and checkout templates.
woocommerce_cart_totals_after_shipping fires inside cart/cart-totals.php after all shipping method rows have rendered. woocommerce_review_order_after_shipping fires in the equivalent position inside checkout/review-order.php. Both receive no arguments, so you pull whatever context you need from the WC() global.
A minimal proof-of-concept:
add_action( 'woocommerce_cart_totals_after_shipping', 'topsyde_shipping_message' );
add_action( 'woocommerce_review_order_after_shipping', 'topsyde_shipping_message' );
function topsyde_shipping_message() {
echo '<tr><td colspan="2" class="shipping-message">';
echo '<p>Standard shipping applies to this order.</p>';
echo '</td></tr>';
}
Put this in your child theme's functions.php or a site-specific plugin. Never edit a parent theme directly — an update will erase it.
How to detect the customer's shipping zone in PHP
WooCommerce resolves zones by matching a "package" array against your configured zone regions. The package must contain a destination key with country, state, postcode, and city.
function topsyde_get_customer_zone() {
$customer = WC()->customer;
$package = [
'destination' => [
'country' => $customer->get_shipping_country(),
'state' => $customer->get_shipping_state(),
'postcode' => $customer->get_shipping_postcode(),
'city' => $customer->get_shipping_city(),
],
];
$zone = WC_Shipping_Zones::get_zone_matching_package( $package );
return $zone; // WC_Shipping_Zone object, or zone 0 (rest-of-world)
}
$zone->get_id() returns an integer. Zone 0 is WooCommerce's built-in "Rest of the World" fallback. $zone->get_zone_name() returns the human-readable label you set in WooCommerce → Settings → Shipping.
Geolocation caveat: If the customer hasn't entered an address yet (early in checkout), get_shipping_country() may fall back to geolocation if you have that enabled under WooCommerce → Settings → General → Default customer location. This means zone detection works even before address entry — but it can be wrong. Always sanity-check with a WC_Geolocation fallback or gracefully handle an empty zone.
How to display zone-specific shipping messages
With zone detection in place, conditional messaging is straightforward. The following example outputs different notices for a US domestic zone, a Canada zone, and the rest-of-world fallback:
add_action( 'woocommerce_cart_totals_after_shipping', 'topsyde_zone_shipping_message' );
add_action( 'woocommerce_review_order_after_shipping', 'topsyde_zone_shipping_message' );
function topsyde_zone_shipping_message() {
$zone = topsyde_get_customer_zone();
$zone_name = strtolower( $zone->get_zone_name() );
$message = '';
if ( str_contains( $zone_name, 'united states' ) || str_contains( $zone_name, 'domestic' ) ) {
$message = 'Orders ship within 1–2 business days via UPS Ground.';
} elseif ( str_contains( $zone_name, 'canada' ) ) {
$message = 'Canadian orders ship within 3–5 business days. Duties may apply.';
} else {
$message = 'International orders ship within 7–14 business days via DHL Express.';
}
if ( $message ) {
echo '<tr class="shipping-notice"><td colspan="2">';
echo '<p class="topsyde-shipping-msg">' . esc_html( $message ) . '</p>';
echo '</td></tr>';
}
}
Key point: Match on get_zone_name() strings only if your zone names are stable and well-defined. For robustness in larger projects, match on get_id() against known integer IDs (which you hard-code after noting them from the database or the admin URL: /wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=3).
How to show shipping messages based on product category
Category-based messages require iterating WC()->cart->get_cart() and checking each item's product categories. has_term() is the right tool — it checks against the product_cat taxonomy.
function topsyde_cart_has_category( $category_slug ) {
foreach ( WC()->cart->get_cart() as $cart_item ) {
$product_id = $cart_item['product_id'];
if ( has_term( $category_slug, 'product_cat', $product_id ) ) {
return true;
}
}
return false;
}
Then use it inside your hook callback:
add_action( 'woocommerce_cart_totals_after_shipping', 'topsyde_category_shipping_message' );
add_action( 'woocommerce_review_order_after_shipping', 'topsyde_category_shipping_message' );
function topsyde_category_shipping_message() {
if ( topsyde_cart_has_category( 'hazardous-materials' ) ) {
echo '<tr><td colspan="2"><p class="topsyde-shipping-msg">';
echo esc_html( 'Hazmat items require ground shipping and cannot be expedited.' );
echo '</p></td></tr>';
}
if ( topsyde_cart_has_category( 'perishables' ) ) {
echo '<tr><td colspan="2"><p class="topsyde-shipping-msg">';
echo esc_html( 'Perishable items ship Monday–Wednesday only to ensure freshness.' );
echo '</p></td></tr>';
}
}
Multiple messages can stack — just echo multiple <tr> blocks. Wrap each in a conditional and they'll only appear when relevant.
For stores with complex product logic like custom-built items, check how WooCommerce product configurators handle category and attribute data — the same taxonomy structure applies when building shipping conditionals.
How to build a free shipping threshold progress message
This is the highest-ROI use case. According to a 2023 Baymard Institute study, free shipping is the top checkout incentive for 80% of US online shoppers. Displaying a "You're $X away from free shipping" message directly in the cart reduces abandonment and increases average order value.
add_action( 'woocommerce_cart_totals_after_shipping', 'topsyde_free_shipping_nudge' );
add_action( 'woocommerce_review_order_after_shipping', 'topsyde_free_shipping_nudge' );
function topsyde_free_shipping_nudge() {
$threshold = 75.00; // Match your free shipping coupon or method minimum
$cart_total = (float) WC()->cart->get_subtotal();
$remaining = $threshold - $cart_total;
echo '<tr class="free-shipping-nudge"><td colspan="2">';
if ( $remaining > 0 ) {
$formatted = wc_price( $remaining );
echo '<p class="topsyde-shipping-msg">';
// wp_kses_post allows the wc_price HTML output safely
echo wp_kses_post( sprintf(
'Add %s more to your order and get <strong>free shipping</strong>!',
$formatted
) );
echo '</p>';
// Optional: progress bar
$pct = min( 100, round( ( $cart_total / $threshold ) * 100 ) );
echo '<div class="free-ship-progress" style="background:#eee;border-radius:4px;height:8px;margin-top:6px;">';
echo '<div style="width:' . esc_attr( $pct ) . '%;background:#0071a1;height:8px;border-radius:4px;transition:width .3s;"></div>';
echo '</div>';
} else {
echo '<p class="topsyde-shipping-msg" style="color:green;font-weight:600;">✓ You\'ve unlocked free shipping!</p>';
}
echo '</td></tr>';
}
Important: Sync $threshold with your actual free shipping method's minimum order amount in WooCommerce → Settings → Shipping. A mismatch causes customer frustration when the promised free shipping doesn't apply at checkout. If you're using WooCommerce HPOS, note that cart totals here come from session data, not order records — HPOS doesn't affect this flow.
Combining zone + category + threshold logic
Real stores need all three conditions simultaneously. The pattern is to build a priority stack: category-specific restrictions first (hardest constraints), then zone-specific notes, then the free shipping nudge as a soft conversion prompt.
add_action( 'woocommerce_cart_totals_after_shipping', 'topsyde_combined_shipping_messages' );
add_action( 'woocommerce_review_order_after_shipping', 'topsyde_combined_shipping_messages' );
function topsyde_combined_shipping_messages() {
$zone = topsyde_get_customer_zone();
$zone_id = $zone->get_id();
$has_heavy = topsyde_cart_has_category( 'heavy-equipment' );
// 1. Hard restriction — overrides everything
if ( $has_heavy && $zone_id !== 2 ) { // Zone 2 = Continental US
echo '<tr><td colspan="2"><p class="topsyde-shipping-msg topsyde-warning">';
echo esc_html( 'Heavy equipment ships to Continental US only. Remove item to proceed.' );
echo '</p></td></tr>';
return; // Stop here — no nudge needed
}
// 2. Zone-specific note
if ( $zone_id === 5 ) { // Zone 5 = Hawaii/Alaska
echo '<tr><td colspan="2"><p class="topsyde-shipping-msg">';
echo esc_html( 'Extended transit time of 5–7 days applies to Hawaii and Alaska.' );
echo '</p></td></tr>';
}
// 3. Free shipping nudge (runs unless hard restriction fired)
topsyde_free_shipping_nudge();
}
Plugin alternatives for non-developers
If writing and maintaining hooks isn't feasible for a client site, three plugins cover the main use cases:
| Plugin | Free Tier | Zone Support | Category Support | Threshold Progress Bar |
|---|---|---|---|---|
| WooCommerce Shipping Messages (WooThemes) | No | Yes | Yes | No |
| Flexible Shipping (WP Desk) | Yes (limited) | Yes | No | No |
| Cart Notices for WooCommerce (WooCommerce.com) | No | No | Yes | No |
| ShipperHQ | No | Yes (advanced) | Yes | No |
| Free Shipping Bar (various) | Yes | No | No | Yes |
No single plugin covers all three dimensions (zone + category + threshold bar) without custom code. For stores needing all three, the hook-based approach above is more maintainable than stacking multiple plugins — each plugin you add is a performance and update-management liability. The WooCommerce without Jetpack guide covers this philosophy in detail for other feature areas too.
Performance and caching considerations
These hooks fire on every cart and checkout page load. Zone detection calls WC_Shipping_Zones::get_zone_matching_package(), which hits the database. On high-traffic stores, this adds up.
Transient caching pattern:
function topsyde_get_customer_zone_cached() {
$customer = WC()->customer;
$cache_key = 'topsyde_zone_' . md5(
$customer->get_shipping_country() .
$customer->get_shipping_state() .
$customer->get_shipping_postcode()
);
$zone = get_transient( $cache_key );
if ( false === $zone ) {
$zone = topsyde_get_customer_zone();
set_transient( $cache_key, $zone, HOUR_IN_SECONDS );
}
return $zone;
}
Page caching: Cart and checkout pages must be excluded from full-page caching. Most managed hosts handle this automatically. On TopSyde's managed WordPress hosting plans starting at $89/mo, cart and checkout are excluded from the Nginx FastCGI cache by default — no configuration required.
According to WooCommerce's own performance documentation (2024), uncached zone lookups on stores with more than 20 zones can add 40–80ms per request. Transient caching brings this to under 1ms on cache hits.
Testing shipping messages safely
Before pushing hook customisations to production, test against every zone and category combination you've defined. A staging environment is the correct place to do this — you want live shipping rates and real zone configurations without touching real orders.
TopSyde provides push-button staging on all plans. If you're currently on a host that doesn't include staging, the WordPress staging environments guide covers how to set one up manually and what to watch out for when pushing back to production.
For stores with SAP or ERP integration, verify that shipping zone data returned in the cart matches what your integration expects — see the WooCommerce SAP Business One integration guide for how zone metadata flows through order records.
Frequently Asked Questions
Does woocommerce_cart_totals_after_shipping fire on the checkout page too?
No — it only fires in cart/cart-totals.php. For the checkout order review table, you need `woocommerce

Content & SEO Strategist
7+ years SEO & content strategy, Google Analytics certified
Elena drives content strategy and SEO at TopSyde, helping clients maximize organic visibility and AI search presence. She combines technical WordPress knowledge with data-driven content optimization.



