Olo Google Tag Manager Tracking: Complete Developer Guide
Ishant
Published : June 12, 2026 at 6:51 pm
Updated : June 12, 2026 at 7:18 pm
Ishant
Ishant Sharma is the Founder and CEO of Hustle Marketers, a Google Partner digital marketing agency. With 12+ years of experience in Google Ads, Meta Ads, SEO, and e-commerce PPC, he has helped 2500+ brands generate $780M+ in trackable revenue. Upwork Top Rated Plus with 99% Job Success Score. Ishant Sharma is the digital marketing specialist, not the Indian cricketer of the same name.
Summarize this blog post with:
Olo’s support docs tell you GTM is supported, list the blocked pages, and link to a reference PDF that now returns a 404. Simo Ahava’s jQuery article explains GTM scripting but never touches Olo. Neither tells you how Olo Serve actually hands order data to your tags, and neither warns you about the hidden trigger filter that can silently kill every tag in the official setup.
So I went to the source. Olo publishes its entire GTM integration on GitHub (ololabs/olo-serve-gtm-templates): the custom template, four importable container configurations, and even the build script that generates them. I read all of it. And this isn’t theory for us; we’ve implemented and maintained this exact tracking stack for multi-location restaurant clients, including Curry Up Now, where the gaps documented below showed up in production before we patched them. This guide covers what the code does field by field, the four gaps in the official container, and the copy-paste fixes for each one.
How Does Olo Serve Actually Send Data to Google Tag Manager
Here’s the part no blog has documented. Olo Serve exposes a global JavaScript object on every ordering page: window.Olo. It has two parts you care about.
First, an event emitter. Serve broadcasts “global events” through Olo.on(eventName, callback). Every meaningful ordering action fires one:
| Olo global event | Fires when |
|---|---|
v1.productsVisible | Menu items render on screen |
v1.clickProductLink | User clicks a menu item |
v1.viewProductDetail | User opens or configures an item |
v1.addToCart | Item added to basket |
v1.removeFromCart | Item removed from basket |
v1.createBasket | A new basket is created |
v1.checkout | User proceeds toward checkout |
v1.transaction | Order completes (confirmation page) |
v1.userLogin | User signs in |
Second, a global data store. Olo.data.vendor holds the current location’s details including its currency, and Olo.data.device exposes isHybrid, isHybridIOS, and isHybridAndroid flags that distinguish the website from the Serve App webview.
Olo’s official GTM template is nothing more than a subscriber to this emitter. Its sandboxed code calls Olo.on('v1.transaction', ...) and friends, then translates each callback payload into a GA4-spec dataLayer push. The template’s own permission manifest confirms it: the only window access it requests is Olo.on and Olo.data.vendor. Once you see that, the whole system stops being a black box. The template is the bridge. Your tags consume what the bridge pushes.
One structural detail that changes how you debug: every push is preceded by { ecommerce: null }. The template clears the ecommerce object before each event so stale items can’t leak between events. Null pushes in the console are by design.
What Exactly Does Olo Push to the DataLayer (Field by Field)
Verified from the template source, not guessed. In GA4 mode, every event and its payload:
Menu and product events. view_item_list, select_item, and view_item each carry ecommerce.items where every item has item_name, item_id, item_category, and price. The price source for these three is the product’s baseCost: the menu price before modifiers.
Cart events. add_to_cart and remove_from_cart carry the same fields plus quantity, but price switches to unitCost, which includes modifiers. A burger viewed at $10 and added with $3 of toppings shows $10 on view_item and $13 on add_to_cart. If item prices look inconsistent across funnel stages, that’s why. Correct behavior, just undocumented.
begin_checkout. Fires with full basket contents the moment the user heads to checkout. The source contains a revealing comment here: Serve passes a callback and holds the page transition until analytics finish. Checkout is a hard navigation out of the single-page app, which is exactly why begin_checkout reaches your tags even though GTM is blocked on the checkout page itself. The event fires before you leave.
purchase. The big one, fired on the order confirmation page:
| dataLayer key | Olo source field | What it actually is |
|---|---|---|
ecommerce.transaction_id | order.displayId | The customer-facing order number, not Olo’s internal ID |
ecommerce.value | order.subTotal | Subtotal only. No tax, no tip, no delivery fee |
ecommerce.tax | order.vendorTax | Tax as a separate parameter |
ecommerce.shipping | order.deliveryCharge | Delivery fee as a separate parameter |
ecommerce.affiliation | order.vendorName | The restaurant location name |
ecommerce.currency | Olo.data.vendor.currency | Per-vendor currency, correct for multi-country brands |
ecommerce.coupon | basket coupon code | Order-level only; no item-level discounts |
ecommerce.items | basket products | Deduplicated and price-averaged (explained below) |
login. Pushed from v1.userLogin with a method parameter: olo, third-party for SSO, or facebook. Keep this one in mind; the official container has a surprise about it.
Why Your GA4 Revenue Never Matches Olo’s Order Reports
The template sets ecommerce.value to order.subTotal. Not the order total. GA4 purchase revenue, and any Google Ads conversion value mapped from it, will run systematically below what Olo’s dashboard and your POS report, because tax, delivery, and tips are excluded. Tax and shipping ride along as separate parameters so GA4 can display them, but they don’t count toward revenue. Tips aren’t in the payload at all.
Arguably subtotal is the right number for ad optimization, since you want to bid on product revenue rather than pass-through tax. But you need to know which number you’re staring at. Three rules:
- Reconcile GA4 purchase revenue against Olo’s subtotal column, never the total column.
- If finance insists Google Ads should see gross order value, sum value, tax, and shipping in a Custom JavaScript variable (code in the Google Ads section). Tips stay missing; accept it or feed conversions server-side from Olo’s order data.
- Olo’s changelog confirms Dispatch (delivery) orders reported zero revenue in GA4 until an application-side fix in late August 2023. Treat pre-fix delivery revenue in audits as broken data, not a trend.
A second reconciliation trap: transaction_id is the displayId, the order number the customer sees. Olo’s CRM integrations key off different identifiers, so when matching GA4 transactions against Klaviyo or Olo exports, join on the customer-facing order number or your dedupe silently fails.
Why Item Prices in GA4 Look “Wrong” on Olo Orders
One more thing buried in the source. When an order contains the same item multiple times with different modifiers (two burritos, one with extra guac), Olo can’t send two lines with the same item_id at different prices without breaking GA’s math. So the template merges them: quantities sum, and unit price becomes a quantity-weighted average across instances, rounded to two decimals.
GA4 might show “Burrito, quantity 2, price $11.50” when the customer paid $10 and $13. Total revenue stays correct; per-unit price becomes a blend. When a franchisee asks why analytics prices don’t match the menu, this is the answer, and it’s working as designed.
Which Olo Pages Allow GTM, and How Tracking Survives the Checkout Block
The exclusion list: on desktop, GTM loads everywhere except Checkout and My Account. On mobile web, also excluded are Sign Up, Login, Reset Password, and Forgot Password.
Connect that to the event architecture and the design makes sense:
begin_checkoutfires from a GTM-enabled page before the hard navigation into the blocked checkout.- Checkout collects payment with zero third-party scripts, which is part of how the page stays PCI compliant.
purchasefires on the confirmation page, GTM-enabled again, carrying the full order payload.
You lose nothing that matters. What you must never do is trigger conversions on a checkout URL or scrape checkout fields; the page is invisible to you. Every conversion tag in this guide triggers on the purchase dataLayer event.
The consequence people miss: because checkout runs without GTM, every cookie your conversion stack needs (GCLID from the Conversion Linker, Meta’s _fbp, GA4’s client ID) must be written before the user enters checkout. First-party cookies persist through blocked pages fine, but only if the tags that set them already ran. That’s why linkers and base pixels fire on All Pages, never on the purchase event.
What’s Inside Olo’s Official GTM Container (Every Tag, Trigger, and Variable)
The repo ships four importable configurations plus the Node script (generate.js) that builds them by name-filtering a master container export. The four files:
| File | Use it? |
|---|---|
olo-serve-container-configuration-ga4-web.json | Yes. This is the one for almost everyone |
olo-serve-container-configuration-ga4-all-platforms.json | Only if you run Serve App and want the plumbing ready |
...ga4-ios-NOT-SUPPORTED.json | No. The filename isn’t kidding |
...ga4-android-NOT-SUPPORTED.json | No. Same reason |
The iOS and Android configs exist but don’t work because Google never shipped GA4 support for hybrid apps, which Olo’s changelog called out back in January 2022. The all-platforms file contains the same web tags plus fourteen iOS and Android variants gated by device flags; importing it on a web-only brand just adds dead weight. Take the web file.
Here’s exactly what the GA4 web import puts in your workspace, from the JSON itself:
One template tag: “Olo Serve Integration – GA4”, firing on a DOM Ready trigger, once per event. This is the Olo.on subscriber. Its config enables seven events: transaction, clickProductLink, removeFromCart, viewProductDetail, productsVisible, addToCart, and checkout.
Eight Custom Event triggers: one per GA4 event name (view_item_list, select_item, view_item, add_to_cart, remove_from_cart, begin_checkout, purchase, create_basket). Every single one carries an additional filter condition, which matters enormously and gets its own section below.
Eight GA4 event tags wired to those triggers. They don’t use the “Send Ecommerce data” checkbox. Each maps explicit event parameters: the purchase tag maps items, transaction_id, affiliation, value, tax, shipping, currency, and coupon from the container’s variables. The funnel tags map only items, which is gap number three below. Each tag carries measurementIdOverride: G-000000, the placeholder you replace. Two changelog entries (December 2023 and October 2024) exist purely because this key was missing and broke imports, so if you grabbed the files long ago, re-download.
Eleven variables: eight Data Layer Variables (version 2, no default value) for ecommerce.items, ecommerce.transaction_id, ecommerce.value, ecommerce.tax, ecommerce.shipping, ecommerce.currency, ecommerce.affiliation, ecommerce.coupon, plus three JavaScript variables reading Olo.data.device.isHybridAndroid, Olo.data.device.isHybridIOS, and Olo.data.device.isHybrid.
The import procedure deserves its own walkthrough, so here it is in plain steps.
How to Download and Import Olo’s GTM Template From GitHub (Simple Steps)
You don’t need to write any code for the base setup. Olo gives away the whole configuration for free, and you can download it from the official GitHub repository. These are the same steps Olo’s own documentation describes, written so anyone on your team can follow them.
Step 1: Send Olo your container ID. In GTM, copy your container ID from the top bar (it looks like GTM-XXXXXXX). Email it to your Olo CSM and ask them to ingest it. Olo injects the container onto your ordering pages from their side; you can’t do this part yourself.
Step 2: Confirm the injection went live. Open your ordering site, right click, View Page Source, and search for your container ID. If it’s there, you’re live. If not, wait for your CSM. This only works on the Serve platform, so confirm you’re on Serve before anything else.
Step 3: Download the container file. On the GitHub page, open the container folder and download olo-serve-container-configuration-ga4-web.json. Ignore the iOS and Android files; their names literally say NOT SUPPORTED because GA4 doesn’t run in hybrid apps. Re-download even if you grabbed it months ago, since older copies had a broken import key that Olo fixed later.
Step 4: Import into GTM. In GTM go to Admin, then Import Container. Select the JSON you downloaded, pick a workspace (a new one is cleanest), and choose Merge, never Overwrite. Overwrite wipes every existing tag in your container.
Step 5: Add your GA4 Measurement ID. The import creates eight GA4 tags, and every one ships with the placeholder G-000000. Open each tag and replace it with your real Measurement ID from GA4. Miss one tag and that event silently sends nothing.
Step 6: Preview and test. Hit Preview in GTM, connect it to your live ordering URL, browse the menu, and add an item to the cart. You should see the Olo template fire on DOM Ready and the GA4 events follow. Finish with a real low-value test order to confirm purchase arrives.
That’s the official setup done. Everything after this section covers what the official setup doesn’t do, and the fixes.
The Hidden Trigger Filter That Can Silently Kill Every Olo Tag
This is the detail that justified reading the JSON line by line. Every one of the eight triggers carries a second condition beyond the event name:
{{Olo Serve - Is Web App?}} equals true
And the “Is Web App?” variable is not what its name suggests. It’s a JavaScript variable reading Olo.data.device.isHybrid with GTM’s format options set to invert the boolean: convert true to “false” and false to “true”. On the website, isHybrid is false, the variable inverts it to “true”, and the filter passes.
Why you need to know this: the inversion only converts the boolean false. If Olo.data.device is ever undefined when the trigger evaluates, the variable returns undefined, the filter fails, and every GA4 tag stops firing with no error anywhere. The events still appear in the dataLayer, preview mode shows triggers evaluating, and tags just don’t fire. I’ve seen agencies bill days hunting this class of failure.
So add this to your permanent debug list: in the console on the ordering site, run window.Olo.data.device.isHybrid. It must return exactly false on web. And in GTM preview, when a tag mysteriously doesn’t fire on an Olo event, check the trigger’s filter evaluation before anything else.
If you run Serve App, this same mechanism is how the all-platforms container routes app traffic to separate tags via isHybridIOS and isHybridAndroid, so don’t delete the variables as clutter. They’re load-bearing.
What the Official Container Doesn’t Do (Four Gaps and Their Fixes)
Reading the full config exposes exactly where the official setup stops. Four gaps, each with the fix.
Gap 1: Login tracking is off. The imported template tag enables seven events; v1.userLogin isn’t one of them, and no login trigger or tag ships. Fix: open the “Olo Serve Integration – GA4” tag, tick the User login checkbox, then add a Custom Event trigger on login and a GA4 event tag passing the method dataLayer key. Five minutes, and you get sign-in method reporting (olo vs SSO vs Facebook) for free.
Gap 2: create_basket is wired but dead. The container ships the trigger (and a GA4 tag) for a create_basket event the template never emits. It’s waiting for a custom bridge to v1.createBasket, which the next section provides. Until then it shows zero in preview, and now you know that’s expected, not broken.
Gap 3: Funnel events carry no value or currency. The add_to_cart, begin_checkout, and view_item tags map only the items parameter. GA4 derives item revenue from the array, but the event-level value and currency Google Ads uses for value-based bidding signals and cart remarketing are absent. Fix: two variables. First, a JavaScript variable named JS – Olo Vendor Currency with the global path Olo.data.vendor.currency (the dataLayer only carries currency on purchase; this reads it from Olo’s global store on every page). Second, a Custom JavaScript variable named CJS – Olo Items Value:
function() {
var items = {{Olo Serve - GA4 - Ecommerce Items}} || [];
var total = 0;
for (var i = 0; i < items.length; i++) {
if (!items[i]) continue;
total += (parseFloat(items[i].price) || 0) * (items[i].quantity || 1);
}
return Math.round(total * 100) / 100;
}Then add two event parameters to the add_to_cart and begin_checkout GA4 tags: value mapped to the CJS variable and currency mapped to the vendor currency variable. Your Google Ads cart signals fill in immediately.
Gap 4: No ad platform tags at all. The container is a GA4 integration, full stop. No Conversion Linker, no Google Ads conversion, no Meta Pixel. Those are the next three sections.
Copy-Paste Code: Listen to Olo Events the Template Doesn’t Cover
The technique that unlocks everything, and I haven’t seen it published anywhere. Since Serve exposes window.Olo.on globally, a plain Custom HTML tag can subscribe to any global event directly, including v1.createBasket. The only trick is timing: your tag might run before the Olo object exists, so poll for it.
Tag type: Custom HTML. Trigger: the imported “Olo Serve – DOM Ready” trigger. Tag firing options: Once per page.
<script>
(function() {
var attempts = 0;
function subscribe() {
if (!window.Olo || typeof window.Olo.on !== 'function') {
attempts++;
if (attempts < 20) setTimeout(subscribe, 500);
return;
}
window.dataLayer = window.dataLayer || [];
window.Olo.on('v1.createBasket', function(basket) {
window.dataLayer.push({ ecommerce: null });
window.dataLayer.push({
event: 'create_basket',
basket_id: basket && basket.id ? basket.id : undefined
});
});
}
subscribe();
})();
</script>How it works: the function checks for window.Olo every 500ms, up to 20 tries, then registers a listener on Serve’s emitter. When Serve broadcasts v1.createBasket, the callback pushes create_basket to the dataLayer, where the container’s existing trigger and GA4 tag (gap 2 above) wake up and fire. Mind the platform filter from earlier: that trigger also checks Is Web App, so it works on web and correctly stays quiet in the app webview.
What it depends on: nothing but the Olo injection. No jQuery, no template. The ecommerce: null push mirrors the official template’s hygiene.
What to change: swap the event name to listen to anything else. The callback receives Serve’s raw objects, so v1.transaction hands you the complete order including fields the GA4 mapping drops. One caution: this runs on a live ordering site. Guard property access like the basket && basket.id pattern, because an uncaught error in your callback can interfere with Serve’s own flow, especially near checkout where Serve waits on analytics callbacks.
Copy-Paste Code: Google Ads Conversion Tracking for Olo Orders
Three pieces, and the firing order is the whole design.
Piece 1: Conversion Linker (the cookie writer). Tag type: Conversion Linker. Trigger: All Pages. Enable “Enable linking across domains” and list your domains comma-separated: yourbrand.com, order.yourbrand.com.
How it works: when a user lands from a Google ad, the GCLID arrives in the landing URL. The linker stores it in first-party cookies, which survive the GTM-blocked checkout because cookies don’t need JavaScript to persist. Without this firing early, your conversion tag on the confirmation page has no click to claim.
Piece 2: The variables. The import gave you ecommerce.transaction_id, ecommerce.value, ecommerce.currency, ecommerce.tax, and ecommerce.shipping as Data Layer Variables. Optionally, for gross order value instead of subtotal, create CJS – Olo Order Total:
function() {
var value = parseFloat({{Olo Serve - GA4 - Ecommerce Value}}) || 0;
var tax = parseFloat({{Olo Serve - GA4 - Ecommerce Tax}}) || 0;
var shipping = parseFloat({{Olo Serve - GA4 - Ecommerce Shipping}}) || 0;
return Math.round((value + tax + shipping) * 100) / 100;
}GTM resolves the three references at fire time from the same purchase push, sums, and rounds to cents. Use it only if your bidding should see gross value; most accounts should bid on subtotal and skip this.
Piece 3: The conversion tag. Tag type: Google Ads Conversion Tracking, with your Conversion ID and Label. Trigger: the imported “Olo Serve – GA4 – Web – Purchase” trigger. Map:
- Conversion Value:
{{Olo Serve - GA4 - Ecommerce Value}}(or the CJS total) - Transaction ID:
{{Olo Serve - GA4 - Ecommerce Transaction ID}} - Currency Code:
{{Olo Serve - GA4 - Ecommerce Currency}}
How the pieces connect: linker writes the click cookie on landing, the Olo template fires purchase on confirmation, the trigger (after passing the platform filter) fires the conversion tag, GTM resolves the variables from the same push, and the Transaction ID (the displayId) deduplicates if the customer refreshes the confirmation page. Use the native tag rather than a hand-rolled gtag snippet; the native tag handles consent mode and linker cookies for you.
Copy-Paste Code: Meta Pixel Purchase Tracking on Olo
Olo injects only your GTM container, so the Pixel lives inside GTM. Two tags with a sequencing dependency.
Tag 1: Base Pixel. Custom HTML, trigger All Pages, firing option Once per page.
<script>
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
document,'script','https://connect.facebook.net/en_US/fbevents.js');
fbq('init', 'YOUR_PIXEL_ID');
fbq('track', 'PageView');
</script>How it works: Meta’s standard loader creates the fbq command queue immediately, so calls made before the library finishes loading still process, then pulls the script asynchronously. Firing on All Pages also writes the _fbp cookie before checkout, same logic as the linker.
Tag 2: Purchase event. Custom HTML, trigger on the imported Purchase trigger. In Advanced Settings, Tag Sequencing, set the base Pixel as the setup tag so the queue exists before this runs.
<script>
(function() {
if (typeof fbq !== 'function') return;
var items = {{Olo Serve - GA4 - Ecommerce Items}} || [];
var value = parseFloat({{Olo Serve - GA4 - Ecommerce Value}}) || 0;
var currency = {{Olo Serve - GA4 - Ecommerce Currency}} || 'USD';
var txnId = {{Olo Serve - GA4 - Ecommerce Transaction ID}} || '';
var contents = [];
var numItems = 0;
for (var i = 0; i < items.length; i++) {
var it = items[i];
if (!it) continue;
contents.push({
id: it.item_id || it.item_name || 'unknown',
quantity: it.quantity || 1,
item_price: parseFloat(it.price) || 0
});
numItems += it.quantity || 1;
}
fbq('track', 'Purchase', {
value: value,
currency: currency,
contents: contents,
content_type: 'product',
num_items: numItems
}, { eventID: txnId });
})();
</script>Line by line: the fbq typecheck bails safely if the base Pixel failed. The four GTM variable references resolve at fire time to values from the same purchase push the trigger fired on, which is why this reads variables instead of scanning the dataLayer array manually (scanning would also trip over the ecommerce: null resets). The loop reshapes GA4 items into Meta’s contents format, and these are Olo’s merged items, so quantities are already deduplicated with averaged prices, keeping Meta’s value math consistent with GA4’s.
The eventID carries the displayId. The day you add the Conversions API, send the same order number as the server event’s event_id and Meta deduplicates browser and server events instead of double-counting. Setting it now costs nothing and saves a migration later.
Repeat the pattern for add_to_cart mapping to AddToCart and begin_checkout to InitiateCheckout, using the gap 3 value and currency variables so Meta gets cart values too.
Can You Use the Same Olo Triggers for Bing, TikTok, and Every Other Ad Platform?
Yes, and this is the payoff of the whole architecture. The Custom Event triggers (purchase, add_to_cart, begin_checkout) aren’t Google-specific or Meta-specific. They’re just GTM triggers listening to dataLayer events, and any tag from any platform can fire off them. One trigger, unlimited tags. You never build per-platform tracking on Olo again; you add one more tag to the existing trigger. Microsoft Ads, TikTok, Pinterest, Snapchat, an affiliate postback, a CDP event: same pattern every time. Base pixel on All Pages so its cookie exists before the GTM-blocked checkout, event tag on the shared trigger reading the same variables.
Here’s Microsoft Ads done fully, since restaurant brands run it more than people admit.
Copy-Paste Code: Microsoft Ads (Bing) UET Purchase Tracking on Olo
Tag 1: UET base tag. Custom HTML, trigger All Pages, firing option Once per page. Replace YOUR_UET_TAG_ID with the tag ID from Microsoft Ads (Tools, UET tag).
<script>
(function(w,d,t,r,u){var f,n,i;w[u]=w[u]||[],f=function(){
var o={ti:"YOUR_UET_TAG_ID", enableAutoSpaTracking: true};
o.q=w[u],w[u]=new UET(o),w[u].push("pageLoad")},
n=d.createElement(t),n.src=r,n.async=1,
n.onload=n.onreadystatechange=function(){var s=this.readyState;
s&&s!=="loaded"&&s!=="complete"||(f(),n.onload=n.onreadystatechange=null)},
i=d.getElementsByTagName(t)[0],i.parentNode.insertBefore(n,i)
})(window,document,"script","//bat.bing.com/bat.js","uetq");
</script>One line in there matters more on Olo than anywhere else: enableAutoSpaTracking: true. Serve is a single-page app, and without that flag UET only records the first page load, missing every menu navigation after it. Microsoft’s own snippet ships without it; on Olo, you want it on.
Tag 2: Purchase event with variable revenue. Custom HTML, trigger on the imported Purchase trigger, with the UET base tag set as the setup tag in Tag Sequencing.
<script>
window.uetq = window.uetq || [];
window.uetq.push('event', 'purchase', {
revenue_value: parseFloat({{Olo Serve - GA4 - Ecommerce Value}}) || 0,
currency: {{Olo Serve - GA4 - Ecommerce Currency}} || 'USD'
});
</script>How it works: same mechanics as the Meta tag. The GTM variables resolve at fire time from the purchase push, revenue_value carries the subtotal, and currency keeps multi-country brands honest. UET queues the call even if bat.js is still loading, so the only hard dependency is the sequencing.
Then in Microsoft Ads: Tools, Conversion goals, create a goal of type Event, set the action to equal purchase, and choose “the value of this conversion action may vary” so revenue_value flows through. Without that goal configured, the event arrives and counts nothing.
Same subtotal caveat as everywhere else: Microsoft will report the same number GA4 and Google Ads report, which keeps all three platforms consistent with each other and consistently below Olo’s order totals. That’s the correct, reconcilable state.
How to Fix Cross-Domain Tracking Between Your Restaurant Site and Olo
The classic restaurant attribution leak: marketing site on yourbrand.com, ordering on order.yourbrand.com or an Olo-hosted domain, and GA4 attributes orders to “referral / yourbrand.com” instead of the ad click that drove them.
If ordering lives on a subdomain of your main domain, the GA4 cookie sits on the root domain and sessions carry over automatically. Run the same Measurement ID on both and confirm GTM is on each side.
If the ordering domain is entirely different, three steps:
- GA4 Admin, Data Streams, your stream, Configure tag settings, Configure your domains: add both. GA4 appends the
_gllinker parameter to links between them, carrying the client ID across. - Same screen, List unwanted referrals: add the ordering domain so the hop doesn’t start a new referral session that steals attribution.
- The Conversion Linker with cross-domain linking enabled does the same job for the GCLID.
Verify with one click: go from your main site’s Order Now button to the ordering site and look for _gl= in the landing URL. Missing means the link is built by JavaScript in a way the linker can’t decorate, and the outbound button needs auditing.
Should You Use jQuery in GTM Tags on Olo
Olo’s docs say no, and the source backs them up: Olo’s own template contains zero jQuery, and Serve doesn’t guarantee the library exists. Simo Ahava’s article shows how to load jQuery via tag sequencing when you must, and it’s solid engineering, but on Olo you never need it. Notice every code block in this guide is vanilla JavaScript, and the cleanest data access doesn’t touch the DOM at all: it reads the emitter and the dataLayer.
When you do need something off the page, the safe Custom JavaScript variable pattern:
function() {
var el = document.querySelector('.your-selector');
return el ? el.textContent.trim() : undefined;
}The null check is non-negotiable. Serve is a single-page app; elements mount and unmount between routes, and a throwing variable kills every tag referencing it. Common swaps: $(sel).text() becomes document.querySelector(sel).textContent, $(sel).val() becomes .value, $(sel).length becomes document.querySelectorAll(sel).length, $(el).closest(sel) is already native. If a vendor tag hard-requires jQuery and won’t be rewritten, load it once per page via a setup tag in a sequence exactly as Simo describes, and log it as technical debt.
How to Debug GTM on an Olo Ordering Site
The checklist I run on every Olo account, in order, with what each step proves:
- Injection check. View source on the live ordering URL, search for your GTM container ID. Absent means Olo hasn’t ingested it; nothing else matters until your CSM confirms.
- Emitter check. In the console, type
window.Olo. You should see the object with itsonmethod anddatastore.window.Olo.data.vendorshows the location’s data including currency. IfOlois undefined, you’re not on Serve, full stop. - Platform flag check. Run
window.Olo.data.device.isHybrid. It must return exactlyfalseon web. Anything else (undefined included) and the hidden trigger filter blocks every GA4 tag while showing no errors anywhere. This single line has saved me more hours than any other on Olo accounts. - Template check. GTM Preview on the ordering URL. Confirm “Olo Serve Integration – GA4” fires on DOM Ready. In debug mode the template logs lines like
Olo Serve GTM: listening for "v1.transaction" events, telling you exactly which subscriptions are active. An event logging “NOT listening” means its checkbox is off in the template tag, which is the default state of User login. - DataLayer check. Interact with the menu and watch
dataLayerin the console: theecommerce: nullreset followed by each event push with populated items. - Purchase check. Place a real low-value test order. On confirmation, verify
purchasefired and that the GA4 tag, Google Ads conversion, and Meta Purchase all fired off it. The transaction_id should match the order number on screen, and the value should match the subtotal on your receipt, not the total. If you expected the total, reread the revenue section before filing a bug. - Downstream check. GA4 DebugView for the event stream, Google Ads conversion diagnostics within a few hours, Meta Events Manager Test Events with the eventID visible.
And remember Tag Assistant will appear to lose the container on the checkout page. That’s the documented block, not a broken setup.
Need Olo Tracking and Restaurant SEO That Actually Reconciles?
Wiring the tags is half the job. The other half is making four systems agree: Olo’s order reports, GA4 revenue, Google Ads conversion value, and Meta attribution, each measuring slightly different numbers for the reasons this guide just documented. At Hustle Marketers we run the full restaurant stack and have done it in production for multi-location brands like Curry Up Now: GTM and GA4 builds on Olo and other ordering platforms, Google Ads and Meta conversion tracking, and the restaurant SEO that fills the top of the funnel those pixels measure. If your ordering data and your ad platforms are telling different stories, we’ll find the gap and close it.
Conclusion
Olo’s tracking system is better engineered than its documentation suggests. A global emitter broadcasts every ordering action, an open-source template translates them to GA4’s spec, and the scary checkout block is designed around: begin_checkout fires before the blocked page, purchase fires after it. The traps live in details the docs skip. Revenue is subtotal. Transaction IDs are display order numbers. Item prices average across modifiers. Every trigger hides an inverted platform filter that fails silently. Login tracking ships disabled. Import the web container, patch the four gaps, wire the linker, conversion, and Pixel tags above, and place one test order checked against your receipt. Then ship it.
FAQs
How does Olo send ecommerce data to GTM? Serve exposes a window.Olo event emitter. Olo’s GTM template subscribes via Olo.on and pushes GA4-spec events to the dataLayer.
Which Olo container file should I import? The ga4-web JSON. The iOS and Android files are marked NOT SUPPORTED because GA4 doesn’t work in hybrid apps.
Why did my Olo GA4 tags suddenly stop firing? Check window.Olo.data.device.isHybrid in the console. Every trigger filters on an inverted version of it, and undefined silently blocks all tags.
Why is GA4 purchase revenue lower than Olo’s reports? The template maps value to the order subtotal. Tax and delivery are separate parameters, and tips aren’t tracked at all.
Does GTM work on Olo’s checkout page? No, checkout blocks GTM. The begin_checkout event fires before that page, and purchase fires on the GTM-enabled confirmation page.
Why doesn’t the create_basket tag ever fire? The container ships its trigger and tag, but the template never emits the event. Bridge v1.createBasket yourself with a Custom HTML listener.
Is login tracking included in Olo’s container? No. The v1.userLogin event is unchecked by default. Enable it in the template tag and add your own login trigger and GA4 tag.
Do I need jQuery for GTM tags on Olo? No. Olo’s own template uses none and Olo recommends document.querySelector. Sequence-load jQuery only for stubborn vendor tags.
Can the same Olo triggers fire Bing, TikTok, or other pixels? Yes. The purchase and cart triggers are platform-agnostic GTM triggers. Any tag, including Microsoft Ads UET, can fire off them.









