— Article — № 115

115 —WordPress

WooCommerce double-charge: tracing a duplicate Stripe hook

Two payment intents, three seconds apart, same order, same card. An incident walkthrough of a duplicate woocommerce_payment_complete hook hiding in mu-plugins.

Overhead photo on bone linen: paper Stripe webhook timeline, manila folder, index card, graph-paper sheet, brass tag, wax seal.
Hero · staged still№ 115

At 23:41 on a Tuesday a Loom came in from an agency lead in Utrecht. Subject line: "Stripe charged the customer twice and the customer is awake." The recording was two minutes long. It showed a WooCommerce thank-you page (Order #48211, €148.50), a Stripe dashboard with two successful payment intents for the same order, and a Slack thread with a tired ops engineer asking what to refund first. The WooCommerce double-charge would turn out to be a duplicate hook registration. The trace took an hour; the lesson sat in mu-plugins.

The store had been live for six years. The double-charge had started the previous Sunday. By Tuesday it had hit eleven customers and shown up in three chargeback notices. The agency had already refunded everyone twice over (once on Stripe, once via a manual store credit) but they still did not know why the second charge was firing. They asked us to look because the legacy site they had inherited from a previous developer had a tangle of plugins they had never fully mapped.

This post walks through the trace that found it. The bug was a duplicate woocommerce_payment_complete hook registered by two separate plugins, and the path from "two charges on a card" to "the line in the plugin that did it" is more instructive than the fix itself.

Two intents, three seconds apart

Both payment intents had the same metadata.order_id. Both were succeeded. The first one fired at 21:14:08 UTC. The second one fired at 21:14:11 UTC. Three seconds apart. Same card, same amount, same statement descriptor. Different idempotency_key values. That last detail is the one that mattered: if the two requests had carried the same idempotency key, Stripe would have returned the cached response and only charged once. Two keys meant two distinct API calls. Stripe's idempotency contract is keyed on the header, not on the order number, so the WooCommerce side has to enforce uniqueness itself.

The first intent was created by the official woocommerce-gateway-stripe plugin. We confirmed that by reading the description field: it followed the gateway's template ("Order 48211 for store.example.com"). The second intent had a different description ("Capture for order 48211 (renewal-ready)"). That string did not exist anywhere in the official gateway source. Someone else had written it, and that someone was running on the same site.

Tracing the hook

WooCommerce's payment flow is documented in the gateway API reference. The relevant action is woocommerce_payment_complete, fired by WC_Order::payment_complete() when an order moves into processing or completed. Plugins listen on it to grant downloads, fulfil subscriptions, push to ERPs, send Klaviyo events, and so on. Anything with a side effect tends to live there.

We started where we always start on a multi-plugin site: a hook dump. On a staging mirror of the production database we added the following to wp-content/mu-plugins/000-pier-debug-hooks.php:

<?php
add_action('woocommerce_payment_complete', function ($order_id) {
    global $wp_filter;
    $callbacks = $wp_filter['woocommerce_payment_complete']->callbacks ?? [];
    error_log("[pier-trace] order=$order_id hook fired, registered callbacks:");
    foreach ($callbacks as $priority => $entries) {
        foreach ($entries as $key => $cb) {
            error_log("  priority=$priority callback=$key");
        }
    }
}, 1);

Priority 1 so it logs before anything else fires. Then we replayed the checkout against a Stripe test card (4242 4242 4242 4242) and tailed wp-content/debug.log. The output was:

[pier-trace] order=48312 hook fired, registered callbacks:
  priority=10 callback=WC_Subscriptions_Manager::process_subscription_payments_on_order
  priority=10 callback=wc_paying_customer
  priority=20 callback=ace_stripe_capture_renewal_fee
  priority=20 callback=woocommerce_gateway_stripe_capture_postpay

Two unfamiliar callbacks at priority 20. The first, ace_stripe_capture_renewal_fee, came from a plugin called ace-subscription-tools that the agency had inherited from a previous developer four years ago. The second, woocommerce_gateway_stripe_capture_postpay, lived in wp-content/mu-plugins/stripe-postpay-helper.php. A file no-one on the current team remembered writing or seeing in the deploy diffs.

Both of them, independently, called \Stripe\PaymentIntent::create() when the hook fired. Both succeeded. Hence two charges.

We confirmed sequencing by adding a microsecond-precision timestamp to the trace and re-running. The ace_stripe_capture_renewal_fee callback fired roughly six microseconds before the mu-plugin's. WordPress runs same-priority callbacks in insertion order, which mapped cleanly to plugin load order: ace-subscription-tools registered its hook on plugins_loaded, the mu-plugin registered on file include. That ordering is what told us which intent to refund and which to keep on each order in the backfill.

Origin of the mu-plugin

The mu-plugin had been added in 2022 to handle a one-off bug where a particular subscription product was not capturing its first invoice cleanly. The fix at the time was to listen on woocommerce_payment_complete and force a capture if the order metadata contained a specific flag. Six months later, the agency removed the flag from every product, and the function was supposed to short-circuit. Its original guard clause was:

if (get_post_meta($order_id, '_needs_postpay_capture', true) === 'yes') {
    capture_now($order);
}

Without the flag, the function should have returned early. Except that the previous April, someone had refactored capture_now to take the order itself, deleted the meta check while debugging, and committed without restoring it. The function then ran on every payment-complete event for every order. It only became visible when the ace-subscription-tools plugin shipped a 4.1 update on the previous Sunday that started using the same live Stripe secret key. Before that, the mu-plugin had been failing silently on a stale test key, so the second charge had been throwing a 401 and never landing on the card. The 4.1 update synced the key across plugins. That synchronisation is what turned a dormant bug into a live double-charge.

The patch, in the order that matters

The instinct on a billing incident is to delete the offending file and reload. We did not do that. Two charges had been shipping for nine days, which meant the mu-plugin was the second writer on a few dozen orders whose processing state implicitly assumed it had run. Pulling it out without a plan would leave those orders in a state nobody had modelled.

The patch was three steps, applied in this order:

  1. Stop the bleeding. Add a hard guard at the top of the mu-plugin's handler: if (defined('WC_PIER_DISABLE_POSTPAY') && WC_PIER_DISABLE_POSTPAY) return;. Set the constant in wp-config.php. New double-charges stop within one deploy, and you can flip the constant back without redeploying code if the rollback turns out to break something else.
  2. Make the customers whole. Query the Stripe API for every payment_intent created by the mu-plugin in the last fourteen days, group by metadata.order_id, and refund the second of each pair. We pinned this to the description string the mu-plugin had written, since that was the only thing distinguishing the two intents:
$stripe = new \Stripe\StripeClient(STRIPE_SECRET_KEY);
$cursor = null;
do {
    $page = $stripe->paymentIntents->all([
        'limit'         => 100,
        'created'       => ['gte' => strtotime('-14 days')],
        'starting_after'=> $cursor,
    ]);
    foreach ($page->data as $pi) {
        if (strpos($pi->description ?? '', 'renewal-ready') === false) continue;
        $stripe->refunds->create([
            'payment_intent' => $pi->id,
            'reason'         => 'duplicate',
            'metadata'       => ['refunded_by' => 'pier-incident-2026-06'],
        ]);
        // log to wp_pier_refund_audit for finance reconciliation
    }
    $cursor = end($page->data)->id ?? null;
} while ($page->has_more);
  1. Clean the wound. Only then, with a refund ledger in hand, delete the mu-plugin, update the runbook, and write a regression check that fails the build if any new callback registers on woocommerce_payment_complete without an explicit allow-list entry.

Order matters. Step 1 stops the bleeding. Step 2 makes the customer whole and produces an audit trail finance can reconcile against. Step 3 is the cleanup. Reversing 1 and 3 risks continuing to charge during reconciliation. Skipping 2 leaves you owing money you cannot match to invoices.

Hook hygiene that would have caught this

A few habits would have caught the double-charge earlier. None of them are exotic.

Treat payment-complete as a write boundary

Anything registered on woocommerce_payment_complete that talks to a payment processor should be treated as a write to the customer's bank. Two writes need one of three guards: an idempotency key derived from the order ID, a database-level "already-captured" flag checked inside a transaction, or a flat-out refusal to charge twice in the same request lifecycle. The official Stripe gateway uses the first. Most custom code uses none.

Audit every mu-plugin on inherited sites

Must-use plugins are invisible from the WordPress admin and load on every request before normal plugins. Any agency taking over a site should grep wp-content/mu-plugins/ on day one and write down, in English, what every file does. If no-one knows, the answer is "copy it to staging, comment out its hook registrations, watch for breakage for a week, then delete it in production." The cost of an unexpected mu-plugin running for years is measured in incidents like this one.

Log hook registrations on deploy

A two-line script that dumps the callback list for every WooCommerce action into a versioned file at deploy time would have surfaced this collision the moment the second key sync landed. The diff would have been one new line under woocommerce_payment_complete, and anyone reading the deploy log would have flagged it. WP_Hook exposes the list as $wp_filter['woocommerce_payment_complete']->callbacks, which is all you need to walk.

Scope API keys per integration

The double-charge here was latent for nine months because one plugin had a stale test key. The minute the ace-subscription-tools 4.1 release brought it onto the live key, the bug became visible. If you let every plugin read STRIPE_SECRET_KEY out of a shared constant, you inherit whatever assumption the last release shipped with. Prefer plugin-scoped settings that an integrator must explicitly populate, and document which one a given codepath is reading. The blast radius of a misregistered hook is bounded by which keys it can reach.

The audit, automated

When we built Pier we ran into this exact category of problem on enough legacy WordPress audits that we stopped treating it as ad-hoc. The way we ended up handling it: Pier docks with the FTP and MySQL of a WordPress install, lets you ask in plain English "what's listening on woocommerce_payment_complete across every plugin and mu-plugin," and keeps a version history of every file you touch so you can roll back a patch the moment something looks off. The MySQL editor on the same dock lets you scan the orders table for duplicate captures while you are at it.

If you only do one thing today, grep your own wp-content/mu-plugins/ directory and read every file in it. Most of them will be fine. The one that is not is worth finding before a customer's bank does.

— Questions —

Why didn't Stripe automatically block the second charge?

Stripe blocks duplicates only when the same idempotency key is reused on the request header. Two separate plugins generated two different keys, so Stripe treated them as distinct intents and processed both.

Can I just disable the second plugin and move on?

Not safely. If orders have been double-captured for days, downstream state (subscriptions, fulfilment flags) may already depend on both runs. Stop new charges first, refund the duplicates, then remove the code.

How do I check if my own WooCommerce site has a duplicate hook?

Add a tiny mu-plugin that logs $wp_filter['woocommerce_payment_complete']->callbacks the moment the action fires. Any unfamiliar callback that hits the Stripe API is the one to investigate.

Is this only a Stripe problem, or can it happen with Mollie and PayPal too?

It can happen with any gateway. The bug lives in WordPress hook resolution, not in Stripe. Mollie, PayPal, Adyen, and Klarna integrations all listen on the same action and are equally vulnerable.