Skip to main content

The E-commerce DataLayer Blueprint: Events That Actually Matter

A copy-paste datalayer specification: the seven events to implement, the parameters each needs, and the mistakes that break reporting.

Every measurement tool you attach to an e-commerce site ends up reading the same source of truth. GA4 reads it. The Meta pixel reads it through GTM variables. Your server-side container reads a forwarded copy. The affiliate network you haven't signed yet will read it in eight months. If the datalayer is wrong, it's wrong everywhere at once, and it fails quietly, because nothing in the stack tells you a number arrived as a string.

This is the spec I hand to an engineering team before anyone builds a tag. Seven commerce events, the shape of the items array, the identity problem that silently destroys funnel reports, timing around navigation, and the QA pass that catches whatever survived.

The DataLayer Is a Contract

Treat the datalayer as an API your front end publishes and your analytics stack consumes. That framing changes how people behave. An API has a version, a schema, an owner, and a rule against changing field names without telling consumers. A "tracking script" has none of that, which is why tracking scripts rot.

The practical consequence is that the spec lives in a document both developers and analysts sign off on, not in the head of whoever built the container. Store it in the repo, so a pull request that changes the product page has to touch the spec file too.

The second is that you stop putting tool-specific fields in the datalayer. No fbq_content_ids, no ga_transaction_id. Push one clean commerce object and let each tag map it. Meta wants content_ids, Google Ads wants id, Pinterest wants product_id, and all three can read items[].item_id through a GTM variable. Bake vendor names into the payload and you've guaranteed a developer ticket every time marketing adds a platform.

The third: the datalayer describes what happened, not what you want reported. It records that a user added two of SKU 4471-BLK-M at 34.00 GBP. It doesn't decide whether that counts as a conversion. Business logic belongs in the tag layer, where an analyst changes it without a deploy.

Seven Events That Carry the Funnel

You can measure a full commerce funnel with seven events. Everything else (refunds, wishlist adds, promo clicks) is useful but optional. These seven break reporting when they're missing or malformed.

EventFires whenRequired beyond itemsMost common defect
view_item_listA grid, carousel, or search result rendersitem_list_id, item_list_nameFires once per card instead of once per list
view_itemProduct detail page is readycurrency, valueMissing on variant switch in a SPA
add_to_cartThe cart API returns successcurrency, value, quantityFires on click, before the request succeeds
view_cartCart page or drawer openscurrency, valueDrawer reopens push a duplicate
begin_checkoutFirst checkout step renderscurrency, value, coupon if presentFires on every step rather than the first
add_payment_infoPayment method is validatedpayment_type, currency, valuepayment_type hardcoded to "credit card"
purchaseOrder confirmation renders, oncetransaction_id, value, tax, shippingRefires on page refresh or back navigation

Two notes that save arguments later. First, value is the sum of price times quantity across that event's items, and for purchase it conventionally includes tax and shipping even though both are also reported separately. Pick one convention and tell finance which, because they will eventually compare GA4 revenue to the order table.

Second, add_to_cart belongs on the API success callback, not the click handler. Sites with inventory checks will happily report thousands of adds that never entered a cart. You'll see it as an add-to-cart rate that looks great and a cart-to-checkout rate that looks broken.

Building the Items Array Correctly

The items array is where most specs go soft. All seven events carry it, and it should use identical field names in every one, so a single GTM variable reads it everywhere.

window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ ecommerce: null });
window.dataLayer.push({
  event: "add_to_cart",
  ecommerce: {
    currency: "GBP",
    value: 68.00,
    items: [{
      item_id: "4471-BLK-M",
      item_name: "Ridgeline Merino Crew",
      item_brand: "Ridgeline",
      item_category: "Apparel",
      item_category2: "Tops",
      item_category3: "Knitwear",
      item_variant: "Black / M",
      price: 34.00,
      quantity: 2,
      discount: 0,
      index: 3,
      item_list_id: "search_results",
      item_list_name: "Search Results"
    }]
  }
});

The ecommerce: null push above isn't superstition. The datalayer is a merge-on-push model, so if the previous event carried three items and this one carries one, a tag reading the merged object may see all four. Clearing the key first costs you one line.

Three field-level rules worth enforcing. price is per unit. discount is per unit too, expressed as the amount taken off rather than the discounted price. And item_category through item_category5 map to a hierarchy, so don't dump a slash-separated breadcrumb into the first one.

On begin_checkout and purchase, send the full line items with quantities. Truncating to the first item because "the payload got long" is a decision you will not remember making when product-level checkout analysis stops working.

The Item Identity Problem

This is the defect I find most often in containers that have been live a year, and the one that does the most damage per line of code.

On the product detail page, the front end knows the exact variant the user is looking at, so it pushes item_id: "4471-BLK-M". At checkout, the order object comes from the commerce platform, which stores line items against the parent product, so it pushes item_id: "4471". Both are technically correct. Together they make product-level funnel analysis impossible, because GA4 has no way to know the SKU viewed and the parent purchased are the same thing. Your item funnel shows views with no purchases for the variant and purchases with no views for the parent.

The fix is to choose one identity level and make every event use it. My default is the variant SKU, because that's what merchandising optimizes and what your feed uses, and because you can always roll variants up to a parent later if you also send the parent. What you cannot do is disaggregate a parent ID into variants after the fact.

