Headless & Custom Shopify Storefronts

Overview

Convert's Shopify app delivers client-side tracking through a theme app extension, which is Liquid-only. On a headless or custom storefront — Hydrogen, Oxygen, Next.js, Nuxt, or any front end built on the Storefront API — that extension cannot render. Nothing then writes the visitor and bucketing data that Convert's web pixel reads at checkout.

The failure is silent at every layer. Experiences still run, variations still apply, and the storefront looks healthy — but orders arrive at Convert with empty bucketing data, revenue attribution stays flat, and split tests report "variation lost" because conversions were never attributed to the variation that produced them. There is no error, no console warning, and no failed request to notice.

This page is the complete install path for that case. You add one generated block to your project's Global JavaScript, and implement one piece yourself — the carrier that writes the resulting attributes onto the Shopify cart, which is the only part that differs per platform.

Scope: this page covers bucketing persistence and conversion attribution. Per-variation pricing and shipping-rule behaviour on a custom storefront is a separate mechanism and is not covered here.

What a Headless Storefront Is Missing

Convert's Shopify integration needs four cookies. The install-side split is two of four:

CookieContentsWritten byOn a headless storefront
_conv_vVisitor identity and stateThe tracking scriptWorks, unchanged
_conv_sSession detectionThe tracking scriptWorks, unchanged
_conv_gVisitor segmentsThe theme app extensionNothing writes it — the snippet on this page does
_conv_dBucketed experiences and the cookie domainThe theme app extensionNothing writes it — the snippet on this page does

The tracking script sets _conv_v and _conv_s on your domain exactly as it does on any site — see Cookies Reference. The other two, plus the Shopify cart attributes the pixel reads at checkout, are what a headless install has to supply for itself — and _conv_g and _conv_d are written only when cookies are writable, with a localStorage fallback when they are not.

Installation

1. Load the Tracking Script

Add the script for your account and project to every storefront page, as on any other site:

<script src="https://cdn-4.convertexperiments.com/v1/js/[account_id]-[project_id].js"></script>

2. Add the Generated Snippet to Global JavaScript

The Global JS block is published separately, as the Shopify Headless Cart Attributes recipe. That code block is a generated, version-stamped build artefact, emitted from its own maintained source — which is a hand-written port of the theme app extension's logic, not a copy of it. What holds the two together is a build-time drift gate: every build executes the generated block and the theme app extension against the field set the pixel actually consumes, and fails the build, naming the missing field, the moment either one stops supplying something the pixel reads. Drift therefore cannot ship unnoticed — but it is repaired by a person and a new snippet version rather than corrected silently underneath you, so after an upgrade take the current block from the recipe again and check its version. The prose on this page is maintained by hand; only the recipe's code block and its version line are machine-written.

The version stamp is visible in the block you paste, and it also travels inside the __data cart attribute. If you raise a support ticket about this install, quote that version — it identifies exactly which snippet you are running.

Paste the block into Project Settings → Global JavaScript in the Convert app. Do not put it in a bundled application file, a component, or a framework hook. See Timing: Why Registration Must Be Synchronous below for why placement is not a matter of preference here.

The three globals in play. Only one of them is yours to touch:

GlobalWho declares itWhat you do with it
window._conv_qThe snippet initialises it defensively, never by plain assignmentNothing — but see Initialising the Queue below if you also push commands of your own
window.convertThe tracking script, as its own API objectNothing. You never declare it
window.convertHeadlessThe snippetFill in config, assign transport. Everything else on it is the snippet's own state

window.convertHeadless is the snippet's whole surface:

window.convertHeadless = {
  config,     // the seven values you fill in — see below
  transport,  // the carrier you assign — see step 3
  registered, // the snippet's own registration guard
  cartUpdate, // the shared debounce and abort state
  version     // the support stamp
};

Both config and transport are seeded with ||, so a value you set earlier on the page always wins over the snippet's placeholder.

If you are moving from a Liquid theme: the theme app extension gets the same values from Liquid instead, as _conv_shopify_data_tpl (six of them) and _conv_webhook_id. Those two globals do not exist on a headless install and the snippet neither reads nor declares them — the seven Liquid-templated values become convertHeadless.config here. window.convertShopify is a third, unrelated namespace; see Which Surface Does What below.

The seven values you supply. On a Liquid theme these are resolved from the Shopify app's metafields. On a headless storefront nobody resolves them, so you fill them in by hand.

