Matomo Ecommerce Tracking 2026: Self-Hosted Setup Guide
If you enabled Matomo CE on a storefront and revenue isn’t showing up in reports, the problem isn’t your JavaScript snippet. Ecommerce tracking in Matomo is disabled per-site by default, and the toggle isn’t where most developers look. It’s not under Plugins. It’s not under Tracking Code. It’s under Administration → Websites → Manage → Edit Site → Ecommerce as a dropdown with three states (Disabled / Enabled with Cart Updates / Enabled without Cart Updates). One select, three states, zero documentation in the quickstart. About an hour of typical debugging time.
This guide walks the full Matomo CE ecommerce stack: the four JavaScript functions that cover 95% of e-commerce flows, what Matomo actually writes to the database, WooCommerce and Shopify integration patterns, the cart-abandonment trap most setups fall into, refund handling without a native API, and three SQL queries that beat the UI for high-volume stores. Prerequisite: Matomo CE 4.x or 5.x running on your own infrastructure. If you haven’t deployed yet, start with Matomo on Hetzner with MariaDB and Caddy.
The hidden setting: enabling ecommerce in Matomo CE
Administration → Websites → Manage → [edit your site] → Ecommerce: Yes (enabled).
This flips matomo_site.ecommerce = 1 in the database and unlocks three things: the Ecommerce menu in the left navigation, the setEcommerceView / addEcommerceItem / trackEcommerceOrder JavaScript API (calls are silently ignored if the site flag is off — the most common gotcha), and the idgoal IN (0, -1) rows actually being persisted to matomo_log_conversion.
// COUNTER-INTUITIVE
Matomo’s “Ecommerce Reports” is a built-in plugin that just renders the UI. The actual tracking feature is a per-site toggle in the Websites admin section. Most developers spend an hour searching the Plugins page for an “Enable Ecommerce” button that doesn’t exist.
The site’s currency is set in the same edit form (ISO 4217 code). The tracker does not accept a currency parameter on trackEcommerceOrder — all amounts are interpreted in the site’s configured currency. Multi-currency stores need either one Matomo site per currency or server-side currency normalization before tracking.
The four events that cover 95% of ecommerce flows
GA4 Enhanced Ecommerce ships 17 recommended events. Most stores use four of them. Matomo’s API was designed around the same four:
| Event | When to fire | Required params | Database table |
|---|---|---|---|
setEcommerceView() | Product detail page | SKU, name, [category], [price] | staged on next pageview |
addEcommerceItem() | Item added/updated in cart | SKU, name, category, price, qty | in-memory until cart flush |
trackEcommerceCartUpdate() | Cart change committed | grandTotal | log_conversion (idgoal=0) |
trackEcommerceOrder() | Purchase completed | orderId, grandTotal, [subTotal, tax, shipping, discount] | log_conversion (idgoal=-1) + log_conversion_item |
Two more exist for edge cases: removeEcommerceItem(SKU) and clearEcommerceCart(). You rarely need them — firing a fresh addEcommerceItem call with the same SKU overwrites the previous line, and trackEcommerceOrder clears the cart server-side automatically.
Custom events (_paq.push(['trackEvent', ...])) cannot populate the Ecommerce reports. If you push purchases as custom events you lose the entire Products / Categories / Days-to-Conversion / Visits-to-Conversion bundle and cannot reconcile against your store database. Use the dedicated ecommerce API.
What Matomo actually writes: schema deep-dive
Three tables hold the data. Default prefix is matomo_ (or piwik_ on legacy installs):
matomo_log_conversion— one row per cart update OR order.idgoal = 0means cart update,idgoal = -1means completed order. Goals you create in the admin UI start atidgoal = 1.matomo_log_conversion_item— one row per SKU per order, joined viaidorder+idsite. Holds price, quantity, deleted flag.matomo_log_visit— one row per visit.referer_type,referer_name,visit_entry_idaction_urlfor attribution joins.
Mechanism note: Matomo identifies a “cart” by visitor ID (cookie or Config ID heuristic). Each addEcommerceItem updates an in-memory cart on the visitor session. trackEcommerceCartUpdate flushes it as idgoal=0. trackEcommerceOrder flushes it as idgoal=-1 and clears the cart server-side. This means raw row count in log_conversion can grow fast on stores with chatty cart UIs — debounce 500ms or more, and aim for one cart-update row per cart page load, not per keystroke.
The 30-line JavaScript for a full purchase flow
Product detail page:
_paq.push(['setEcommerceView',
'SKU-12345', // productSKU (required, string)
'Blue T-Shirt', // productName (optional, false to skip)
['Apparel', 'Shirts'], // category — string OR array up to 5 levels
19.99 // price (optional)
]);
_paq.push(['trackPageView']); // MUST follow — setEcommerceView only stages
setEcommerceView does not send a request on its own. It attaches e_pv_* parameters to the next trackPageView. Forgetting trackPageView means silently zero product views. To skip optional fields, pass false, not empty string.
Cart update:
_paq.push(['addEcommerceItem',
'SKU-12345', 'Blue T-Shirt', ['Apparel', 'Shirts'], 19.99, 2 // SKU, name, cat, price, qty
]);
_paq.push(['addEcommerceItem',
'SKU-67890', 'Cap', ['Apparel'], 9.99, 1
]);
_paq.push(['trackEcommerceCartUpdate', 49.97]); // grand total of the cart
Re-push the entire cart on every cart-update event. addEcommerceItem with the same SKU overwrites the previous line, but SKUs not pushed before the next trackEcommerceCartUpdate remain in the in-memory cart. Best practice: call _paq.push(['clearEcommerceCart']) first if you’re rebuilding from scratch.
Order confirmation:
// Re-add all items server-side from the order DB, not from the cart cookie:
_paq.push(['addEcommerceItem', 'SKU-12345', 'Blue T-Shirt', ['Apparel'], 19.99, 2]);
_paq.push(['addEcommerceItem', 'SKU-67890', 'Cap', ['Apparel'], 9.99, 1]);
_paq.push(['trackEcommerceOrder',
'ORDER-7890', // unique orderId — REQUIRED for idempotency
49.97, // grandTotal — REQUIRED
44.97, // subTotal (excl. tax + shipping) — optional
3.20, // tax — optional
4.99, // shipping — optional
5.00 // discount as numeric amount — optional
]);
Discount gotcha: the 6th argument is documented as a discount amount but Matomo CE 5.x quietly accepts strings and discards them. Passing '10OFF' (a coupon code) results in zero discount in the report, while the order total is wrong. Pass numeric discount amount, store the coupon code as a Custom Dimension.
WooCommerce: hooking into the right actions
Two paths. The official Matomo for WordPress plugin auto-injects the tracker, ships a WooCommerce add-on for ecommerce events, and handles the consent integration. For most WP stores, that’s the entire setup. If you want explicit control or you’re not running the plugin, the manual hook pattern is fifteen lines:
// Order tracking via woocommerce_thankyou
add_action('woocommerce_thankyou', function($order_id) {
$order = wc_get_order($order_id);
if (!$order || $order->get_meta('_matomo_tracked')) return;
$items_js = '';
foreach ($order->get_items() as $item) {
$product = $item->get_product();
$sku = $product ? $product->get_sku() : '';
$items_js .= sprintf(
"_paq.push(['addEcommerceItem', %s, %s, false, %.2f, %d]);\n",
wp_json_encode($sku),
wp_json_encode($item->get_name()),
$order->get_item_total($item, false, false),
$item->get_quantity()
);
}
echo "<script>{$items_js}";
printf(
"_paq.push(['trackEcommerceOrder', %s, %.2f, %.2f, %.2f, %.2f, %.2f]);</script>",
wp_json_encode($order->get_order_number()),
$order->get_total(),
$order->get_subtotal(),
$order->get_total_tax(),
$order->get_shipping_total(),
$order->get_total_discount()
);
$order->update_meta_data('_matomo_tracked', 1);
$order->save();
}, 10, 1);
The _matomo_tracked order meta flag is critical. Without it, every browser refresh of /checkout/order-received/ re-fires the order — the single largest source of duplicate-order complaints in self-hosted Matomo deployments.
For product views, hook woocommerce_after_single_product. For cart updates, the reliable points are woocommerce_add_to_cart, woocommerce_cart_item_removed, and woocommerce_after_cart_item_quantity_update — three discrete events. Avoid woocommerce_cart_updated; it fires on every page where the cart loads, including unrelated views.
Shopify integration via Customer Events Pixel
Shopify deprecated direct script injection in checkout.liquid in 2023; the migration deadline was August 2024. New stores must use the Customer Events / Web Pixel API, which runs in a sandboxed iframe with no window, no document, and no _paq access in main page context.
The fix is to bypass _paq entirely and post directly to Matomo’s HTTP Tracking API:
// Settings → Customer Events → Add custom pixel
analytics.subscribe('checkout_completed', (event) => {
const checkout = event.data.checkout;
const items = checkout.lineItems.map(li => [
li.variant.sku,
li.title,
'', // category
li.variant.price.amount,
li.quantity
]);
const params = new URLSearchParams({
idsite: '1',
rec: '1',
idgoal: '0', // 0 = ecommerce flag
ec_id: checkout.order.id,
revenue: checkout.totalPrice.amount,
ec_st: checkout.subtotalPrice.amount,
ec_tx: checkout.totalTax.amount,
ec_sh: checkout.shippingLine?.price?.amount || '0',
ec_items: JSON.stringify(items),
url: event.context.document.location.href,
});
fetch(`https://matomo.example.com/matomo.php?${params}`, { mode: 'no-cors' });
});
Two limitations to know about. The Web Pixel context strips client IP, so geolocation and visitor identification break unless you also enable Matomo’s ServerSideTracker plugin and inject IP from Shopify’s orders/create webhook server-side. And Shopify does not guarantee 100% pixel fire on slow connections — for revenue source-of-truth, mirror via the Shopify Admin webhook to a server endpoint that posts to /matomo.php.
First-party cookies and the ad-blocker bypass
The default Matomo endpoint (matomo.example.com/matomo.php) is on every ad-blocker filter list. uBlock Origin matches the /matomo.php path, Brave Shield matches the subdomain pattern, NextDNS matches at the resolver level. Block-rate in tech-savvy audiences runs 25-35%.
The fix is a CNAME on your storefront domain that proxies to your Matomo host. analytics.shop.example.com CNAME-pointed at matomo.example.com. The endpoint is now first-party, off the standard filter lists, and cookies set by Matomo are first-party cookies (which Safari ITP treats with much longer lifetimes than third-party).
If you can’t CNAME (DNS politics, Cloudflare orange-cloud routing), the fallback is reverse-proxying through your origin web server:
location = /js/matomo.js {
proxy_pass https://matomo.example.com/matomo.js;
proxy_set_header Host matomo.example.com;
proxy_cache_valid 200 6h;
}
location = /matomo.php {
proxy_pass https://matomo.example.com/matomo.php;
proxy_set_header Host matomo.example.com;
proxy_set_header X-Forwarded-For $remote_addr;
}
Then change your tracker URL in the snippet to your own domain (https://shop.example.com/matomo.php). The X-Forwarded-For header is mandatory — without it, all visitors collapse to one IP and the visitor counter shows one user. The same pattern applies to other self-hosted analytics tools; the cookieless recipes pillar covers Plausible, Umami, and PostHog with the equivalent reverse-proxy patterns.
The trackEcommerceCartUpdate trap
Matomo’s abandoned-cart report counts visits where idgoal = 0 exists but no subsequent idgoal = -1 within the same visit. The trap: every trackEcommerceCartUpdate call writes a fresh row, and the report uses the most recent one to compute “cart age.” If you fire on every quantity change, every product addition, every coupon application, the cart age stays at zero forever and the abandonment timer never trips.
// PITFALL
Firing trackEcommerceCartUpdate on every cart change resets the abandonment timer. Reports show 0% abandonment and the email automation never triggers. Fix: debounce 30 seconds, or fire only on cart-page load and on beforeunload.
The detection query, run hourly via cron, looks for visits with cart but no order in the last 25 hours:
SELECT
lv.idvisit,
lv.user_id, -- if you setUserId(email)
lc.revenue AS cart_total,
MAX(lc.server_time) AS last_cart_update
FROM matomo_log_conversion lc
JOIN matomo_log_visit lv ON lc.idvisit = lv.idvisit
WHERE lc.idgoal = 0
AND lc.server_time BETWEEN NOW() - INTERVAL 25 HOUR AND NOW() - INTERVAL 1 HOUR
AND lc.revenue > 0
AND NOT EXISTS (
SELECT 1 FROM matomo_log_conversion lc2
WHERE lc2.idvisit = lc.idvisit AND lc2.idgoal = -1
)
GROUP BY lv.idvisit;
The user_id column is critical. Without _paq.push(['setUserId', '[email protected]']) on the cart page (or earlier, at email capture), you have idvisit but no email to trigger the cart-recovery campaign. Set User ID at login or at the email-capture step in checkout.
Refunds, chargebacks, and the lack of a refund API
Matomo CE has no refund API. Three workarounds, ranked by accuracy:
Option A — negative-revenue order. Fire trackEcommerceOrder with a distinct order ID and negative grand total:
_paq.push(['trackEcommerceOrder',
'REFUND-ORDER-7890', // distinct ID, NOT the original
-49.97, // negative grand total
-44.97, -3.20, -4.99, 0
]);
Matomo CE versions before 4.14 reject negative revenue silently; 4.14 and later accept it. Trade-off: average order value gets skewed (mean of [+49.97, -49.97] = 0, which hides two transactions), and reverse-attribution credits whatever source the user is currently on, not the original purchase source.
Option B — Custom Dimension order_status. Define an action-scope Custom Dimension order_status with values paid / refunded / chargeback. On refund, push a synthetic pageview to /refund/ORDER-7890 with the dimension value set. Doesn’t subtract from revenue total but keeps an auditable trail. Pivot via the Custom Reports plugin.
Option C — direct SQL. The only approach that yields accurate retroactive revenue reports:
UPDATE matomo_log_conversion
SET revenue = 0, custom_dimension_1 = 'refunded'
WHERE idsite = 1 AND idorder = 'ORDER-7890';
-- Invalidate the report archives so reports recompute:
DELETE FROM matomo_archive_numeric_2026_05
WHERE idsite = 1 AND name LIKE 'Ecommerce%';
Then re-run ./console core:archive. Back up first; you own the data integrity.
GDPR: ecommerce without a consent banner
Matomo qualifies for the EU “anonymous audience measurement” exemption when configured with IP anonymization, no third-party data sharing, and disableCookies. The full setup:
_paq.push(['disableCookies']);
_paq.push(['setDoNotTrack', true]);
_paq.push(['disableBrowserFeatureDetection']);
// Ecommerce calls work normally:
_paq.push(['setEcommerceView', 'SKU-12345', ...]);
_paq.push(['addEcommerceItem', ...]);
_paq.push(['trackEcommerceOrder', ...]);
Plus the server-side settings: Privacy → Anonymize Visitors’ IPs (mask 2 bytes), and System → General → Tracker → Use third-party cookies: OFF.
What works in cookieless ecommerce: single-pageview orders track normally, because Matomo uses a Config ID heuristic (UA + IP + plugins hash) for visitor identity within a single 30-minute visit window. What breaks: cart-to-checkout across sessions (user adds items today, returns tomorrow to buy — different visitors), returning-customer detection (every visit is “new visitor”), and abandoned-cart email triggers (no idvisit continuity). The mitigation is setUserId(email) for known users, which restores attribution for logged-in customers while leaving anonymous traffic cookieless. Full posture explained in 5 self-hosted cookieless tracking recipes — Matomo is recipe #2.
Three SQL queries that beat the UI
The Matomo dashboard struggles past 100k orders per month. Direct queries against the log tables are faster and let you join store data outside Matomo. Connect with mysql -u matomo -p matomo on your VPS.
Top SKUs by revenue, last 30 days:
SELECT
lci.sku,
lci.name,
SUM(lci.quantity) AS units_sold,
SUM(lci.price * lci.quantity) AS gross_revenue
FROM matomo_log_conversion_item lci
JOIN matomo_log_conversion lc ON lci.idorder = lc.idorder AND lci.idsite = lc.idsite
WHERE lc.idgoal = -1
AND lci.deleted = 0
AND lc.server_time >= NOW() - INTERVAL 30 DAY
GROUP BY lci.sku, lci.name
ORDER BY gross_revenue DESC
LIMIT 50;
Revenue by acquisition channel, last 30 days:
SELECT
CASE lv.referer_type
WHEN 1 THEN 'Direct'
WHEN 2 THEN 'Search'
WHEN 3 THEN 'Website'
WHEN 6 THEN 'Campaign'
ELSE 'Other'
END AS channel,
lv.referer_name,
COUNT(DISTINCT lc.idvisit) AS orders,
SUM(lc.revenue) AS revenue,
SUM(lc.revenue) / COUNT(DISTINCT lc.idvisit) AS aov
FROM matomo_log_conversion lc
JOIN matomo_log_visit lv ON lc.idvisit = lv.idvisit
WHERE lc.idgoal = -1
AND lc.server_time >= NOW() - INTERVAL 30 DAY
GROUP BY lv.referer_type, lv.referer_name
ORDER BY revenue DESC
LIMIT 30;
Top entry pages by conversion rate:
SELECT
a.name AS landing_url,
COUNT(DISTINCT lv.idvisit) AS visits,
COUNT(DISTINCT lc.idvisit) AS orders,
ROUND(COUNT(DISTINCT lc.idvisit) * 100.0 / COUNT(DISTINCT lv.idvisit), 2) AS conv_rate_pct
FROM matomo_log_visit lv
JOIN matomo_log_action a ON lv.visit_entry_idaction_url = a.idaction
LEFT JOIN matomo_log_conversion lc ON lc.idvisit = lv.idvisit AND lc.idgoal = -1
WHERE lv.visit_first_action_time >= NOW() - INTERVAL 30 DAY
GROUP BY a.idaction, a.name
HAVING visits >= 100
ORDER BY conv_rate_pct DESC
LIMIT 20;
Performance hint: on stores larger than 10M conversion rows, add a covering index on matomo_log_conversion (idsite, server_time, idgoal). The default schema indexes by (idsite, idvisit) only.
Common pitfalls
1. trackEcommerceOrder re-fires on thank-you-page refresh. The woocommerce_thankyou hook runs on every page load. Without an order-meta flag (the _matomo_tracked pattern in section 5), one customer pressing F5 generates duplicate orders.
2. trackEcommerceCartUpdate without prior addEcommerceItem. Logs an empty cart with revenue zero. Always push items first, even on cart-page reload.
3. Currency mismatch. The currency is per-site, not per-event. Multi-currency stores either use one Matomo site per currency or normalize all amounts server-side before tracking.
4. Discount as string. '10OFF' coupon codes silently become zero discount. Pass numeric amount; store the code as a Custom Dimension.
5. WooCommerce guest checkout cookie clearing. If the user clears cookies between cart and thank-you (incognito-mode-shoppers, privacy-extension-users), Matomo records the order but loses attribution — the order appears as a brand-new visit. Mitigation: fire from woocommerce_new_order server-side via the Tracking HTTP API as a backup.
6. Bot-inflated conversions. Without enabling Matomo’s bot filter, scrapers and automated checkout-testing scripts generate fake orders. Privacy → Settings → Exclude Bots: ON.
7. Ad-blockers stripping the tracker. Even with first-party CNAME, the script URL must also be relative or first-party. The full reverse-proxy pattern is in section 7. Without it, block-rate stays at 25-35% in tech audiences.
FAQ
Does Matomo Ecommerce work on the free Cloud plan? Yes, but with monthly tracked-action quotas that small stores hit quickly. Self-hosted CE is unlimited — one Hetzner CX22 (€4.51/month) handles roughly 5M monthly events comfortably. The Matomo on Hetzner recipe covers the deploy.
Can I import historical GA4 ecommerce data? No. The schemas are too different to map cleanly. The standard approach is running both in parallel for 30 days, then cutting over to Matomo as source of truth.
How does Matomo handle multi-currency stores? Per-site currency setting, no auto-conversion. Either run one Matomo site per currency or normalize all amounts to a base currency in your backend before calling trackEcommerceOrder.
Does this work with WooCommerce Subscriptions? Yes. Hook woocommerce_subscription_renewal_payment_complete the same way as woocommerce_thankyou but without the duplicate-fire problem (renewals are server-side webhook events, not page loads).
Is server-side ecommerce tracking possible? Yes, via the Matomo Tracking HTTP API — the same endpoint you use for client-side, called from your backend with proper headers. The pattern is identical to the Stripe webhook example in Plausible Custom Events + Revenue Tracking — only the parameter names differ.
// NEXT STEPS
For full revenue tracking on a tool you don’t have yet, see the 5 self-hosted cookieless tracking recipes pillar (Matomo is recipe #2). If your tag stack is growing past Matomo Tag Manager, the 7 self-hosted tag manager alternatives guide covers Matomo TM, Cloudflare Zaraz, and self-hosted sGTM.
Found this useful?
Try the Stack Picker to get a personal recommendation, or browse the install recipe library.