You do not need to instrument checkout. Shopify hosts it, so our app’s Web Pixel already reports
checkout_started and checkout_completed for headless stores.Option 1: the AgentShop pixel
Render once in your root layout. TheAGENTSHOP_API_KEY you already set for SEO is the only configuration.
app/layout.tsx
<Script> with strategy="afterInteractive", the strategy Next documents for analytics.
What the script detects on its own
Every storefront-side standard event, with zero instrumentation:
Cart quantities are deltas — what this event added or removed, not the line’s new total. Shopify merges a repeat add into the existing line, so reporting the total would count it twice. A
cart(id:) query is also observed purely to establish the starting state, which is what lets a removal fire for an item added on a previous visit.
Checkout events (checkout_started … checkout_completed) come from our app’s Web Pixel on Shopify-hosted checkout — see the note above. Storefronts with non-standard routes can call the same events manually: window.AgentShopPixel("Search", { query }) and friends.
On any other server-rendered stack, fetch the config and render the tag yourself:
Why not a pasted script tag
The pixel token identifies your store to our ingest endpoint, and it changes — reconnecting the app or uninstalling and reinstalling it issues a new one. A token pasted into your codebase is frozen at the moment you deployed. The day it changes, every pixel request returns 401 and attribution stops silently until someone notices and redeploys.<AgentShopPixel /> asks for the URL at render time and revalidates every 5 minutes, so a changed token is picked up on its own and no analytics credential is ever written into your source.
Option 2: forward Shopify’s analytics bus
If your storefront already runs Shopify’s analytics bus —<Analytics.Provider> in Hydrogen, or ShopifyScripts from the framework-agnostic Hydrogen SDK — forward that instead. You get Shopify’s typed event payloads, Shopify’s consent gating, and Shopify’s visitor identity rather than ours.
server
client
subscribeAgentShop instead. Fetch pixel server-side (a loader, a server component) and pass it down as a prop — fetchPixelConfig() returns null when the store has no pixel configured or the endpoint is unreachable, so the component below no-ops until it has a real config:
app/components/AgentShopAnalytics.tsx
Events forwarded
page_viewed · product_viewed · collection_viewed · cart_viewed · search_viewed · product_added_to_cart · product_removed_from_cart
cart_updated is deliberately not forwarded — it fires alongside the add and remove events for the same change, and reporting all three would triple-count one cart action.
What’s in event_data
event_data is an allowlist — exactly the flat projection the ingest endpoint reads: whichever of product_id, product_title, price, quantity, variant_id, handle, collection_id, search_term apply to that event type. A field is present only when Shopify’s bus payload actually carries it; nothing is null-filled, and the raw bus payload is deliberately not forwarded — Shopify’s payloads can carry full URLs and customer fragments the ingest never reads.
On cart line events, quantity is the delta — how many this event added or removed — not the line’s post-update quantity. Shopify publishes both the previous and current line on an increment/decrement, and “added 1 more (now 3)” reporting as 3 would triple-count.
Prerequisite: the Storefront API proxy
uniqueToken and visitToken come from Server-Timing headers on Storefront API responses, which Shopify only exposes to a same-origin request at /api/<version>/graphql.json.
If your storefront calls the Storefront API directly from the browser against *.myshopify.com, both values are empty and this destination sends nothing — deliberately. An event stamped with an identifier we invented is worse than a missing one, because it cannot be joined to the order that follows.
Shopify stopped setting _shopify_y and _shopify_s on 1 January 2026 and deprecated them on 30 April 2026. The proxy is the supported replacement — see Shopify’s migration guide for custom headless builds, which also covers useShopifyCookies({ fetchTrackingValues: true }).
Consent
Every event is gated on Shopify’s own answer:<Analytics.Provider> uses for its canTrack prop. It already encodes what varies by region — opt-in before tracking in some, opt-out in others — so this package never re-derives consent from a country code or a cookie.
The check runs per event, because consent can be granted or withdrawn mid-session.
The two paths differ on an unloaded API, and the difference matters.
Load it with useCustomerPrivacy() on Hydrogen, or loadFeatures on any other headless storefront:
setTrackingConsent, passing headlessStorefront: true. Pass a canTrack function to Option 2 if your storefront establishes consent some other way.
What Option 1 writes to the browser
Once it proceeds, the served script setsagentshop_llm (90 days, acquisition source), agentshop_cid (1 year, visitor id), agentshop_sid (24 hours, mirrored in sessionStorage), a localStorage visit counter (agentshop_vc), and — unless marketing consent is explicitly denied — Meta’s _fbp (1 year). These are the cookies ePrivacy Art. 5(3) consent rules govern, which is why the API has to be loaded.
What’s sent
Every event that passes the gate above also carries aconsent object, built from the same Customer Privacy API:
marketingAllowed() answers marketing, adUserData, and adPersonalization together — Shopify exposes one marketing verdict, mapped onto all three ad-consent keys. saleOfData comes from saleOfDataAllowed(), unless the visitor’s browser sends Global Privacy Control (navigator.globalPrivacyControl) — a legally-binding Do-Not-Sell signal in effect in a number of US states since January 1, 2026. GPC forces saleOfData: false regardless of what Shopify’s API answers, and applies even when the Customer Privacy API isn’t loaded at all.
A key is present only when Shopify (or GPC) has actually answered. An unanswered question is omitted, never sent as false — an omitted key can never overwrite a previously recorded consent answer downstream.
Both paths read ad-click identifiers for ad-attribution reporting —
gclid, wbraid, gbraid, ttclid off the URL, fbc (from Meta’s _fbc cookie, or built from an fbclid per Meta’s spec), and fbp. An explicit marketing denial (marketingAllowed() returning false) withholds all of them — the served script sends them as null and skips writing the fbp cookie even if one exists; the analytics destination omits them entirely and never writes cookies at all. While consent is unanswered they flow normally, and the consent object above travels with the event for downstream enforcement.Why you may see no events at all
Both paths report AI-referred visits, and that is all the dashboard shows. An event with no acquisition signal — noutm_source, no external referrer — still ships and is captured (the endpoint answers {"success": true, "attributed": false}), but it doesn’t surface as an AI-referred visit. Loading your own storefront directly is exactly that case, so a correct install can look broken.
To verify, arrive with a source attached:
How this differs from the served script
The served<script> behind <AgentShopPixel /> keeps its own memory of how a visitor arrived: a last-non-direct model, storing the canonical source it resolved (chatgpt, perplexity, …) — not the raw UTM or referrer — in a 90-day cookie that spans sessions, overwritten each time a new source is detected. The analytics destination in Option 2 doesn’t: it reads UTM parameters and document.referrer fresh off the current page on every event, so an event fired several pages after the landing page may carry neither if the visitor navigated internally since.
Delivery differs in one respect: both try navigator.sendBeacon first (no retry, ~64 KiB per call) and fall back — the destination to fetch({ keepalive: true }), the served script to XHR, which it retries up to 3 times on an HTTP error. So the real difference is retry-on-error, not the beacon path or a hard size ceiling.
What is and isn’t collected
IP address. The browser never sends one — but the ingest endpoint reads the connection IP, stores it on the visitor record, and passes it to Meta’s Conversions API asclient_ip_address, which Meta uses for event matching. Meta’s spec requires that parameter to be sent unhashed, so it leaves as a raw address. IP is personal data under GDPR Art. 4(1) and that transfer is a separate processing purpose, so it belongs in your privacy policy. It is not gated by the marketing consent signal below — if you need it withheld, block the event.
(The crawler endpoint is different: it accepts no IP field at all and derives its identifier from host + user agent.)
Cookies and storage. Option 1’s served script writes them; Option 2’s destination writes none. See the cookie list.
Everything else. No request bodies, and no headers beyond the user agent and referrer. The ad-click identifiers described above are the exception, and only when marketing consent hasn’t been explicitly denied — with one further carve-out: ga_client_id, read from your own _ga cookie, sits outside the marketing gate and is sent whenever analytics is allowed.
No query strings or fragments either: both paths strip url and referrer to origin + pathname before anything leaves the browser. A magic-link or reset token in a query string never reaches analytics; the only query parameters attribution uses are the UTM keys and click identifiers, each extracted into its own allowlisted field.
You send the raw signals a visit arrived with; attribution is resolved server-side. That keeps it accurate as AI platforms change how they tag outbound links, with no redeploy on your side.
Next steps
AI crawlers
Crawlers don’t run JavaScript. Report them from your server.
Endpoint reference
The pixel and event endpoints, with auth and response shapes.

