To add Google Tag Manager to WHMCS without editing templates, put one PHP file in includes/hooks/. The ClientAreaHeadOutput hook prints the container script inside the head, ClientAreaHeaderOutput prints the noscript frame at the top of the body, and ShoppingCartCheckoutCompletePage pushes a purchase event to the dataLayer. GA4 itself is then set up as tags inside GTM.
Last reviewed 10 October 2026. Disclosure: I sell WHMCS development, including hook files and tracking setups like this one, so I am not neutral about hiring someone for it. The hook names and parameters below were checked against the official WHMCS hooks reference and a WHMCS 9 install.
Why add Google Tag Manager to WHMCS with a hook and not in the template?
Because a hook file survives theme updates and theme changes, and a template edit does not. If you paste the snippet into header.tpl, the next theme update can overwrite it, and every extra theme needs the same edit again. A file in includes/hooks/ is loaded whichever theme is active.
The hook output reaches the page through variables that the stock themes already print: {$headoutput} inside the head, {$headeroutput} after the opening body tag and {$footeroutput} before the closing one. A custom theme that removed those variables prints nothing, so check header.tpl and footer.tpl first. Putting them back is a small WHMCS theme customization job.
Which WHMCS hook prints each part of the GTM snippet?
Three output hooks cover the snippet and one cart hook covers the purchase. Each returns a string of HTML, and WHMCS places it at a fixed spot in the page.
| Hook | Where the output lands | Use it for |
|---|---|---|
ClientAreaHeadOutput | Inside the head tags of every client area page | Consent defaults, then the GTM container script |
ClientAreaHeaderOutput | Top of the body | The GTM noscript iframe |
ClientAreaFooterOutput | Before the closing body tag | Scripts that can load late, such as a chat widget. GTM does not need it. |
ShoppingCartCheckoutCompletePage | The order-complete page only | The dataLayer purchase push |
AdminAreaHeadOutput | The head of the admin area | Nothing here. Marketing tags do not belong in admin. |
"Client area" in these names means every page the client theme renders: the homepage, the cart, the knowledgebase, login and the logged-in pages. My WHMCS hooks guide explains priorities and how hook files are loaded.
How do you write the ClientAreaHeadOutput hook for the GTM container?
Create includes/hooks/gtm.php, return the container script from ClientAreaHeadOutput and the noscript iframe from ClientAreaHeaderOutput. Replace GTM-XXXXXXX with your container ID.
<?php
// includes/hooks/gtm.php
if (!defined('WHMCS')) {
die('This file cannot be accessed directly');
}
use WHMCS\Database\Capsule;
function sm_gtm_id()
{
// No tracking while a staff member is logged in on this browser.
if (!empty($_SESSION['adminid'])) {
return '';
}
return 'GTM-XXXXXXX';
}
add_hook('ClientAreaHeadOutput', 1, function ($vars) {
$id = sm_gtm_id();
if ($id === '') {
return '';
}
return <<<HTML
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
ad_storage: 'denied', ad_user_data: 'denied',
ad_personalization: 'denied', analytics_storage: 'denied'
});
</script>
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','{$id}');</script>
HTML;
});
add_hook('ClientAreaHeaderOutput', 1, function ($vars) {
$id = sm_gtm_id();
if ($id === '') {
return '';
}
return '<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=' . $id
. '" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>';
});
The second script block is the standard container snippet from your GTM account. The first block sets consent to denied before the container loads, which I explain below. Save the file and view the source of any client area page: the snippet should sit in the head.
How do you set up GA4 in WHMCS through GTM?
Add one Google tag in GTM with your GA4 Measurement ID (it starts with G-) and fire it on all pages. That gives you page views on every WHMCS page with no further PHP.
Send each page view once. WHMCS ships its own Google Analytics addon module with ecommerce tracking, which I do not cover here. If it is active, or the gtag.js snippet is already pasted into a template, remove that before publishing the container, or every visit is counted twice. Use the same Measurement ID on your marketing site and on WHMCS, so a visit that moves to the cart stays one session. Separate domains also need cross-domain measurement in the GA4 data stream.
How do you push a GA4 purchase event on the order-complete page?
Use ShoppingCartCheckoutCompletePage, which runs when the checkout complete page is displayed and returns HTML for that page. It receives the order ID, order number, invoice ID, an ispaid flag, the order amount, the payment method and the client's details. Add this to the same file:
add_hook('ShoppingCartCheckoutCompletePage', 1, function ($vars) {
if (sm_gtm_id() === '') {
return '';
}
$currency = Capsule::table('tblorders')
->join('tblclients', 'tblclients.id', '=', 'tblorders.userid')
->join('tblcurrencies', 'tblcurrencies.id', '=', 'tblclients.currency')
->where('tblorders.id', $vars['orderid'])
->value('tblcurrencies.code');
$event = [
'event' => 'purchase',
'invoice_paid' => (bool) $vars['ispaid'],
'ecommerce' => [
'transaction_id' => (string) $vars['ordernumber'],
'value' => (float) $vars['amount'],
'currency' => $currency,
],
];
$json = json_encode($event, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
return "<script>window.dataLayer = window.dataLayer || [];"
. "dataLayer.push({$json});</script>";
});
The order form's complete.tpl prints the returned HTML through its $addons_html loop, so a custom order form without that loop shows nothing. In GTM, create a Custom Event trigger for the event name purchase and a GA4 Event tag named purchase that sends ecommerce data from the data layer. Know these limits before you trust the numbers:
- An order is not a payment. With bank transfer the invoice is still unpaid when the page appears, which is why the push carries
invoice_paid. Use it as a trigger condition if you only want paid orders. - The page is not always shown. Auto Redirect on Checkout, on the Ordering tab of General Settings, can send the customer to the invoice or straight to the payment gateway instead of the order-complete page. A customer who pays on a hosted payment page may never load the complete page. Test each gateway you offer.
- Renewals never pass through the cart. For exact revenue, WHMCS reports stay the source of truth. To record paid orders regardless of the browser, send the event from the server when the
OrderPaidhook runs, using GA4's Measurement Protocol. - Item reports need items. GA4 fills its item reports from an
itemsarray. Build it from the order's services, addons and domains if you need product-level data.
Always send transaction_id. GA4 uses it to avoid counting the same purchase twice when a customer reloads the page.
How do you keep tracking off admin pages and staff sessions?
The admin area is already excluded, because the ClientArea hooks do not run there. The admin area has its own AdminAreaHeadOutput family, which this file does not use. The leak is staff browsing the client area: testing the cart, or opening a customer's account with Login as Owner. The sm_gtm_id() check above returns nothing when the session holds an admin login.
That session key is not part of the documented hook parameters, so confirm it on your version: log in as admin, open the client area in the same browser and check that the page source has no GTM snippet.
How do you handle consent before tags fire?
Set a default consent state before the container loads, then let a consent banner update it. The first script in the hook does the default part: it marks ad and analytics storage as denied, so Google's tags set no cookies until the visitor agrees. The banner, loaded through GTM or printed by the same hook, then calls gtag('consent', 'update', ...) with the visitor's choice. Tags from other vendors ignore this signal unless you add consent conditions to them in GTM.
Which visitors need the banner is a legal question, not a WHMCS one. If you sell to the European Union or the United Kingdom, assume you need it and ask whoever advises you on privacy.
How do you test with Tag Assistant and GA4 DebugView?
Use GTM's Preview button, which opens Tag Assistant against your WHMCS URL, and watch DebugView in GA4 at the same time. Work through this list on staging, then again on production after publishing the container:
- The container loads on the homepage, the cart, the login page and a logged-in page.
- The Google tag fires once per page, not twice.
- With consent denied, no analytics cookies are set. After accepting, they are.
- A test order shows the
purchaseevent in Tag Assistant with the right order number, total and currency code. - Reloading the order-complete page does not add a second purchase in GA4.
- A test order in a second currency carries that currency's code.
- Each gateway you offer still reaches the order-complete page, or you know which ones do not.
- The same pages viewed while logged in as admin contain no snippet.
- The events appear in DebugView.
What should you never send to Google from WHMCS?
Anything that identifies a person: names, email addresses, phone numbers, postal addresses, tax IDs and card details. Google's Analytics policies forbid it, and privacy law makes it your problem. In WHMCS the risk is concrete: the purchase hook receives a full clientdetails array, and it is tempting to push all of it. Push only the order number, value, currency and product data.
Watch page addresses too. GA4 records the URL of each page, and some WHMCS links carry one-time tokens, such as password reset and email verification links. Exclude those pages in GTM or redact the address before the tag fires.
What is the next step?
Create the container, copy the hook file to a staging copy of WHMCS and run the test list before anything reaches production. If you want the items array, server-side payment events or a consent banner wired in, that is WHMCS API integration and automation work, and my hire a WHMCS developer page explains how I scope it. I start with a free scoping call of about 30 minutes and quote within one business day.