Skip to content

E-commerce / Case 08

Preserving Meta and UTM attribution from ad click through the Shopify order

Variant navigation was stripping campaign parameters even while browser tracking still fired correctly. The solution preserved Meta and UTM values during product interaction, kept a 24-hour last-touch attribution snapshot, carried the valid data into checkout, and used Shopify Flow to persist the final six campaign fields on the order.

ShopifyMeta PixelUTMShopify FlowJavaScriptOrder metafieldsAttribution

What this solved for the business or user

The shopper did not need to see any of this. They could arrive from a Meta or Google campaign, change size or color, add products to cart, leave and return within the valid attribution window, and complete checkout normally. Behind the scenes, the store kept the campaign context intact and attached the valid last-touch values to the resulting order so the marketing team had something concrete to audit beyond browser pixel events.

What was happening

The storefront already fired important browser events, including product views, add-to-cart activity, and checkout completion. Meta browser identifiers such as _fbp and _fbc could also exist correctly. The gap appeared when product variant navigation rebuilt the URL as only ?variant=<id>, which silently removed fbclid and UTM parameters from an ad landing URL. A second requirement was to make the campaign context survive beyond the visible URL and become available on the Shopify order itself. The attribution model therefore had to cover two separate failure points: preserving campaign context during storefront navigation, then persisting the newest valid campaign snapshot through cart and checkout.

Why the obvious solution was not enough

Variant selection still needed to update the URL so the selected product option remained shareable and restorable. Attribution could not be kept forever, because an old campaign should not be credited to a purchase weeks later. The agreed model was last-touch, not first-touch: capture utm_source, utm_medium, utm_campaign, utm_content, gclid, and fbclid; keep the snapshot valid for 24 hours from capture; replace the entire snapshot when a newer attributed visit arrives; do not refresh the expiry on an unattributed visit; and leave the order fields empty when no valid attribution exists. Restored or logged-in carts also had to be cleaned so stale cart attributes could not outlive the browser-side attribution record.

How the solution works

  1. Reproduce the URL-loss bug from a realistic campaign landing URL containing fbclid and UTM parameters, then change product variants several times instead of testing only a clean product URL.
  2. Change the variant-navigation code to start from the current URL object, update only the variant parameter, and preserve the rest of the existing query string. history.replaceState keeps the selected variant in the address without adding a browser-history entry for every size or color click.
  3. Define one attribution contract with exactly six marketing fields: utm_source, utm_medium, utm_campaign, utm_content, gclid, and fbclid. Treat those six values as one snapshot so a newer touch replaces the older set instead of mixing fields from different campaigns.
  4. When a valid marketing visit is detected, store the snapshot with captured_at, expires_at, landing_page, referrer, and cart-state context. Keep the working copy in sessionStorage with a cookie fallback so attribution can survive the navigation patterns required by the storefront.
  5. Use a strict 24-hour TTL measured from the original capture time. An unattributed visit does not extend the timer. When the record expires, remove the browser-side snapshot and clear any stale Shopify cart attributes that could otherwise reach a future checkout.
  6. When both sessionStorage and the cookie contain valid snapshots, choose the newest captured_at value. This prevents an older fallback copy from replacing a newer last-touch visit.
  7. Sync the valid six-field snapshot into Shopify cart attributes before checkout. That makes the attribution available downstream as order custom attributes instead of relying only on the current browser URL at the moment the customer pays.
  8. Keep a line-item-property fallback for older or alternate storefront paths where the order custom attributes may not contain a field. The extraction logic prefers order custom attributes first, then checks the fallback properties using both public and underscore-prefixed keys.
  9. Use Shopify Flow after order creation to read the attribution values, normalize the six outputs, and write them into dedicated order metafields: custom.utm_source, custom.utm_medium, custom.utm_campaign, custom.utm_content, custom.gclid, and custom.fbclid.
  10. Re-check attribution state after browser restore, login-related navigation, window focus, and visibility changes so a stale logged-in cart cannot keep campaign attributes after the 24-hour record has expired.
  11. Verify the full journey rather than only the URL: ad-style landing, variant changes, add to cart, cart updates, checkout, completed order, Shopify Flow run, and final order metafields.
Simplified end-to-end attribution pipeline
const ATTR_KEYS = [
  "utm_source",
  "utm_medium",
  "utm_campaign",
  "utm_content",
  "gclid",
  "fbclid",
];
const TTL_MS = 24 * 60 * 60 * 1000;

// 1) Preserve campaign parameters when the selected variant changes.
function updateVariantUrl(selectedVariantId) {
  const url = new URL(window.location.href);
  url.searchParams.set("variant", selectedVariantId);
  window.history.replaceState({}, "", url.toString());
}