So item_id carries the SKU on every event including purchase, and you add item_group_id for the parent. If your ad platforms match on parent IDs, map that field in the tag rather than changing the datalayer.

// Normalize identity at the push boundary, not in the tag.
function toItem(line) {
  return {
    item_id: String(line.sku),          // always the variant SKU
    item_group_id: String(line.parentId), // parent, for feed matching
    item_name: line.title,
    item_variant: line.optionLabel || undefined,
    price: Number(line.unitPriceExTax.toFixed(2)),
    quantity: Number(line.qty)
  };
}

Run one check before calling this done: pull the distinct item_id values from view_item and from purchase over the same week and compare formats. Different lengths, different casing, or a leading zero on one side all point to two systems disagreeing about what a product is. Feed IDs deserve the same comparison, since a mismatch there quietly kills dynamic remarketing.

Timing Pushes Around Navigation

Timing bugs produce the strangest symptoms: an event that fires on three quarters of sessions, a purchase with an empty items array, a page_view attributed to the previous URL. Almost all of them come from pushing at the wrong moment relative to navigation or render.

On a multi-page site, put commerce pushes in the HTML above the GTM snippet so the data exists the moment the container initializes. If it must come from an XHR, push after the response resolves.

Single-page apps need more care. The route changes before the new page's data exists, so the sequence matters:

  1. Route change begins. Clear the previous ecommerce object with { ecommerce: null }.
  2. New view's data resolves from the store or API.
  3. Push the virtual page view event carrying the new page_path and page_title.
  4. Push the commerce event (view_item, view_cart) with the resolved data.

Page view before commerce event, not after. GA4 tags read page location at fire time, so if view_item beats the route update you'll attribute product views to the previous URL. On variant switches that change price or SKU without a route change, fire another view_item. Users perceive a new product, your reports should too.

The purchase event needs an idempotency guard. Confirmation pages get refreshed, bookmarked, and reached by back button. Write the transaction_id to sessionStorage and skip the push if it's already there. A duplicate purchase inflates revenue and, worse, teaches everyone to distrust the number.

Currency and Numeric Type Discipline

Numbers must be numbers. "34.00" is a string, and depending on the consumer you'll get a zero, a dropped parameter, or a silent coercion that works until someone sends "34,00" from a locale-formatted template. Templating engines are the usual source, since anything interpolated into a JSON blob arrives quoted unless you explicitly cast it.

The rules are short. price, value, tax, shipping, and discount are numbers with at most two decimal places and no thousands separators. quantity and index are integers. item_id and transaction_id are strings, always, even when they look like numbers, because a numeric ID with a leading zero loses it and a very long one loses precision.

Currency is an uppercase three-letter ISO 4217 code at the event level. If you sell in multiple currencies, send the one the user transacted in, not a normalized reporting currency. GA4 converts using daily rates itself, and converting first produces a double-converted number nobody can reconcile. A missing currency on an event carrying value means GA4 discards the revenue entirely.

One guardrail that catches most of this in staging:

// Staging only. Wrap push and shout about type violations.
const orig = window.dataLayer.push;
window.dataLayer.push = function (obj) {
  const ec = obj && obj.ecommerce;
  if (ec && ec.value !== undefined) {
    if (typeof ec.value !== "number") console.warn("[dl] value not number", obj.event);
    if (!ec.currency) console.warn("[dl] value without currency", obj.event);
  }
  (ec && ec.items || []).forEach(function (i) {
    if (typeof i.price !== "number") console.warn("[dl] price", i.item_id);
    if (!Number.isInteger(i.quantity)) console.warn("[dl] qty", i.item_id);
  });
  return orig.apply(window.dataLayer, arguments);
};

Testing the Spec in Preview Mode

Preview mode tells you what the container saw, the only question that matters at this stage. Open Tag Assistant, connect to staging, and work the funnel end to end in one session: category page, product, add, cart, checkout with a coupon, test order.

For each event, click it in the left rail, open the Data Layer tab, and switch the toggle from Merged to the raw message. Merged view hides exactly the bug you're hunting, because a stale items array from two events ago still looks correct there.

Check four things per event: the name matches the spec character for character, ecommerce sits at the top level rather than nested, item count matches the screen, and numeric fields render without quotes. Then click the tag that fired and read the Variables tab. A tag that fired with an undefined items variable still shows green.

Green in Preview means the tag executed, not that anything was received. Keep GA4 DebugView open alongside and confirm the event lands with parameters intact. If the items array is empty there but populated in Preview, your variable mapping is the problem.

Finally, run the funnel with an ad blocker on and with consent denied. You're not looking for events to succeed, you're looking for the JavaScript to survive. A push that throws because a blocked script removed a global takes the rest of the page's tracking with it.

The QA Checklist Before Launch

Run this against staging, then again against production on launch day. Twenty minutes once you've done it twice.

If you automate one check, automate the reconciliation between GA4 purchase revenue and your order table, daily, alerting on a gap over two percent. It catches almost every failure on this list within a day.

Making the Spec Stick

A datalayer spec decays the moment nobody owns it. The version that survives is checked into the repo, referenced in the pull request template for anything touching product, cart, or checkout templates, and validated in CI against recorded payloads.

Start with the seven events, get item identity right on day one, enforce numeric types at the push boundary. Those decisions determine whether your commerce reporting is trustworthy for years or whether you spend next spring explaining why GA4 revenue doesn't match finance. The rest is detail you can add without breaking anything, which is the property a good contract should have.