Matomo Ecommerce Tracking 2026: WooCommerce + Shopify Self-Hosted
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.
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.
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:
| Event | When to fire | Required params | Database row |
|---|---|---|---|
setEcommerceView() | Product detail page | SKU or name (one required), [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=-1) |
trackEcommerceOrder() | Purchase completed | orderId, 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).
log_conversion ↔ log_conversion_item on idorder + idsite; JOIN log_conversion ↔ log_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
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.
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.
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.
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.
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.
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;
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
matomo_log_conversion directly to confirm the rows actually landed.FAQ
Does Matomo Ecommerce work on the free Cloud plan?
Can I import historical GA4 ecommerce data?
How does Matomo handle multi-currency stores?
trackEcommerceOrder.Does this work with WooCommerce Subscriptions?
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?
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?
What’s the maximum precision for prices?
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?
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_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.