Track Profit and Bid on POAS
Report the profit of each order alongside its revenue, so you can optimize campaigns on profit on ad spend instead of return on ad spend. Two campaigns with the same ROAS can have very different profit behind them, and the difference is usually the product mix.
This recipe was built on a write-up contributed by Matana Ksuma, whose shipping carve-out and units warning are the two parts most likely to save you from a wrong answer that looks right.
What the Pixel Manager already knows
With one of the supported Cost of Goods Sold sources active, the Pixel Manager calculates a profit margin per order: product revenue minus the cost of goods, discounts, refunds, and payment processor fees. Everything net of tax.
Two things it does not know, and cannot:
- What the carrier charges you. WooCommerce records what you charged the customer for shipping, but nothing records what shipping cost you. Shipping is therefore out of the margin on both sides.
- Whether a product's cost is actually set. A product without a cost is counted with a cost of zero, which reports that product's entire revenue as profit, silently.
Both are supplied below.
Step 1: put your shipping economics into the margin
The naive implementation asks whether the customer was charged for shipping:
// Wrong on any shop with a free-shipping threshold.
if ($order->get_shipping_total() > 0) {
$courier_cost = 34.00;
}
That reads as correct and is wrong. Above the threshold the customer is charged nothing, the test fails, no carrier cost is deducted, and the order still ships. On a flat-rate courier contract with a 400 threshold, that is roughly 8.5% of revenue quietly added to the profit of that order.
The distortion is not random. It lands entirely on the largest orders, and it always inflates. Comparing campaigns on profit, you would see high-AOV campaigns systematically overstated, and you would move budget toward them for a reason that does not exist. An error that looks like a finding is worse than a missing number.
Decide the carrier cost from the shipping method, not from the amount charged. The method tells you whether a courier is involved. The amount only tells you what the customer paid, which is a different question.
/**
* What the carrier charges for this order. Net of tax, like every other
* figure in the profit margin calculation.
*/
function my_shop_courier_cost($order) {
$methods = $order->get_shipping_methods();
// No shipping line at all: nothing ships, nothing to pay.
if (empty($methods)) {
return 0.0;
}
foreach ($methods as $method) {
$id = $method->get_method_id();
// Classic local pickup is `local_pickup`, WooCommerce's newer
// block-based local pickup registers as `pickup_location`.
if ('local_pickup' === $id || false !== strpos($id, 'pickup')) {
return 0.0;
}
}
// Everything else ships, `free_shipping` included. That is the point.
return 34.0;
}
add_filter('pmw_order_shipping_profit', function ($shipping_profit, $order) {
return (float) $order->get_shipping_total()
- (float) $order->get_total_shipping_refunded()
- my_shop_courier_cost($order);
}, 10, 2);
The pmw_order_shipping_profit filter is available from version 1.67.1. The revenue half is simply get_shipping_total() added back, since the calculation excluded it, and refunded shipping is subtracted because nothing else in the margin deducts it.
get_shipping_total() returns the net amount, so your carrier figure has to be net too.
Worked through with real numbers: the customer pays 37 including 18% VAT, which is 31.36 net. The carrier charges 34 net. Shipping is therefore a small net cost of about 2.64 per order, not a gain, which is the opposite of what the gross figures suggest.
This is easy to get wrong in a VAT country, because every number you read off an order screen is tax-inclusive while the calculation works in net. Taking 37 from the admin, or entering a tax-inclusive carrier rate, is wrong by the VAT on every single order. Small per order, systematic across all of them, and invisible unless you go looking for it.
An order split across two shipping lines gets the flat cost counted once. If you split shipments, count per line instead.
Step 2: send the profit to Google Analytics and Google Ads
pmw_custom_order_parameters adds a parameter to the purchase event the Pixel Manager sends to Google Analytics, where you register it as a custom metric. The same value is then available to the browser for a second Google Ads conversion action.
add_filter('pmw_custom_order_parameters', function ($custom_parameters, $order) {
if (!class_exists('\SweetCode\Pixel_Manager\Profit_Margin')) {
return $custom_parameters;
}
// Send no profit at all rather than one that is too high because a
// product on this order has no cost set. Absent is better than wrong:
// when the parameter is missing, the JavaScript below skips the order
// on its own.
if (!\SweetCode\Pixel_Manager\Profit_Margin::order_has_complete_cogs($order)) {
return $custom_parameters;
}
$custom_parameters['profit'] = round(
(float) \SweetCode\Pixel_Manager\Profit_Margin::get_order_profit_margin($order),
2
);
return $custom_parameters;
}, 10, 2);
order_has_complete_cogs() is available from version 1.67.1. Do not reimplement the cost lookup yourself: the meta keys differ per source, per version, and between the order line item and the product, and a lookup that copies part of the list finds nothing without telling you.
Then report it as a second Google Ads conversion action, next to the revenue action the Pixel Manager fires:
add_action('wp_head', function () {
?>
<script>
window._pmwq = window._pmwq || [];
window._pmwq.push(function () {
pmw.bus.on("pmw:event:purchase", function () {
var order = window.pmwDataLayer && pmwDataLayer.order;
if (!order || !order.custom_parameters) return;
var profit = parseFloat(order.custom_parameters.profit);
if (!isFinite(profit)) return;
pmw.trackCustomGoogleAdsConversion("AW-CONVERSION_ID/CONVERSION_LABEL", {
value : Math.max(0, profit),
currency : order.currency,
transaction_id: String(order.number)
});
});
});
</script>
<?php
});
pmw.trackCustomGoogleAdsConversion() is available from version 1.67.1 and applies the marketing consent gate and the wait for the Google tag for you. Four details in that snippet matter:
- Print it on every page,
wp_headwith no page condition. The purchase event is the gate, and Automatic Conversion Recovery fires it on an ordinary shop page when a customer returns with an order that was never tracked. A snippet scoped to the confirmation page misses exactly those orders. - Hang it on
pmw:event:purchase, never onpmw:readyand never on awindow.gtagcheck. On a shop that defers or delays JavaScript the data layer is printed by PHP long before the tracking bundle runs, sogtagis reliably absent at that moment, andpmw:readyis never replayed.pmw:event:purchaseis. - Use
order.numberfortransaction_id, the same identifier the Pixel Manager's own conversion sends, so both actions key on the same order. - Floor the value at zero. Profit can be negative on a heavily refunded order, and the ad platforms either reject a negative value or misread it.
Register profit in Google Analytics as an event-scoped custom metric, and allow a day for processing before it appears in reports.
Step 3: know what your revenue action is reporting
If you supply your payment processor fees through pmw_order_fees, that filter does not only feed the profit margin. Under Order Subtotal it also lowers the value sent to the marketing pixels, so your revenue conversion action reports the subtotal net of that fee from the day you added it. It makes the two actions more comparable, but it is a step change in your reported series with nothing in the settings to explain it.
The profit margin already has pmw_order_fees taken off it. A fee subtracted both there and in your own profit calculation is counted twice.
The same applies the day you switch Marketing value logic to Profit margin: the value sent to the ad platforms then already includes your payment fee and your shipping filter, so remove anything you were adding on top of it by hand.
Testing it
-
Place a test order and confirm the parameter reaches the browser:
pmwDataLayer.order.custom_parameters; // { profit: 42.13 } -
Place a second order containing a product with no cost set, and confirm
custom_parametersis absent and no profit conversion fires. -
Place a third one with a shipping method above your free-shipping threshold, and confirm the carrier cost is still deducted. This is the case the naive version gets wrong.
-
Confirm the conversion count in Google Ads, not in the browser's Network panel. One conversion fans out over several Google endpoints, so a single call produces more than one request.
Reloading the confirmation page does not fire the purchase again. Add ?nodedupe to the confirmation URL to retest with the same order, and turn on the Console Logger with ?pmwloggeron to watch the event flow.
Related Documentation
- Shop Settings - What the profit margin contains, and where a product's cost comes from
- PHP Filters - The shipping profit filter, the order fees filter and the Profit Margin API
- Tips and Tricks - Custom Google Ads conversions
- JavaScript Events - The purchase event and its replay behaviour