Only accountId and projectId are required — they are the render gate. The rest are optional to the snippet's own gates, which is not the same as harmless to omit: currency, revenueGoalId, subscriptionGoalId and oneTimePaymentGoalId all still travel to the pixel inside __data, as blank values, whether or not you fill them in. webhookId is the one exception — it never travels in __data and does nothing but gate the write.

ValueRequiredNotes
accountIdYesRender gate — see below
projectIdYesRender gate — see below
currencyNoThe currency to record revenue in
revenueGoalIdNoYour Convert revenue goal. In the Shopify app this is the Checkout Completed goal (checkoutCompletedGoalId) — note the rename. Blank means no revenue goal ID reaches the pixel
subscriptionGoalIdNoSubscription-order goal
oneTimePaymentGoalIdNoNon-subscription-order goal. In the Shopify app this is nonSubscriptionGoalId — note the rename
webhookIdNoYour Shopify order webhook ID. Gates only the cart-attribute step — see below. Blank still persists bucketing to the cookie and localStorage tiers

The accountId / projectId render gate. The snippet no-ops entirely — it registers nothing and writes nothing — unless both accountId and projectId are present and are not the literal string 'null'. This mirrors the gate the Liquid wrapper applies, and it is why a half-filled config block produces silence rather than a partial install.

The cart-write gate is two-condition. The snippet skips the cart write when webhookId is blank or falsy or when it is the literal string 'null'. Both arms matter. A value templated out of a Shopify metafield can render as the four-character string 'null', which is not falsy — a check that only tested for blank would let a shop with no order webhook pass the gate and issue cart writes anyway.

3. Assign the Transport Carrier

This is a required install step, not an optional refinement. Nothing is sent anywhere until you do it. The snippet builds the attributes object and hands it to a carrier you assign; it never writes to the Shopify cart itself, and with no carrier assigned it silently stops at that seam.

Assign your carrier to window.convertHeadless.transport, from your application code rather than from Global JavaScript — see Assign the Carrier Early below for why the two differ and for the queuing stub that covers the first firing:

window.convertHeadless.transport = function (attributes, {signal}) {
  return myCartAttributesUpdate(attributes, {signal});
};

The snippet checks that transport is a function before calling it, so an unassigned carrier produces no error and no warning — the same silence as a late registration. Which carrier goes here depends on your platform: see Transport: Getting the Attributes onto the Shopify Cart below for the implementation per case, and pass signal through to keep single-flight behaviour.

Assign it late and you lose that firing. The snippet reads transport at the moment its 300 ms debounce fires — not when the handler runs, and not when you eventually get around to assigning it. On a cold page load snippet.goals_evaluated fires early in the tracking script's run cycle, so that read happens roughly 300 ms later. A carrier for the Storefront API cannot be ready by then: it needs your framework's cart context and a storefront access token, so it is assigned after hydration by definition. The attributes for that firing are built and then dropped — no error, no warning, nothing sent. A visitor who lands, adds to cart and checks out without an SPA route change therefore gets no cart attribute at all, because there is no second firing to rescue it.

The remedy is to assign a queuing stub in Global JavaScript, immediately after the pasted block, and swap the real carrier in from your application once your cart layer is ready. The two halves live in different places and that split is load-bearing: only the stub belongs in Global JavaScript.

// Global JavaScript, straight after the pasted block.
// `||`, never `=`. Global JavaScript re-executes on every SPA URL change (see
// Route Changes below), so a plain assignment would reinstall this buffer-only
// stub over the real carrier you swapped in, and null the buffer along with it.
window.__convertPendingCart = window.__convertPendingCart || null;
window.convertHeadless.transport =
  window.convertHeadless.transport ||
  function (attributes, {signal}) {
    // Keep only the most recent. Each firing carries the full bucketing state and
    // supersedes the one before it, whose signal the snippet has already aborted.
    window.__convertPendingCart = {attributes: attributes, signal: signal};
  };
// In your application — NOT in Global JavaScript — once the cart context and
// storefront token exist. Assign with `=` here: this is the carrier that must win,
// and this code runs once rather than on every route change.
window.convertHeadless.transport = function (attributes, {signal}) {
  return myCartAttributesUpdate(attributes, {signal});
};

var pending = window.__convertPendingCart;
if (pending) {
  window.__convertPendingCart = null;
  window.convertHeadless.transport(pending.attributes, {signal: pending.signal});
}

