Headless Indirect Tracking
How it works
Indirect tracking connects a purchase on your Shopify store back to the Funneled funnel that influenced it — even when the customer leaves and comes back later through a different channel.
- A customer visits one of your funnel pages
- Funneled sets a tracking cookie on your root domain
- The customer returns to your headless store later (via Google, direct, email, etc.)
- The
applyFunneledAttribution()function reads the cookie and writes attribution data to the Shopify cart - When the customer checks out, Funneled records the conversion in your analytics
On standard theme stores, Funneled installs the listener automatically. For headless setups, call applyFunneledAttribution() on the client after your cart is initialised.
Prerequisites
Your storefront must share a root domain with your funnel subdomain — the _gf_attr cookie is scoped to it.
| Funnel subdomain | Store domain | Works? |
|---|---|---|
pages.acme.com | acme.com | Yes |
pages.acme.com | shop.acme.com | Yes |
pages.acme.com | mystore.myshopify.com | No — different root domains |
You'll need:
- Your Shopify public Storefront API token (
PUBLIC_STOREFRONT_API_TOKEN) - Your store's myshopify domain (e.g.
mystore.myshopify.com) - The active cart GID — sourced from wherever your app manages cart state
Implementation
// lib/funneled-attribution.js
const CART_ATTRIBUTES_UPDATE = `
mutation cartAttributesUpdate($cartId: ID!, $attributes: [AttributeInput!]!) {
cartAttributesUpdate(cartId: $cartId, attributes: $attributes) {
cart { id }
userErrors { field message }
}
}
`;
function getCookie(name) {
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return match ? match[1] : null;
}
function payloadHash(p) {
return [p.pid, p.tid, p.fid, p.uid, p.vid, p.eid].join('|');
}
/**
* @param {object} config
* @param {string} config.storeDomain - e.g. "mystore.myshopify.com"
* @param {string} config.storefrontToken - public Storefront API token
* @param {string} config.storefrontVersion - Storefront API version, e.g. "2025-01"
* @param {string} config.cartId - active cart GID from your cart state
*/
export function applyFunneledAttribution({storeDomain, storefrontToken, storefrontVersion, cartId}) {
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
const run = () => {
try {
if (!cartId) return;
const raw = getCookie('_gf_attr');
if (!raw) return;
const p = JSON.parse(decodeURIComponent(raw));
if (!p?.pid) return;
if (p.ts && Date.now() - p.ts > TTL_MS) return;
const applied = JSON.parse(localStorage.getItem('_gf_applied') || '{}');
if (applied.hash === payloadHash(p)) return;
// Mark before the fetch — prevents duplicate sends on re-render or navigation
localStorage.setItem('_gf_applied', JSON.stringify({hash: payloadHash(p)}));
const attributes = [
{key: '_gf_src', value: 'indirect'},
p.pid && {key: '_gf_pid', value: p.pid},
p.tid && {key: '_gf_sid', value: p.tid},
p.fid && {key: '_gf_fid', value: p.fid},
p.uid && {key: '_gf_uid', value: p.uid},
p.vid && {key: '_gf_vid', value: p.vid},
p.eid && {key: '_gf_eid', value: p.eid},
].filter(Boolean);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5_000);
fetch(`https://${storeDomain}/api/${storefrontVersion}/graphql.json`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': storefrontToken,
},
keepalive: true,
signal: controller.signal,
body: JSON.stringify({query: CART_ATTRIBUTES_UPDATE, variables: {cartId, attributes}}),
})
.then(() => clearTimeout(timeout))
.catch(() => clearTimeout(timeout));
} catch (_) {
// Never surface attribution errors to the user
}
};
// Defer until idle — attribution never competes with rendering
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(run, {timeout: 2_000});
} else {
setTimeout(run, 0);
}
}Usage
Call applyFunneledAttribution once your cart state is available — on mount, after cart initialisation, or inside a cart context effect:
import {applyFunneledAttribution} from '~/lib/funneled-attribution';
applyFunneledAttribution({
storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN,
storefrontToken: process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_TOKEN,
storefrontVersion: '2025-01',
cartId: cart.id, // GID from your cart state, e.g. "gid://shopify/Cart/abc123"
});The function is safe to call multiple times — localStorage deduplication ensures the mutation only fires once per funnel session.
Verify the integration
- Visit a funnel page on your subdomain.
- Navigate to your headless store and place a test order.
- In Shopify Admin → Orders, open the order and confirm Funneled attribution data appears in the order's note attributes.
If attribution isn't appearing, check that your funnel subdomain and store share the same root domain (see Prerequisites above).
Disable indirect tracking
Toggle off from Funneled → Settings → Attribution. The widget stops writing the tracking cookie and applyFunneledAttribution becomes a no-op.
