Skip to content
$_ setuptracking
Tracking

Matomo Ecommerce Tracking 2026: WooCommerce + Shopify Self-Hosted

24 min read
Server rack cluster representing self-hosted Matomo ecommerce tracking infrastructure

Your WooCommerce store is quietly losing 30–40% of attribution data because Matomo’s ecommerce toggle is buried four menus deep. Not under Plugins. Not under Tracking Code. The setting lives at 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. Flip it in 90 seconds — then read the rest before the duplicate-order Slack messages start.

// MATOMO CLOUD — ECOMMERCE TIER
$23/mo + per-action quotas
  • Ecommerce Reports
  • Quota gate at 50k actions/mo (Essential)
  • Hosted dashboard, EU/US
  • Updates auto-applied
  • No SQL access
// MATOMO CE — SELF-HOSTED
€4.51/mo unlimited actions
  • Ecommerce Reports + raw SQL
  • ~5M events/mo on a CX22 VPS
  • Self-hosted, no quota gate
  • Direct ClickHouse-style MariaDB queries
  • WooCommerce + Shopify + Admin API webhooks
Same data, fewer guardrails. The Cloud surcharge buys hosting + UI conveniences, not different tracking.

This guide ships the full Matomo CE ecommerce stack on a self-hosted instance: the four JavaScript functions that cover 95% of e-commerce flows, what Matomo actually writes to the three log tables, WooCommerce hooks with idempotency baked in, the Shopify Admin-API-webhook path that beats the sandboxed Web Pixel, the cart-abandonment trap that hides revenue, three refund workarounds with their trade-offs, six SQL queries faster than the dashboard, an honest “when this is overkill” section, and the four pitfalls that silently drop attribution.

Prerequisite: a running Matomo CE 4.x or 5.x instance. New to Matomo? Start with Matomo on Hetzner with MariaDB and Caddy, then come back. For the broader self-hosted analytics landscape (5 tools compared) see the self-hosted analytics hero pillar — this article is the ecommerce-layer deep-dive on top.

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 (-1, 0) rows actually being persisted to matomo_log_conversion.

matomo.example.comAdministrationWebsitesManage → Edit Site
SITE NAME
My WooCommerce Store
CURRENCY
EUR — Euro ▾
ECOMMERCE  ← THE HIDDEN TOGGLE
Yes (enabled with cart updates) ▾
The dropdown that controls everything. Disabled by default on every new site.

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 Tracking JavaScript Client was designed around the same four:

EventWhen to fireRequired paramsDatabase row
setEcommerceView()Product detail pageSKU or name (one required), [category], [price]staged on next pageview
addEcommerceItem()Item added/updated in cartSKU, name, category, price, qtyin-memory until cart flush
trackEcommerceCartUpdate()Cart change committedgrandTotallog_conversion (idgoal=-1)
trackEcommerceOrder()Purchase completedorderId, grandTotal, [subTotal, tax, shipping, discount]log_conversion (idgoal=0) + log_conversion_item

Two more functions exist for edge cases: removeEcommerceItem(SKU) and clearEcommerceCart(). You rarely need them — firing a fresh addEcommerceItem 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 writes: schema deep-dive

Three tables hold the data. Default prefix is matomo_ (or piwik_ on legacy installs).

matomo_log_conversion idgoal = 0 → ORDER idgoal = -1 → CART idgoal >= 1 → goal idsite, idvisit, idorder revenue, server_time custom_dimension_* idorder + idsite matomo_log_conversion_item one row per SKU per order idaction_sku, idaction_name idaction_category (FK log_action) price, quantity, deleted idorder, idsite, idvisit idvisit matomo_log_visit attribution & identity idvisitor, user_id referer_type, referer_name utm_source, country visit_entry_idaction_url Goals + abandoned carts Line items Sources + identity
Three tables. JOIN log_conversionlog_conversion_item on idorder + idsite; JOIN log_conversionlog_visit on idvisit.

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=-1. trackEcommerceOrder flushes it as idgoal=0 and clears the cart server-side. Raw row count in log_conversion grows fast on stores with chatty cart UIs — debounce 30 seconds or fire only on cart-page load and on beforeunload.

The 30-line JavaScript for a full purchase flow

setEcommerce View() stages e_pv_* trackPageView() sends staged REQUIRED follow-up addEcommerce Item() in-memory cart trackEcommerceCartUpdate() log_conversion (idgoal=-1) cart still in memory trackEcommerceOrder() log_conversion (idgoal=0) + log_conversion_item, clears cart
Lifecycle: stage → send → accumulate → (cart row OR order row). The order path also clears the in-memory cart server-side.