Buffer on a global of your own, not on window.convertHeadless — everything on that namespace other than config and transport is the snippet's own state. This is buffering on your side of the seam; the cartAttributesUpdate call itself is still yours to write.

||-never-= is a page-wide rule on a headless install, not a _conv_q quirk. Anything you place in Global JavaScript runs again on every route change, so any state that must survive one has to be seeded first-wins. The generated snippet already does this for itself — ns.transport = ns.transport || null — and seeding your stub the same way is what lets the real carrier you assign later keep winning.

Timing: Why Registration Must Be Synchronous

Global JavaScript is not merely a workable place for this listener — it is the only one, and the reason is ordering.

sequenceDiagram
    participant GJS as Global JavaScript
    participant WF as Tracking script run()
    participant H as Your handler

    WF->>GJS: Execute Global JavaScript (early)
    GJS->>WF: Register snippet.goals_evaluated listener
    Note over WF: segments, locations, experiences…
    WF->>WF: Goals processed
    WF->>H: Fire snippet.goals_evaluated
    Note over WF,H: Fired non-deferred — never replayed for late listeners

Global JavaScript executes early in the script's run cycle. The snippet.goals_evaluated event fires later in the same run cycle, after segments, locations, experiences and goals have all been processed. A listener registered synchronously from Global JavaScript is therefore in place well before the event, guaranteed, without any ready-event or timeout gymnastics on your part.

The Failure Mode

snippet.goals_evaluated is fired non-deferred. The script replays past events only for events it recorded as deferred, so a listener registered after the fire is never replayed. There is no retry and no catch-up.

Register late and the outcome is permanent silence for that page load:

  • No error and no console warning
  • No failed network request to find
  • _conv_g and _conv_d never written
  • No cart attribute written
  • Every conversion on that visit unattributed

This is why registration must be synchronous inside Global JavaScript. On the first page load, all of the following land after the event has already fired and get no replay:

  • A React useEffect or any other post-hydration effect
  • A router hook such as afterEach
  • A dynamic import() or any deferred bundle
  • A setTimeout, at any delay

Route-change work may live in your framework's hooks — re-reading page context, re-issuing a cart write after the customer changes their cart. Registration may not.

Initialising the Queue

Always initialise the queue with ||, never by assignment:

// CORRECT
window._conv_q = window._conv_q || [];

// WRONG — this silently disables the listener
window._conv_q = [];

The reason is specific to how the queue works. The initial array is drained when the tracking script is constructed, and window._conv_q is then replaced by an object whose push method feeds the script's queue directly. Assigning a fresh [] discards that live object and leaves an inert array that nothing ever drains — everything you push afterwards sits there unread.

Route Changes

The tracking script re-runs its full cycle on every SPA URL change — see Single-Page Application Support — and Global JavaScript re-executes on each one. Listener registration is not de-duplicated by the script, so the snippet carries its own registration guard on its own namespace and shares one debounce and abort state across every execution of the block. You do not need to add a guard of your own; you do need to leave the one in the generated block intact.

What the Snippet Writes

Cookies, With a localStorage Fallback

When _conv_v is readable, the snippet writes the segments into _conv_g and the verification data — the bucketed experiences and the cookie domain — into _conv_d. When cookies are not writable, because of a consent platform or a blocking browser setting, it writes to localStorage['convert.com'].shopifyData instead, which the pixel reads as its second data tier. This is a branch, not a stop: the handler continues either way.

The two arms are not the same payload, and that is deliberate. The localStorage arm carries the visitor ID, the per-experience data and the visitor's segments alongside the same experiences and domain; the _conv_g cookie carries the tracking script's default segments, which are a different shape. Each tier is read by a different part of the pixel's lookup cascade, so the two sources are not interchangeable. The generated snippet gets this right; a hand-written copy that collapses them to one source changes what the pixel reads.

_conv_d.domain — Required, or Goals Re-fire

_conv_d carries a domain field, derived from your project's configured domains. The pixel needs it: it cannot update the visitor cookie unless a domain has been supplied, either from _conv_d or from the decoded __data. Without it the pixel logs "We cannot update visitor cookie since verify data is not provided", cannot persist goal-deduplication state, and goals re-fire — the same conversion counted more than once.

The generated snippet emits domain in both places. A hand-written copy that omits it will appear to work and quietly over-count.

The Attributes Object

Per bucketed experience the snippet emits one experience_<experienceId> entry, plus a single __data attribute:

