← Blog

E-commerce

WooCommerce audit playbook: 45 minutes to five money leaks

The agency stopped replying. You got SSH credentials at 10pm. Here is the 45-minute audit we run on every inherited WooCommerce store: queries, diff, fixes.

Jacob Molkenboer· Founder · A Brand New Company· 4 Jun 2026· 9 min
Half-unwrapped craft-paper parcel, brass scale, chartreuse wax seal on receipt, linen twine on ivory blotter.

A founder messages you on a Tuesday at 10pm. "The checkout has been broken since Sunday. The agency stopped replying in March. We have €180k pending in carts. Can you look?" You ask for SSH, database credentials, and the wp-admin login. Forty-five minutes later you have a list of what is bleeding, what is dangerous, and what to fix tonight. This is the playbook we use, in the order we run it.

What you need before the timer starts

Five things. SSH access to the server, database credentials (read-only is enough), a wp-admin account with the administrator role, the current payment-provider dashboard (Stripe, Mollie, Adyen), and Google Analytics or the equivalent. If you have all five you can finish in 45 minutes. If you are missing one, the audit becomes a day because you spend most of it chasing the previous developer.

Open three terminal tabs and a notes file. One tab for SSH, one for a MySQL client, one for git. The notes file captures every odd finding with a query result, a line number, or a path. No screenshots without context. The output of this audit is a single one-page document the founder reads in three minutes, so structure your notes that way as you go.

The database tells the truth first

WordPress admins lie. Plugins report themselves active when they crashed at boot. Themes report the wrong version. The database is where the real story lives. Start there before you click anything in wp-admin.

First query: how big is the orders table, and what is the spread of statuses?

SELECT post_status, COUNT(*) AS orders,
       MIN(post_date) AS first_order,
       MAX(post_date) AS last_order
FROM wp_posts
WHERE post_type = 'shop_order'
GROUP BY post_status
ORDER BY orders DESC;

This tells you three things in one shot. The total order count, which you should sanity-check against the dashboard total. The proportion stuck in wc-pending or wc-failed (anything above 8% is a payment integration issue, not user error). And the date of the last successful order. If last_order on wc-completed is three days ago and the store gets daily traffic, the checkout is broken and you have your first finding before minute ten.

Next, total up the failed-order pile in euros. The session table is interesting but noisy. Failed orders with line items are the real signal.

SELECT SUM(CAST(pm.meta_value AS DECIMAL(10,2))) AS lost_revenue,
       COUNT(DISTINCT p.ID) AS failed_orders
FROM wp_posts p
JOIN wp_postmeta pm ON pm.post_id = p.ID
WHERE p.post_type = 'shop_order'
  AND p.post_status = 'wc-failed'
  AND pm.meta_key = '_order_total'
  AND p.post_date > DATE_SUB(NOW(), INTERVAL 30 DAY);

If the failed-orders total over thirty days exceeds 5% of completed-order revenue, the payment gateway is misconfigured or a plugin is throwing a fatal at the wrong moment in the checkout flow. We see this on roughly half the stores we inherit. The fix is usually either a stale API key on the gateway or a custom theme override calling a removed action hook.

Third query, the one most agencies skip: how many products have zero physical stock but are still purchasable?

SELECT p.ID, p.post_title,
       MAX(CASE WHEN pm.meta_key = '_stock' THEN pm.meta_value END) AS stock,
       MAX(CASE WHEN pm.meta_key = '_stock_status' THEN pm.meta_value END) AS status,
       MAX(CASE WHEN pm.meta_key = '_backorders' THEN pm.meta_value END) AS backorders
FROM wp_posts p
JOIN wp_postmeta pm ON pm.post_id = p.ID
WHERE p.post_type = 'product'
  AND p.post_status = 'publish'
GROUP BY p.ID
HAVING stock = '0' AND status = 'instock' AND backorders = 'no';

Every row in that result is an order the warehouse cannot ship. The customer paid. You owe a refund and an apology. Some of these have been live for a year because the stock sync from the ERP fails silently when the SKU contains a slash.

The plugin diff nobody runs

Switch to the SSH tab. Go to wp-content/plugins. You want WP-CLI installed; if it is not, install it before you start. Then run this one-liner:

for d in */; do
  plugin="${d%/}"
  active=$(wp plugin is-active "$plugin" --quiet && echo "ON" || echo "off")
  version=$(wp plugin get "$plugin" --field=version 2>/dev/null)
  updated=$(stat -c %y "$d" 2>/dev/null | cut -d' ' -f1)
  printf "%-3s  %-40s  %-12s  %s\n" "$active" "$plugin" "$version" "$updated"
done | sort -k4

The output sorts by last-modified date. Plugins that have not been touched in three years are vulnerability candidates. Plugins active on disk but missing from the admin list are usually mu-plugins or dropins that bypass the update flow. Both are interesting for different reasons.

Cross-check every plugin against the Wordfence vulnerability database. We look specifically for anything in WooCommerce's payment, shipping, tax, or coupon paths. Older woocommerce-gateway-stripe releases had quirks handling 3DS callbacks; older Mollie plugins occasionally lose the order ID on redirect when a CDN strips query strings. Both surface as failed orders that look mysterious in the dashboard and obvious in wp-content/uploads/wc-logs/.

Then run the dependency diff. The plugin's manifest tells you what WooCommerce core version it expects. If the gap is more than two minor versions, something has been hot-patched by the previous developer. Look inside mu-plugins for files named fix-checkout.php, custom.php, or tmp.php. We have inherited single-file 800-line hotfixes more than once. They are always undocumented and always load-bearing.