Product detail page

_paq.push(['setEcommerceView',
  'SKU-12345',                // productSKU (one of SKU/name required)
  'Blue T-Shirt',              // productName (one of SKU/name required)
  ['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. Per the Matomo Tracking JS source, only one of SKU or name is required (the function returns silently if both are missing); both can be passed false.

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.

Read:  How to Audit Your Website Tracking in 30 Minutes

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 — deduped within visit; guard cross-visit server-side
  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 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:

woocommerce_thankyou with idempotency flag

// Order tracking via woocommerce_thankyou (also fires on every refresh of the URL)
add_action('woocommerce_thankyou', function($order_id) {
  $order = wc_get_order($order_id);
  if (!$order || $order->get_meta('_matomo_tracked')) return;

  // get_subtotal() = sum of pre-discount line totals (tax depends on prices_include_tax).
  // Cart-level coupon discounts are NOT subtracted here — they live in get_total_discount().
  // On 'prices_include_tax = yes' shops, line totals are gross-of-tax, so Matomo's subTotal
  // (expected ex-tax) won't balance — normalize server-side or use get_subtotal() - get_total_tax().
  $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),  // ex-tax, unrounded
      $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. The woocommerce_thankyou hook fires on every browser refresh of /checkout/order-received/. Without the flag, one customer pressing F5 generates duplicate orders.

Product views and cart events

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.

WooCommerce Subscriptions renewals

For recurring revenue, hook woocommerce_subscription_renewal_payment_complete. It runs server-side (no F5 problem), but it can fire multiple times on payment-gateway retries, PayPal IPN replays, and admin-driven “process renewal” actions. Apply the same idempotency guard: use a _matomo_subscription_renewal_tracked flag on the renewal order, or store renewal_id in a small KV (Redis) with a 7-day TTL.

Shopify: Admin API webhook beats Web Pixel

Shopify deprecated direct script injection in checkout.liquid in 2023; the migration deadline was August 2024. The two paths now are the Customer Events Web Pixel (sandboxed) and the Admin API webhooks (server-side). For Matomo, the webhook is the primary path; use the Web Pixel only for in-page events the webhook can’t see.

CUSTOMER SHOPIFY YOUR SERVER MATOMO CE Checkout PRIMARY: Admin API webhook (server-to-server) orders/create + HMAC verify /matomo.php?idgoal=0&rec=1… FALLBACK: Web Pixel (sandboxed iframe) analytics.subscribe(‘checkout_completed’, …) App Pixels need connect-src in manifest; Custom Pixels OK no _paq, no document, no IP, ~95% fire-rate
Two paths for Shopify. Webhooks are reliable + signed; Web Pixels run in a sandbox — App Pixels declare network access in their manifest, Custom Pixels (the path here) get a lax sandbox where outbound fetch works without manifest declaration.

Admin API webhook (recommended)

Two webhook topics matter: orders/create fires the moment Shopify creates the order record; orders/paid fires only after payment is captured. For most stores, subscribe to orders/paid — it gates revenue on actual capture and avoids reporting unpaid manual-review orders. orders/create is fine for stores using Shopify Payments with immediate capture (where create == paid in practice). Subscribe via Settings → Notifications → Webhooks or the Admin API. On every event, Shopify POSTs the full order JSON. Verify the HMAC with a constant-time compare, then forward to Matomo:

// Express handler for Shopify orders/create webhook
import crypto from 'crypto';

app.post('/webhooks/shopify/orders',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    // Verify HMAC with constant-time compare (Shopify's documented pattern).
    // Plain `===` leaks signing-key bits via timing differences.
    const sig = req.headers['x-shopify-hmac-sha256'] || '';
    const expected = crypto.createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET)
      .update(req.body).digest('base64');
    const a = Buffer.from(sig);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send('bad signature');
    }

    const order = JSON.parse(req.body);
    const ip = order.client_details?.browser_ip || order.customer?.last_order_ip || '0.0.0.0';

    const items = order.line_items.map(li => [
      li.sku || '', li.title, '',
      parseFloat(li.price), li.quantity
    ]);

    const params = new URLSearchParams({
      idsite: '1',
      rec: '1',
      idgoal: '0',                                    // 0 = ORDER per GoalManager.php
      ec_id: String(order.id),                         // Shopify order ID — never null on orders/create
      revenue: order.total_price,
      ec_st: order.subtotal_price,
      ec_tx: order.total_tax,
      ec_sh: order.total_shipping_price_set?.shop_money?.amount || '0',
      ec_dt: order.total_discounts || '0',
      ec_items: JSON.stringify(items),
      cdt: Math.floor(new Date(order.created_at).getTime() / 1000),
      token_auth: process.env.MATOMO_TOKEN,           // required for cdt > 24h backdating
      url: `https://${order.order_status_url || 'shop.example.com'}`,
    });

    await fetch(`https://matomo.example.com/matomo.php`, {
      method: 'POST',
      headers: {
        'User-Agent': order.client_details?.user_agent || 'ShopifyWebhook/1.0',
        'X-Forwarded-For': ip,
      },
      body: params,
    });
    res.status(200).end();
  }
);

Why this beats the Web Pixel: the Admin API webhook is signed (HMAC), retried by Shopify on 5xx, includes the full order including IP and user-agent, and fires before the customer sees the thank-you page. Matomo’s Tracking HTTP API accepts the cdt (custom datetime) param when paired with token_auth for any backdating beyond 24 hours.

Read:  GDPR in Simple Words: What It Means for a Basic Website

Web Pixel (fallback for in-page events)

The Web Pixel runs in a sandboxed iframe with no window, no document, and no _paq access. fetch() from the sandbox is gated by the pixel manifest’s connect-src permission; without declaring your Matomo domain there, the request is silently blocked:

// Settings → Customer Events → Add custom pixel
// Permissions: connect-src must include https://matomo.example.com
analytics.subscribe('checkout_completed', (event) => {
  const checkout = event.data.checkout;
  // checkout.order.id may be null at this event boundary — fall back to checkout.token
  const orderId = checkout.order?.id || checkout.token;
  if (!orderId) return;

  const items = checkout.lineItems.map(li => [
    li.variant?.sku || '', li.title, '',
    li.variant?.price?.amount || '0', li.quantity
  ]);

  const params = new URLSearchParams({
    idsite: '1',
    rec: '1',
    idgoal: '0',                                          // 0 = ORDER
    ec_id: String(orderId),
    revenue: checkout.totalPrice?.amount || '0',
    ec_st: checkout.subtotalPrice?.amount || '0',
    ec_tx: checkout.totalTax?.amount || '0',
    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: the Web Pixel context strips client IP, so geolocation breaks unless the webhook fills it in; and Shopify documents the pixel as best-effort, not guaranteed. Use it for cart events / checkout-step events the webhook doesn’t see, but always pair with the webhook for the order itself.

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. Block-rate in tech-savvy audiences runs 25-35% (per EasyPrivacy filter coverage for Matomo paths).

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 (Safari ITP treats them with much longer lifetimes). See the Matomo first-party tracking guide.

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;
    proxy_set_header X-Real-IP $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. Also add the proxy domain to trusted_hosts[] in config/config.ini.php on the Matomo side, otherwise Matomo refuses to render reports for the new host.

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 = -1 exists but no subsequent idgoal = 0 within the visit window (default 30 minutes, controlled by visit_standard_length in config/global.ini.php). The trap: every trackEcommerceCartUpdate call writes a fresh row, and the report uses the most recent server_time on those rows to compute “cart age.” Fire on every quantity change and you push the timestamp forward indefinitely, never letting the visit close as abandoned.

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(hashedEmail)
  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 AND lc.idsite = lv.idsite
WHERE lc.idsite = <YOUR_SITE_ID>
  AND lc.idgoal = -1                       -- -1 = CART
  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.idsite  = lc.idsite          -- cross-site idvisit collisions exist
      AND lc2.idgoal  = 0                  -- order in same visit
  )
GROUP BY lv.idvisit, lv.user_id, lc.revenue;   -- ONLY_FULL_GROUP_BY-safe

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. Note: storing raw email in matomo_log_visit.user_id is a GDPR liability — hash it server-side first (e.g., SHA-256), and document that decision in your privacy policy.

Refunds, chargebacks, and the lack of a refund API

Matomo CE has no refund API. Three workarounds, ranked by accuracy. Pick once, document the choice, never mix.

Criterion A. Negative-revenue order B. Custom Dimension flag C. Direct SQL UPDATE
Net revenue accuracy Approximate (skews AOV) Wrong (gross only) Exact
AOV impact Mean of [+X, -X] = 0 Untouched Untouched
Audit trail Yes (separate row) Yes (CD column) No (overwrites)
Reversibility Easy (delete row) Easy (clear flag) Hard (no original)
Risk level Low Low High (DB writes)
Three bad options, pick your poison. Most stores end up on Option A; finance teams prefer C with backups.

Option A — negative-revenue order

_paq.push(['trackEcommerceOrder',
  'REFUND-ORDER-7890',   // distinct ID, NOT the original
  -49.97,                 // negative grand total
  -44.97, -3.20, -4.99, 0
]);

Modern Matomo CE accepts negative revenue. Verify against your version with a test refund row before relying on it — very old installs (pre-4.x) had stricter validation. Trade-off: AOV gets skewed (mean of [+49.97, -49.97] = 0, 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 (Premium) or via SQL.

Option C — direct SQL UPDATE + invalidate-report-data

The only approach that yields accurate retroactive revenue reports. Always back up first.

UPDATE matomo_log_conversion
SET revenue = 0, custom_dimension_1 = 'refunded'
WHERE idsite = 1 AND idorder = 'ORDER-7890';

-- DON'T directly DELETE archive rows. Use the official invalidator instead —
-- it touches both matomo_archive_numeric_* and matomo_archive_blob_* and
-- updates the 'done' marker rows that core:archive checks.
./console core:invalidate-report-data \
  --sites=1 \
  --dates=2026-05-01,2026-05-31

./console core:archive --force-all-periods=86400 --force-all-websites

The core:invalidate-report-data CLI is the supported path. Raw DELETE on archive tables leaves orphaned ‘done’ marker rows that prevent core:archive from re-processing — you’ll see “archive already done” and stale numbers in reports until the markers are cleaned up.

Read:  Consent Mode v2 Without Google: Self-Hosted Analytics Patterns That Pass CNIL (2026)

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.

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, returning-customer detection, and abandoned-cart email triggers (no idvisit continuity). Mitigation is setUserId(hashedEmail) for known users. Full cookieless posture in 5 self-hosted cookieless tracking recipes (Matomo is recipe #2).

When Matomo Ecommerce isn’t the right tool

The honest version. If any of the following describes your job-to-be-done, you’ll fight Matomo’s design:

  • Multi-currency stores without server-side normalization. Currency is per-site, not per-event. Either normalize all amounts to a base currency in your backend, run one Matomo site per currency, or use a tool with native multi-currency tracking.
  • Subscription-MRR businesses where event-style tracking is the wrong shape. Matomo records each renewal as a one-off order, no native MRR cohort view. Build the cohort yourself in SQL, or pick a billing-aware tool (ChartMogul, Baremetrics) for the financial side.
  • Stores with >10M conversions/month. Default schema indexes (idsite, idvisit); high-volume querying needs custom indexes on (idsite, server_time, idgoal) + read replicas. Workable, but not turn-key.
  • Real-time refund accuracy with full audit trail. All three refund workarounds have trade-offs. If finance demands exact net revenue with full history, dual-store: Matomo for marketing reporting, your billing DB as source-of-truth, reconcile nightly.
  • Google Ads conversion tracking with Consent Mode v2. Matomo doesn’t ship a Google Ads integration; you’ll need server-side tagging (sGTM), which mostly defeats the self-hosted-by-default point.
  • Non-technical operators. Self-hosted CE requires infrastructure ownership. Matomo Cloud Business at $23/mo+ is the right call if you don’t have a dev who’ll babysit Docker and MariaDB.

If those don’t apply, Matomo CE handles 80%+ of WooCommerce/Shopify analytics jobs with the patterns in this guide.

SQL queries that beat the dashboard

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

-- log_conversion_item stores SKU/name/category as integer FKs (idaction_*)
-- into log_action.name. JOIN twice to resolve human-readable values.
SELECT
  asku.name  AS sku,
  aname.name AS product_name,
  SUM(lci.quantity)              AS units_sold,
  SUM(lci.price * lci.quantity)  AS gross_revenue
FROM matomo_log_conversion_item lci
JOIN matomo_log_action asku  ON lci.idaction_sku  = asku.idaction
JOIN matomo_log_action aname ON lci.idaction_name = aname.idaction
JOIN matomo_log_conversion lc
  ON lci.idvisit = lc.idvisit AND lci.idorder = lc.idorder
WHERE lc.idsite = <YOUR_SITE_ID>
  AND lc.idgoal = 0                -- 0 = ORDER per GoalManager.php
  AND lci.deleted = 0
  AND lc.server_time >= NOW() - INTERVAL 30 DAY
GROUP BY asku.idaction, aname.idaction
ORDER BY gross_revenue DESC
LIMIT 50;
mysql>
SELECT sku, units_sold, gross_revenue FROM top_skus_30d LIMIT 5;
+────────────+────────────+──────────────+
| sku | units_sold | gross_revenue |
+────────────+────────────+──────────────+
| SKU-12345 | 342 | 6839.58 |
| SKU-67890 | 201 | 2009.99 |
| SKU-11111 | 158 | 1579.50 |
| SKU-22222 | 119 | 1428.81 |
| SKU-33333 | 87 | 869.13 |
+────────────+────────────+──────────────+
5 rows in set (0.04 sec)
mysql> _
Sample output. The dashboard takes ~3-8 seconds to render the same data on a 1M-conversion store; the SQL takes ~40ms.

Revenue by acquisition channel

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,
  ROUND(SUM(lc.revenue) / COUNT(DISTINCT lc.idvisit), 2) AS aov
FROM matomo_log_conversion lc
JOIN matomo_log_visit lv
  ON lc.idvisit = lv.idvisit AND lc.idsite = lv.idsite
WHERE lc.idsite = <YOUR_SITE_ID>
  AND lc.idgoal = 0                              -- 0 = ORDER
  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.idsite = lv.idsite AND lc.idgoal = 0
WHERE lv.idsite = <YOUR_SITE_ID>
  AND 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. See the MariaDB indexing guide for covering-index theory.

Common pitfalls

Pitfall DevTools / DB signal Dashboard signal Fix
1. thankyou re-fires on F5 Multiple idgoal=0 rows same idorder Inflated revenue + duplicate orders _matomo_tracked postmeta flag
2. trackEcommerceCartUpdate w/o items idgoal=-1 rows with revenue=0 Empty cart noise Always push items first
3. Currency mismatch Numbers exist, units don’t Mixed-unit revenue Normalize server-side OR site-per-currency
4. Discount as string Discount column = 0 Wrong order totals Pass numeric; coupon code as Custom Dimension
5. Cookie clear mid-checkout Order rows with no source “Direct” inflated Server-side fallback via Tracking HTTP API
6. Bot-inflated conversions UA matches bot list but headless Chrome doesn’t Suspicious orders / non-human IPs Privacy → Exclude Bots: ON + Cloudflare bot rules
7. Ad-blocker stripping Blocked /matomo.php in DevTools Traffic 25-35% lower than reality First-party CNAME + reverse-proxy
The diagnostic is always: query matomo_log_conversion directly to confirm the rows actually landed.

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 server-side. It can fire multiple times on payment-gateway retries, IPN replays, and admin actions, so apply the same _matomo_subscription_renewal_tracked idempotency flag pattern you’d use on the thank-you hook.
Is server-side ecommerce tracking possible?
Yes, via the Matomo Tracking HTTP API — same endpoint as client-side, called from your backend. For backdating beyond 24 hours you also need token_auth. The pattern is identical to the Stripe webhook example in Plausible Custom Events + Revenue Tracking — only the parameter names differ.
Why are my product views showing zero in the report even though setEcommerceView is firing?
setEcommerceView only stages e_pv_* params; without a follow-up trackPageView nothing actually transmits. The fix is two lines — the setEcommerceView call and immediately after, _paq.push(['trackPageView']).
Can I use Matomo Tag Manager (MTM) instead of _paq.push?
Yes. MTM has built-in Ecommerce variables and triggers that map to the same four functions. The trade-off is a heavier runtime (~30 KB vs ~10 KB for plain matomo.js) and a learning curve for the trigger model. The self-hosted tag manager alternatives guide has the comparison.
What’s the maximum precision for prices?
Matomo stores revenue as DECIMAL(20,6) server-side, but the JS client typically passes 2 decimal places. The HTTP Tracking API accepts up to 6 decimals; round to your store’s currency precision before sending.
How do I track Custom Dimensions on individual line items?
Matomo CE doesn’t support per-line-item Custom Dimensions out of the box — addEcommerceItem is strictly five arguments (SKU, name, category, price, quantity), and matomo_log_conversion_item has no custom_dimension_* columns. Workarounds: encode brand/variant into the SKU (SKU-12345-RED-L), use page-scope setCustomDimension to tag the product page (visit/action scope, applies to all events on that page), or use the premium Custom Reports plugin which adds per-item slicing on top of the standard schema.
What happens if I send trackEcommerceOrder with the same orderId twice?
Matomo’s matomo_log_conversion table enforces a UNIQUE(idsite, idorder) constraint. Within the same visit the existing row is updated in place; across visits the second insert throws InvalidRequestParameterException server-side and the row is rejected. The browser still sees a normal 1×1 GIF response, so the failure is invisible. Always guard server-side with an order-meta flag — the _matomo_tracked postmeta pattern for WooCommerce, Admin API webhook idempotency for Shopify.


Found this useful?

Try the Stack Picker to get a personal recommendation, or browse the install recipe library.