The Problem
A client store I run on WooCommerce 9.7 had a strange ticket on Tuesday: the checkout block accepted coupon codes silently. Customer types a valid code, hits Apply, the input clears, no green confirmation, no red error, totals do not change. Same coupon works fine on the cart block and on the legacy [woocommerce_checkout] shortcode page. Only the checkout block fails.
If you are seeing a coupon input that just clears itself with no message (no toast, no Store API error in the Network tab, sometimes a 400 with a vague woocommerce_rest_cart_invalid_coupon code) it is almost always one of three things: a custom woocommerce_coupon_is_valid filter throwing instead of returning false, a coupon usage restriction the block does not surface, or an old woocommerce_applied_coupon hook that never made it into the Store API contract.
Why It Happens
The checkout block does not call WC()->cart->apply_coupon() directly. It posts to /wp-json/wc/store/v1/cart/apply-coupon, which runs through Automattic\WooCommerce\StoreApi\Routes\V1\CartApplyCoupon. That route calls a thin wrapper that runs WooCommerce core validation, then returns a fresh cart object. If validation fails it returns a 400 with a JSON error.
The block reads that error, but only displays it if the response shape matches what @woocommerce/blocks-checkout expects. Three things break that contract.
Thing 1: Filters returning WP_Error instead of false. Legacy "coupon restriction" plugins filter woocommerce_coupon_is_valid and return a WP_Error on failure. Shortcode checkout catches it and runs wc_add_notice(). The Store API does not. It treats a non-boolean return as truthy, the coupon looks "valid", but a deeper check inside apply_coupon rejects it with no notice.
Thing 2: Usage restriction by user role or email. If your coupon has "Allowed emails" or "Usage limit per user" set, validation runs against the cart's customer data. The block sends an empty customer object until the email field is filled. Apply before typing an email and the check fails with a 400 whose message the block hides, expecting the customer fields to render the error inline.
Thing 3: The old woocommerce_applied_coupon hook does not run. Marketing plugins that hook into woocommerce_applied_coupon to track conversions see nothing on the block path, which uses woocommerce_store_api_cart_update_coupon. If those plugins were also blocking the apply via a misnamed filter, the coupon goes through but their analytics fail silently.
The Fix
Step 1: Confirm the API response. Open DevTools, switch to Network, type a known-good coupon, click Apply. Find the POST to /wp-json/wc/store/v1/cart/apply-coupon.
- If you see a
200withcoupons: []in the response, the apply was rejected silently. It is a filter issue. - If you see a
400with acodefield, copy that code. - If you see no request at all, the block button is disabled (browse rules below).
Step 2: Fix WP_Error returns in custom filters. Search your theme and plugins for woocommerce_coupon_is_valid. Any filter returning WP_Error needs to return false and queue the message for the Store API. Use this pattern:
add_filter( 'woocommerce_coupon_is_valid', function ( $valid, $coupon, $discount ) {
if ( ! my_custom_check( $coupon ) ) {
// Old code: return new WP_Error( 'invalid', 'Reason' );
// New code: throw the StoreApi-aware exception.
throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
'woocommerce_rest_cart_coupon_invalid',
__( 'This coupon is not valid for your cart.', 'my-theme' ),
400
);
}
return $valid;
}, 10, 3 );
Throwing RouteException is what the block expects. The Store API catches it, returns a clean 400 with your message, and the block renders the red error under the coupon input.
Step 3: Move email-restricted validation to the right hook. If your coupon needs the customer email, do not validate it in woocommerce_coupon_is_valid. Run it in woocommerce_store_api_cart_update_customer_from_request instead, which fires once the email field is populated. That keeps the apply step from failing on an empty customer object.
Step 4: Migrate legacy applied_coupon listeners. If you wrote analytics or anti-fraud code on woocommerce_applied_coupon, mirror it onto the Store API hook:
add_action( 'woocommerce_store_api_cart_update_coupon', function ( $coupon_code ) {
do_action( 'woocommerce_applied_coupon', $coupon_code );
}, 10, 1 );
That single line keeps every legacy plugin working while the Store API is in charge.
Step 5: Clear the cart token cache. The block stores a cart hash in localStorage under wc-store-api-nonce-. After fixing filters, run this in the console to force a fresh cart load:
Object.keys(localStorage)
.filter(k => k.startsWith('wc-store-api'))
.forEach(k => localStorage.removeItem(k));
location.reload();
The next coupon apply will use a clean cart and a fresh nonce. If you skip this, you can spend an hour debugging "phantom" 403s that are just stale tokens.
The official Store API coupon route reference is worth bookmarking. It lists every error code the route can return.
The Lesson
The checkout block is a different code path from the legacy checkout, and any custom coupon logic written before WooCommerce 8.x assumes hooks that the Store API never calls. If a coupon "silently fails," it is almost always your code throwing the wrong error type, not a WooCommerce bug.
If your store is leaking conversions because the checkout block is rejecting valid coupons, I fix this kind of thing on retainer — see my services. For a related Store API issue I covered last week, the WooCommerce Stripe webhook signature mismatch fix is worth a read.