Warning

If you find a file in mu-plugins that touches woocommerce_payment_complete or woocommerce_order_status_changed, do not delete it before you read it. It is probably the only thing keeping the accounting export, the warehouse webhook, or the loyalty plugin alive.

The five settings that quietly leak money

This is the bit we made a checklist for. Every inherited WooCommerce store leaks in at least two of these five places. Half leak in four. None of them show up in any dashboard, because they are configuration, not error.

1. Tax rounding at line vs subtotal

WooCommerce, Settings, Tax, Rounding. If "Round tax at subtotal level" is unchecked, taxes round per line item. On a B2B store selling 40-line invoices, the cumulative rounding error reaches around €0.30 per order. On 3,000 orders a month that is €900 the bookkeeper has to reconcile every month. Flip it on. The correct setting depends on the country's invoice law, but in the EU subtotal-level rounding matches how accountants reconcile against the bank statement.

2. Coupon stacking

"Individual use only" is a per-coupon flag, not a global one. Query the coupons table:

SELECT p.post_title AS code,
       MAX(CASE WHEN pm.meta_key = 'individual_use' THEN pm.meta_value END) AS individual,
       MAX(CASE WHEN pm.meta_key = 'usage_count' THEN pm.meta_value END) AS used,
       MAX(CASE WHEN pm.meta_key = 'coupon_amount' THEN pm.meta_value END) AS amount
FROM wp_posts p
JOIN wp_postmeta pm ON pm.post_id = p.ID
WHERE p.post_type = 'shop_coupon'
  AND p.post_status = 'publish'
GROUP BY p.ID
ORDER BY used DESC;

Any coupon with individual = no and a high used count is being stacked with the welcome discount and the seasonal sale. On a fashion store we audited in April, three stackable codes had combined to give 47% off on a third of orders for six weeks. Nobody had noticed because each individual code looked sensible.

3. Hold-stock minutes

Default is 60 minutes. On a fast store with limited inventory, abandoned carts hold real stock for an hour. Customers arrive, see "out of stock", bounce to a competitor. Drop the hold to 15 minutes for low-stock SKUs and leave 60 for high-volume ones. The setting lives at Settings, Products, Inventory.

4. Email recipients on the new-order notification

This sounds trivial. It is not. We have inherited stores where the new-order email goes to info@oldagency.nl and the warehouse has been working from a CSV the founder exports every morning. Orders ship two days late and the old agency mailbox bounces silently because the domain expired. Check Settings, Emails, New order, Recipient. If it is anything other than a live mailbox at the client's domain (or a shared warehouse mailbox), fix it before you close the laptop.

5. Webhooks pointing at dead endpoints

Settings, Advanced, Webhooks. Every dead webhook is a Klaviyo flow, an ERP sync, or a Slack notification that has been failing for months. The delivery log table tells you which:

SELECT webhook_id, response_code, COUNT(*) AS deliveries
FROM wp_wc_webhook_delivery_log
WHERE timestamp > DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY webhook_id, response_code
ORDER BY webhook_id, deliveries DESC;

Anything stuck above 4xx means a downstream system has been blind for at least a week. Disable the dead webhooks before you do anything else. The delivery log table grows fast and slows the entire order pipeline once it crosses a few million rows.

What the 45 minutes buys you

By minute 45 you have: an order-status summary, a failed-order total in euros, a list of overselling SKUs with row counts, a vulnerability flag count, a list of undocumented hotfixes in mu-plugins, and the five-settings findings with current values and recommended values. That fits on one A4 page. Send it to the founder before you negotiate scope. You will know whether this is a one-day clean-up or a three-month rescue, and so will they.

The pattern we see most: a store that turned over €2M last year, lost roughly €40k to misconfigured payment retries, €15k to overselling that became refund tickets, and an unknown amount of customer trust to a checkout flow that breaks for around 6% of carts. None of that is in any dashboard. It is all in the database, and most of it can be fixed in the first week.

When we ran this audit for a Dutch home-goods brand last month, the tax-rounding setting alone reconciled a €600 monthly accounting gap the bookkeeper had been writing off as "noise". That is one setting. We do this kind of legacy-site audit and rescue as a fixed-scope week, so the operations lead knows the bill before we start and the founder gets the one-page document on Friday.

The smallest thing you can do today: run the first query on this page against your own store. If wc-failed orders are above 3% of wc-completed over the last thirty days, your checkout is leaking and you have until tomorrow morning to find out where.

Key takeaway

WordPress lies, plugins lie, dashboards round. The database, the filesystem, and the webhook log are the only three places that tell the truth about a WooCommerce store.

FAQ

Can I run this audit on a live production database?

Yes. Every query in the playbook is read-only and runs against indexed columns. Cap the date range to 30 days if the orders table is over a million rows so you do not block the order queue.

What if I do not have SSH access, only wp-admin?

Install a database-console plugin like Query Monitor or use the Site Health tool to export PHP info. You lose the plugin diff and the mu-plugins inspection, which is where most of the dangerous code hides.

How often should we re-run this audit on a healthy store?

Quarterly for the five settings, monthly for the failed-orders query, and after every plugin or theme update for the webhook delivery log. The whole sweep takes 20 minutes once you have done it twice.

Why not just use a paid WooCommerce health-check plugin?

Those plugins read what WordPress reports about itself. The point of this audit is that WordPress lies. The database, the filesystem, and the delivery log tell you what is actually happening.

e-commercewordpressmysqllegacy sitesoperationsmigration

Building something?

Start a project