The Problem
Client ran a charge-after-shipment workflow on a WooCommerce 10.7 store: Stripe in manual capture mode, the warehouse marks an order complete, and the gateway captures the authorised PaymentIntent. After updating to the latest WooCommerce Stripe gateway (8.4) the capture step started failing on a slice of orders with this in the Woo log:
Stripe response: {
error: {
code: 'payment_intent_unexpected_state',
message: 'This PaymentIntent could not be captured because it has already been captured.',
payment_intent: { id: 'pi_3PXYZ...', status: 'succeeded' }
}
}
Order #4821 status reverted to processing.
The PaymentIntent in the Stripe dashboard was already succeeded and the funds had moved, but the WooCommerce order was still processing because the capture call had blown up. Customer Service then "tried again", got the same error, and on the third attempt the order status finally flipped to completed after the gateway fell through to a different code path. From the merchant's side it looked like the plugin was broken. From Stripe's side every charge had been captured cleanly the first time.
Why It Happens
Two events race each other after a successful charge: the synchronous return from confirmPayment on the checkout page, and the payment_intent.succeeded webhook that Stripe POSTs to your store. The 8.x gateway uses the webhook to mark the order paid when the customer closes the browser before the redirect completes. That webhook handler captures the intent if the order is set to authorize_only. So does the manual "Capture charge" button in the order admin screen.
When manual capture is enabled and the customer's browser stays open long enough for the webhook to arrive while the order is still in on-hold (or before the admin clicks Capture), the webhook captures first. The admin then clicks Capture, the gateway has not yet read the updated _stripe_charge_captured meta because of object cache lag, and it sends a second capture request to Stripe. Stripe rejects it with payment_intent_unexpected_state.
The same race shows up in headless stores using the Store API: the success page fires a server-side capture while the webhook is mid-flight. Whichever wins, the loser logs an error and the WooCommerce order is left in an inconsistent state.
The Stripe API itself is idempotent only when you send an idempotency key, and the 8.x gateway sets one per request — not per intent — so the second capture is treated as a brand-new operation against an intent that is no longer capturable. The Stripe docs on capturing PaymentIntents call this out: capture is a single-shot transition from requires_capture to succeeded, with no replay tolerance.
The Fix
Three pieces. Check the intent state before capturing, mark the order paid from whichever event wins, and stop the manual button from firing if the meta says it is already done.
1. Pre-flight check on the capture filter. Hook wc_stripe_should_capture_charge and short-circuit if the intent is already past requires_capture. Drop this in a site mu-plugin:
add_filter(
'wc_stripe_should_capture_charge',
function ( $should_capture, $order ) {
if ( ! $should_capture ) {
return $should_capture;
}
$intent_id = $order->get_meta( '_stripe_intent_id' );
if ( empty( $intent_id ) ) {
return $should_capture;
}
$intent = WC_Stripe_API::retrieve( "payment_intents/{$intent_id}" );
if ( is_wp_error( $intent ) || empty( $intent->status ) ) {
return $should_capture;
}
if ( in_array( $intent->status, array( 'succeeded', 'processing' ), true ) ) {
$order->update_meta_data( '_stripe_charge_captured', 'yes' );
$order->save();
return false;
}
return $should_capture;
},
10,
2
);
That single REST round-trip prevents the duplicate capture and also self-heals the order meta when the webhook beat the admin. The PaymentIntent retrieve costs one Stripe API call per capture attempt — negligible compared to the support cost of a stuck order.
2. Make the webhook handler authoritative. When payment_intent.succeeded arrives, mark the order paid and stamp _stripe_charge_captured to yes in the same transaction. The 8.x gateway already does this for automatic capture; for manual you have to opt in:
add_action(
'wc_gateway_stripe_process_payment_intent_succeeded',
function ( $order, $intent ) {
if ( 'manual' !== $order->get_meta( '_stripe_charge_capture' ) ) {
return;
}
if ( 'yes' === $order->get_meta( '_stripe_charge_captured' ) ) {
return;
}
$order->update_meta_data( '_stripe_charge_captured', 'yes' );
$order->payment_complete( $intent->id );
$order->save();
},
10,
2
);
Order is now processing or completed regardless of whether the customer closed the tab. The admin Capture button becomes a no-op once the webhook wins.
3. Disable the admin button when the meta is already set. Cosmetic but it stops support staff from clicking twice and creating misleading log lines:
add_filter(
'woocommerce_order_actions',
function ( $actions, $order ) {
if ( 'yes' === $order->get_meta( '_stripe_charge_captured' ) ) {
unset( $actions['capture_charge'] );
}
return $actions;
},
20,
2
);
Verify in staging. Place a manual-capture order, then in Stripe Dashboard fire a test payment_intent.succeeded event before clicking Capture. The order should auto-complete and the button should disappear. Click Capture on a fresh intent that has not received the webhook yet — it should still capture cleanly.
The Lesson
The "already captured" error is a race between the webhook and the manual capture button. The gateway gives you the hooks to make whichever event wins authoritative; you just have to use them. Pre-flight the intent state before capturing, let the webhook complete the order when it arrives first, and stop the admin from firing a redundant call. WooCommerce Stripe is solid once you stop letting both paths run unsupervised.
If your WooCommerce Stripe integration is leaking orders into stuck states, that is the kind of thing I fix as a fixed-scope engagement. See my services. For the related webhook signature issue that breaks capture before it even starts, read WooCommerce Stripe webhook signature mismatch.