// 2) Capture one complete last-touch snapshot.
function readAttributionFromUrl() {
  const url = new URL(window.location.href);
  const values = Object.fromEntries(
    ATTR_KEYS.map((key) => [key, url.searchParams.get(key) || ""]),
  );

  const hasMarketingTouch = ATTR_KEYS.some((key) => values[key]);
  if (!hasMarketingTouch) return null;

  const capturedAt = Date.now();
  return {
    ...values,
    captured_at: capturedAt,
    expires_at: capturedAt + TTL_MS,
    landing_page: window.location.pathname,
    referrer: document.referrer || "",
  };
}

// A newer valid touch replaces the complete older snapshot.
// Normal visits do not refresh expires_at.
function isValid(snapshot) {
  return snapshot && Number(snapshot.expires_at) > Date.now();
}

// 3) Put only the current valid snapshot on the Shopify cart.
async function syncCartAttribution(snapshot) {
  const attributes = Object.fromEntries(
    ATTR_KEYS.map((key) => [key, isValid(snapshot) ? snapshot[key] || "" : ""]),
  );

  await fetch("/cart/update.js", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ attributes }),
  });
}

// 4) Shopify Flow: prefer order custom attributes, then fall back
// to line-item properties. The exact Flow input wrapper can vary.
function getOrderAttribution(order) {
  const orderAttrs = order.customAttributes || [];
  const lineProps = (order.lineItems || []).flatMap(
    (line) => line.customAttributes || line.properties || [],
  );

  function read(key) {
    const candidates = [key, `_${key}`];
    for (const candidate of candidates) {
      const fromOrder = orderAttrs.find((item) => item.key === candidate);
      if (fromOrder?.value) return fromOrder.value;

      const fromLine = lineProps.find((item) => item.key === candidate);
      if (fromLine?.value) return fromLine.value;
    }
    return "";
  }

  return Object.fromEntries(ATTR_KEYS.map((key) => [key, read(key)]));
}

// Flow then maps the six outputs to order metafields:
// custom.utm_source, custom.utm_medium, custom.utm_campaign,
// custom.utm_content, custom.gclid, custom.fbclid.

What should be verified before shipping

  • Open an ad-style product URL with fbclid plus UTM values, change variants repeatedly, and confirm only the variant value changes while campaign parameters survive.
  • Verify the selected variant still restores after refresh and when the resulting URL is copied into another tab.
  • Confirm _fbp and _fbc remain available where Meta expects them and that product_viewed, product_added_to_cart, and checkout_completed continue firing once after the URL fix.
  • Test a full six-field attribution snapshot and confirm the order receives utm_source, utm_medium, utm_campaign, utm_content, gclid, and fbclid through the downstream workflow.
  • Run a last-touch replacement test: capture campaign set A, then a newer valid campaign set B, and confirm only set B reaches the order. Fields that existed only in A, such as an old fbclid, must not leak into B.
  • Test at approximately 23 hours and again after 24 hours. The valid record should still work before expiry and should be removed after expiry without being refreshed by an ordinary unattributed visit.
  • Test guest checkout, logged-in customer navigation, browser restore, page focus, and visibility changes to confirm stale Shopify cart attributes are cleared when the browser attribution has expired.
  • Inspect the Shopify Flow run and verify it prefers order custom attributes, falls back to line-item properties only when necessary, and writes the six dedicated custom.* order metafields.
  • Complete a checkout with no valid campaign snapshot and confirm attribution metafields remain empty instead of inheriting an older visit.
  • Compare the final order-level values with the original campaign landing parameters before treating the attribution pipeline as verified.

What changed

The storefront stopped losing campaign context when shoppers changed variants, and attribution no longer depended on the original query string still being visible at checkout. A valid last-touch snapshot could persist for up to 24 hours, move through Shopify cart and order attributes, and be written by Shopify Flow into six auditable order metafields. Expired or superseded campaign data was cleared rather than silently credited to a later purchase. The work improved attribution integrity and traceability; it did not make unsupported claims that ad CPA or conversion rate improved simply because the tracking data became cleaner.

Reusable lesson

Reliable attribution is a state-management problem, not only a pixel problem. Preserve campaign context when the storefront mutates URLs, define exactly when attribution starts and expires, carry only the valid snapshot into checkout, and verify the final order record rather than stopping at browser events.

How I would evaluate this today

The production workflow records attribution at the order level, not as a permanent customer-profile value. That keeps each purchase tied to the valid campaign context used for that order and avoids overwriting a reusable customer record with unrelated future visits.