When a WooCommerce Staging Clone Starts Charging Real Customers
· 12 min read
A Friday Afternoon Refund Request
A client forwarded me a customer complaint. The customer had been charged twice for the same order — once on the live site and once from a URL they didn't recognise. The email confirmation had the right order details, right product names, right price — but the links all pointed to staging.clientdomain.com.
I checked the client's WooCommerce dashboard. No duplicate order there. Then I SSH'd into their server and found a second WordPress installation on a staging subdomain. A developer had cloned the production site two weeks earlier to test a theme redesign. Full database dump, full wp-content copy, pointed at the same Stripe account, the same SendGrid API key, and the same WooCommerce webhook endpoints.
The staging site had been silently processing real transactions for fourteen days.
The Four-Way Bleed
When you clone a WooCommerce production database to staging without changing anything, you inherit every live integration the site has. On this site, four separate systems were leaking production traffic.
Live payment gateway
Stripe API keys live in wp_options. The clone had the production publishable key and secret key. Any test purchase on staging — or any real customer who somehow landed on the staging URL — got charged to the live Stripe account.
wp option get woocommerce_stripe_settings --path=/home/staging/htdocs --format=json | python3 -m json.tool | grep -E "testmode|secret_key"
"testmode": "no",
"secret_key": "sk_live_..."
Live mode. Production keys. On a staging site with no SSL certificate and a default robots.txt.
Live transactional email
The site used WP Mail SMTP with a SendGrid API key. Every WooCommerce email — order confirmations, shipping notifications, password resets — was going out through the production mail pipeline. The only difference was the URLs in the email body, which pointed to the staging domain.
Customers were receiving emails from what appeared to be the real store, with links that went to a half-finished theme redesign. Some clicked the staging links and placed orders there, thinking it was the real site with a new look.
Live webhooks
WooCommerce webhooks are stored in the posts table with post type shop_webhook. The clone had three active webhooks pushing order data to the client's fulfilment system and accounting software. Every test order the developer placed was also being sent to the warehouse as a real order to be packed and shipped.
I listed the active webhooks:
wp db query "SELECT ID, post_title, post_status FROM wp_posts WHERE post_type = 'shop_webhook' AND post_status = 'publish';" --path=/home/staging/htdocs
+-----+---------------------------+-------------+
| ID | post_title | post_status |
+-----+---------------------------+-------------+
| 891 | Order to Fulfilment | publish |
| 892 | Order to Xero | publish |
| 893 | Customer to Mailchimp | publish |
+-----+---------------------------+-------------+
Three webhooks, all active, all pushing staging data into production systems.
Live cron and Action Scheduler
DISABLE_WP_CRON was not set in the staging wp-config.php. WP-Cron was firing on every page load, and Action Scheduler was processing its queue normally. The staging site had inherited the production schedule — subscription renewals, abandoned cart recovery emails, review request emails, and a scheduled sale price change were all queued up and running.
wp action-scheduler run --path=/home/staging/htdocs --dry-run 2>&1 | head -20
The dry run showed 47 pending actions, including three subscription renewal charges that had already processed against real customer credit cards.
The Immediate Triage
I needed to stop the bleeding before investigating further.
Step 1: Force Stripe into test mode.
// wp-content/mu-plugins/staging-lockdown.php
<?php
add_filter('option_woocommerce_stripe_settings', function($settings) {
$settings['testmode'] = 'yes';
$settings['test_publishable_key'] = '';
$settings['test_secret_key'] = '';
return $settings;
});
This intercepts the Stripe settings at the option level. Even if someone toggles test mode off in the dashboard, it gets forced back on.
Step 2: Kill all outbound email.
// Added to the same mu-plugin
add_filter('pre_wp_mail', '__return_false');
One line. pre_wp_mail was introduced in WordPress 5.7 and short-circuits wp_mail() before it does anything. Returning false silently drops every outgoing email. No bounces, no errors, no customer confusion.
Step 3: Disable WP-Cron.
Added to wp-config.php:
define('DISABLE_WP_CRON', true);
Step 4: Trash all webhooks.
wp db query "UPDATE wp_posts SET post_status = 'trash' WHERE post_type = 'shop_webhook';" --path=/home/staging/htdocs
I trashed rather than deleted so we could review them later if needed.
Step 5: Coordinate refunds.
Working with the client, we identified six real customer charges that originated from staging. All were refunded through Stripe within the hour. Two fulfilment orders that had been picked and packed from webhook data were caught before shipping.
Why WP_ENVIRONMENT_TYPE Alone Is Not Enough
WordPress has had the WP_ENVIRONMENT_TYPE constant since version 5.5. You set it in wp-config.php:
define('WP_ENVIRONMENT_TYPE', 'staging');
Some plugins check this value and adjust their behaviour — disabling analytics, switching to sandbox modes, suppressing emails. But "some" is the operative word. In my experience managing 70+ sites, the coverage is patchy:
- WooCommerce core does not disable live payment gateways based on
WP_ENVIRONMENT_TYPE - Stripe for WooCommerce does not automatically switch to test mode
- WP Mail SMTP does not suppress emails
- Action Scheduler keeps processing regardless
- WooCommerce webhooks keep firing
Setting WP_ENVIRONMENT_TYPE is necessary but nowhere near sufficient. You need a belt-and-braces approach.
The Staging Lockdown mu-plugin
After this incident, I built a single mu-plugin that I now deploy to every staging clone. It checks WP_ENVIRONMENT_TYPE and, if the value is not production, locks down everything dangerous:
<?php
/**
* Plugin Name: Staging Lockdown
* Description: Disables live payments, emails, webhooks, and cron on non-production environments.
*/
if (wp_get_environment_type() === 'production') {
return;
}
// Block all outbound email
add_filter('pre_wp_mail', '__return_false');
// Force Stripe test mode
add_filter('option_woocommerce_stripe_settings', function($settings) {
if (is_array($settings)) {
$settings['testmode'] = 'yes';
}
return $settings;
});
// Force PayPal sandbox mode
add_filter('option_woocommerce_paypal_settings', function($settings) {
if (is_array($settings)) {
$settings['testmode'] = 'yes';
}
return $settings;
});
// Disable WooCommerce webhooks from firing
add_filter('woocommerce_webhook_should_deliver', '__return_false');
// Disable Action Scheduler async runner
add_filter('action_scheduler_allow_async_request_runner', '__return_false');
// Add admin notice so developers know the lockdown is active
add_action('admin_notices', function() {
echo '<div class="notice notice-warning"><p>';
echo '<strong>Staging lockdown active.</strong> ';
echo 'Emails, live payments, webhooks, and async scheduling are disabled.';
echo '</p></div>';
});
Drop this file into wp-content/mu-plugins/staging-lockdown.php and it handles the common leak vectors. mu-plugins load before regular plugins and cannot be deactivated from the dashboard, so a developer can't accidentally disable it.
The Clone Checklist
Beyond the mu-plugin, I run through this checklist every time I clone a WooCommerce site to staging. I keep it as a script, but here it is as a manual sequence:
1. Set the environment type in wp-config.php:
define('WP_ENVIRONMENT_TYPE', 'staging');
define('DISABLE_WP_CRON', true);
2. Deploy the staging lockdown mu-plugin (above).
3. Swap SMTP credentials for a mail trap. I use Mailtrap — it catches all outbound email in a web inbox without delivering anything. Even though the mu-plugin blocks wp_mail(), some plugins bypass it with direct SMTP calls or PHP's native mail(). A trapped SMTP server catches those too.
4. Deregister WooCommerce webhooks:
wp db query "UPDATE wp_posts SET post_status = 'trash' WHERE post_type = 'shop_webhook';" --path=/home/staging/htdocs
5. Block search engine indexing:
wp option update blog_public 0 --path=/home/staging/htdocs
6. Restrict access. Either password-protect the staging URL at the Nginx level:
location / {
auth_basic "Staging";
auth_basic_user_file /etc/nginx/.htpasswd;
try_files $uri $uri/ /index.php?$args;
}
Or restrict by IP if the development team is on a fixed range.
7. Update home and siteurl to the staging domain (you should be doing this anyway, but it bears stating — a staging site that thinks it's the production domain will generate canonical URLs, Open Graph tags, and sitemap entries that compete with your live site in search engines).
wp search-replace 'https://clientdomain.com' 'https://staging.clientdomain.com' --all-tables --path=/home/staging/htdocs
The Bit That Everyone Forgets
The webhook configuration in your Stripe dashboard is separate from WooCommerce. When you clone a WooCommerce site, you don't clone the Stripe webhook endpoints — those live in Stripe's dashboard, not in your database. But here is the problem: if the production Stripe webhook endpoint is configured to send events to https://clientdomain.com/?wc-api=wc_stripe, and your staging site is using the same Stripe API keys, then Stripe will send webhook events to the production endpoint only. That sounds safe.
But if a developer configures a new Stripe webhook endpoint in the dashboard pointing to the staging URL, or if the staging site re-registers its own endpoint through the plugin's setup wizard, you now have two endpoints receiving the same events. The production site gets the webhook, processes the order. The staging site also gets the webhook, tries to process the same order, fails because the order ID doesn't exist in its database — and logs a stream of errors that fill up your disk.
Check your Stripe webhook endpoints after every clone:
curl https://api.stripe.com/v1/webhook_endpoints \
-u sk_live_your_key_here: \
-G
If you see an endpoint pointing to a staging URL, delete it immediately.
What This Actually Cost
The direct cost was six refunded transactions totalling around £340, plus staff time to repack two fulfilment orders. The indirect cost was harder to measure — customer confusion from emails with staging URLs, a brief loss of trust, and several hours of my time and the client's time coordinating the cleanup.
All of it was preventable with a two-minute checklist run after cloning.
This is the kind of silent failure that proactive maintenance is designed to catch. Not a dramatic hack, not a server crash — just an overlooked step in a routine operation that quietly causes real damage for two weeks before anyone notices.
Stop Firefighting. Start Maintaining.
I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.
View Maintenance Plans | Get in Touch
If you're dealing with WooCommerce webhook issues specifically, you might also want to read how I traced 23 missing orders to a broken Stripe webhook — a related but different failure mode where webhooks silently stop working instead of firing twice.