{
  "experience_100456": "100789",
  "experience_100457": "100801",
  "__data": "H4sIAAAAAAAAA…"
}

__data is base64 — gzip plus base64 where the browser supports compression, plain base64 otherwise; the pixel detects which and decodes accordingly. It carries the visitor data, the bucketed experiences, the domain, and the snippet's version stamp.

Emitting experiences and domain inside __data — and not only in _conv_d — is what makes a cross-origin checkout work at all. When the checkout is on a different domain the cookie is unreadable there, so __data is the only channel left.

The Order of Checks

The handler performs these steps in order. Three of them stop the handler; one is a branch that always continues.

  1. Read all visitor data from the tracking script (convert.getAllVisitorData()).
  2. Publish one Shopify experience_impression per bucketed experience — skipped entirely when window.Shopify.analytics is absent, which is the case on a Hydrogen origin. This happens before every check below, so the impression is published even when one of those checks stops the handler.
  3. Derive the list of bucketed experiences.
  4. Derive the cookie domain.
  5. Branch, not a stop: write _conv_g and _conv_d if _conv_v is readable, otherwise write to localStorage. The handler continues either way.
  6. Stop if running inside an iframe.
  7. Stop if webhookId is blank/falsy or the string 'null'.
  8. Stop if there is no bucketing data to attribute.
  9. Build the attributes object, debounce it, and hand it to your transport carrier.

Transport: Getting the Attributes onto the Shopify Cart

The snippet produces the attributes object once. Which carrier writes it onto the Shopify cart is your branch, and a store can be in more than one case at once — a Hydrogen storefront with checkout on another domain uses both the second and third rows below. Every case is on this page; you should not need to go looking for another one.

Your setupCarrier for the attributes objectStatus
Liquid theme (online store)Ajax POST {Shopify.routes.root}cart/update.jsShipped — handled by the theme app extension, nothing to do
Hydrogen / Oxygen, and any custom storefront on the Storefront APIThe cartAttributesUpdate GraphQL mutationYou implement it
Checkout on a different domain from the storefrontCart permalinksYou implement it — carries its own limit
Webflow-fronted ShopifyNone — no Convert-reachable Shopify cart existsUnsupported

Liquid Theme

Nothing to implement: install the Convert Shopify app's theme app extension and it writes the cookies and the cart attributes for you. The rest of this page exists for storefronts where that extension cannot render.

Hydrogen, Oxygen and Storefront API Storefronts

Use the Storefront API's cartAttributesUpdate mutation to write the attributes onto the current cart. This cannot live in Global JavaScript: it needs a storefront access token and your framework's cart context (the cart ID), neither of which Global JavaScript has. So the snippet stops at the seam and hands the object to your callback, and you carry it into your own cart layer.

Convert does not publish this call, because the correct shape of it depends entirely on your framework, your token handling and where your cart state lives. See Shopify's own documentation for the mutation and its input shape — for Hydrogen specifically, Cart attributes.

Important: on a Hydrogen origin window.Shopify is undefined and /cart/update.js does not exist. Any sample — including older Convert guidance — that posts the attributes to cart/update.js on a headless storefront is wrong by construction and will never write anything.

Checkout on a Different Domain — Cart Permalinks

When checkout is on a different domain from the storefront, the cart cannot be updated from the storefront's origin. Instead, carry the same attributes object on the cart permalink itself: append one attributes[<key>] query parameter per entry to every /cart/<variant>:<quantity> link on the page.

https://shop.example.com/cart/44359739015234:1?attributes[experience_100456]=100789&attributes[__data]=H4sIAAAA…

Same attributes object, different carrier. Read the limit below before choosing this path.

Rate Limits, Debounce and Single-flight

snippet.goals_evaluated can fire more than once per visit, and the Shopify storefront cart endpoints are rate-limited — approximately 10–15 requests per minute per IP. Treat that figure as an internal working estimate rather than a documented guarantee; it is not stated in Shopify's published rate-limit documentation.

The snippet protects you on one half of this automatically and only half-way on the other:

  • The 300 ms debounce is always yours. The snippet collapses rapid re-firings into a single call to your carrier, and shares that debounce state across every execution of the block.
  • Single-flight abort survives only if you honour it. The snippet hands the object to your callback as transport(attributes, {signal}), where signal is the AbortSignal of the shared AbortController. Because the snippet never issues the request itself, a carrier that ignores signal keeps the debounce but loses single-flight — an in-flight request will no longer be aborted when the event re-fires.

Pass signal through to your fetch (or your GraphQL client's equivalent) and you keep both.

Published Limits

Two limits apply to this integration and are worth deciding against before you build.

Webflow-fronted Shopify is not supported. When the storefront is a Webflow site, there is no Convert-reachable Shopify cart at all. The origin is not a Shopify one, so there is no Ajax cart endpoint to post to, and no Storefront API cart context is available to Global JavaScript either. There is nothing for the attributes object to be written onto, so bucketing data cannot reach the checkout and those orders cannot be attributed. This is a limit of the setup rather than a configuration you can correct.

Cart permalinks restrict which goals fire. With permalinks, the web pixel can only track goals on the checkout page. Storefront events do not fire, so goals built on collection_viewed, product_viewed and search_submitted will not be tracked at all. Revenue attribution at checkout still works; on-site behavioural goals do not.

Which Surface Does What

Two different Convert surfaces exist for Shopify, and they are easy to confuse:

SurfaceWhat it is
window.convertShopify.run()The pricing path — per-variation prices and shipping rules on a custom storefront. Unrelated to tracking
window.convertHeadlessThe namespace of the tracking snippet on this page — bucketing persistence and conversion attribution

Wiring convertShopify.run() does not make conversions attributed and is not the headless tracking entry point. The snippet on this page is.

The two namespaces are deliberately kept separate: the snippet on this page neither reads nor writes convertShopify, and there is no tracking entry point on it. They are also filled in differently. convertShopify.run() is a function you define, from Convert's custom-storefront pricing guidance — it is not shipped by the Shopify app, which is why nothing exists on that namespace until you paste it. On a Liquid theme the app's theme extension populates the same namespace itself, with addPricingRules() and addShippingRules() for Variation JavaScript to call.

Troubleshooting

The Handler Never Ran

Two distinct causes, and they look identical from the outside.

1. It was registered too late. The most common cause by a wide margin. See The Failure Mode above — anything asynchronous misses the event on first load and is never replayed. Confirm the block is in Global JavaScript and that registration happens synchronously within it.

2. A goal is still waiting on additional visitor data. When a goal's trigger rules test something the script does not yet hold for this visitor — country, region, city or weather condition — the script defers that goal instead of completing goal processing, and requests the missing data separately. snippet.goals_evaluated is then fired only once that request comes back. If it never comes back — an ad blocker, a Content-Security-Policy script-src that does not allow it, or a consent-gated network shim — goal processing never completes and the event never fires, so a correctly registered listener is never called.

The two causes are easy to tell apart, and this is the point of separating them:

Registered too lateWaiting on additional data
Where the fault isYour code's placementThe network
Network evidenceNone — nothing was ever requestedA request for /getjs/extra/data.js that did not complete
Depends on your goalsNo — happens on every page loadOnly with goals that use a country, region, city or weather-condition trigger
FixMove registration into Global JavaScript, synchronouslyAllow the request through the blocker, CSP or consent shim

If your project has no goal with one of those four trigger conditions, cause 2 cannot apply and the answer is cause 1.

Enable debug logging with ?convert_log_level=debug to see whether the event is being handled at all — see Debugging & Preview Mode. A deferred goal logs a Delay processing … line naming the condition it is waiting on.

Bucketing Is Empty, But Nothing Is Broken

If your project has Convert's own tracking-script Consent Settings enabled, a visitor who has not consented can produce empty bucketing data by design. The snippet then stops at the no-bucketing-data check and writes no cart attribute — which looks exactly like a broken integration from the outside, but is expected behaviour.

Check the project's Consent Settings before concluding the snippet is at fault.

The Same Conversion Counted Twice

Goals re-firing is the signature of a missing domain. See _conv_d.domain — Required, or Goals Re-fire above — the pixel cannot persist goal-deduplication state without it. Confirm your project's domain configuration covers the storefront's hostname.

Notes

  • The snippet is a generated artefact. Do not hand-edit the pasted block — take the current version from the recipe instead, so your install stays aligned with what the pixel expects.
  • Nothing in this integration is served from Convert's CDN beyond the tracking script itself. The Global JS block is your project configuration, like Variation JavaScript: Convert serves it as part of your project but does not host or version what you paste.
  • Everything on this page concerns the client-side path. Order-level revenue still reaches Convert through the Shopify order webhook, which is why webhookId gates the cart write — without the webhook there is nothing to match the cart attribute against.

Did this page help you?