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 three parts to your project's Global JavaScript — your configuration, one generated block and a short stub. Then you connect your cart with the ready-made carrier for your platform. Every line you need is on this page.
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:
| Cookie | Contents | Written by | On a headless storefront |
|---|---|---|---|
_conv_v | Visitor identity and state | The tracking script | Works, unchanged |
_conv_s | Session detection | The tracking script | Works, unchanged |
_conv_g | Visitor segments | The theme app extension | Nothing writes it — the snippet on this page does |
_conv_d | Bucketed experiences and the cookie domain | The theme app extension | Nothing 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 Three Parts to Global JavaScript
Everything in this step goes in Project Settings → Global JavaScript in the Convert app, in this order:
| Order | Part | Who writes it |
|---|---|---|
| 1 | Your configuration | You — the code below, with your own seven values |
| 2 | The generated block | Convert — copy it unedited from the recipe |
| 3 | The carrier stub | You — the code below, unchanged |
Do not change the order, and do not put any part in a bundled application file, a component, or a framework hook. See Timing: Why Registration Must Be Synchronous below for why placement matters here.
Part 1: Your Configuration
Paste this above the generated block, then replace each YOUR_… placeholder with your own value. The generated block itself never needs editing, so pasting a newer version later cannot lose your values.
// Global JavaScript, part 1 of 3: your configuration. Paste above the generated block.
window.convertHeadless = window.convertHeadless || {};
window.convertHeadless.config = window.convertHeadless.config || {
accountId: 'YOUR_ACCOUNT_ID',
projectId: 'YOUR_PROJECT_ID',
currency: 'YOUR_CURRENCY_CODE',
revenueGoalId: 'YOUR_CHECKOUT_COMPLETED_GOAL_ID',
subscriptionGoalId: 'YOUR_SUBSCRIPTION_GOAL_ID',
oneTimePaymentGoalId: 'YOUR_NON_SUBSCRIPTION_GOAL_ID',
webhookId: 'enabled'
};Each value comes from the Convert Shopify app, or from the Convert app where the Shopify app shows only a name:
| Value | Required | Where to find it |
|---|---|---|
accountId | Yes | The first number in your tracking script's file name, [account_id]-[project_id].js, from step 1 |
projectId | Yes | The second number in that file name. It is the project selected on the Shopify app's Project Configuration screen |
currency | No | The three-letter currency code selected on Project Configuration |
revenueGoalId | No | The goal selected as Checkout Completed Goal on Project Configuration. The app shows its name: find that name in your project's Goals list in the Convert app and copy the ID column |
subscriptionGoalId | No | The Subscription Goal on the Shopify app's Other Goals screen. Copy its ID from the Goals list the same way |
oneTimePaymentGoalId | No | The Non-Subscription Goal on Other Goals. Copy its ID the same way |
webhookId | No | Set Setup Order Completed Webhook or not to Enabled on Project Configuration. Then type any non-blank text here, such as 'enabled' |
Set a goal to '' when you have not selected one in the app. A blank goal still travels to the pixel inside __data, as a blank value.
The accountId / projectId render gate. The snippet does nothing at all unless both accountId and projectId are present and neither is the literal string 'null'. It registers nothing and writes nothing. This mirrors the gate the Liquid wrapper applies, and it is why a half-filled configuration produces silence rather than a partial install.
webhookId is a switch, not an identifier. The snippet only checks that it is set, and never sends the value anywhere. The cart write is skipped when webhookId is blank or falsy or 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. With the cart write skipped, the snippet still persists bucketing to the cookie and localStorage tiers. Leave it blank only when the order webhook is disabled in the app, because without the webhook nothing reads the cart attributes.
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. Here the seven values live inconvertHeadless.config.window.convertShopifyis a third, unrelated namespace. See Which Surface Does What below.
Part 2: The Generated Block
Copy the code block from the Shopify Headless Cart Attributes recipe and paste it below part 1, unedited.
That 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.
Part 3: The Carrier Stub
Paste this below the generated block, unchanged:
// Global JavaScript, part 3 of 3: the carrier stub. Paste below the generated block, unchanged.
window.convertHeadless = window.convertHeadless || {};
window.convertHeadlessCart = window.convertHeadlessCart || {
attributes: null,
signal: null,
send: null
};
window.convertHeadless.transport =
window.convertHeadless.transport ||
function (attributes, options) {
var cart = window.convertHeadlessCart;
cart.attributes = attributes;
cart.signal = options && options.signal;
if (typeof cart.send === 'function') return cart.send(attributes, options);
};The generated block builds the attributes object and hands it to this stub. It never writes to the Shopify cart itself. The stub keeps the most recent object on window.convertHeadlessCart.attributes, and passes it to send once your application sets one in step 3.
That split solves a timing problem. The block hands over its first object about 300 ms after snippet.goals_evaluated, which is early in the page load. A Storefront API carrier is not ready that early, because it needs your framework's cart context. The stub holds the object until it is, so no firing is lost.
The stub is safe in any position. Its first line creates the namespace when the block has not run yet, so it never throws. Every line uses || rather than =, because Global JavaScript runs again on every SPA URL change (see Route Changes below), and a plain = would reset what the stub holds.
The four globals in play. Two of them are yours to touch:
| Global | Who declares it | What you do with it |
|---|---|---|
window._conv_q | The snippet initialises it defensively, never by plain assignment | Nothing — but see Initialising the Queue below if you also push commands of your own |
window.convert | The tracking script, as its own API object | Nothing. You never declare it |
window.convertHeadless | The snippet | Set config in part 1. The stub sets transport in part 3. The other keys — registered, cartUpdate and version — are the snippet's own state, so never write them |
window.convertHeadlessCart | The stub in part 3 | Your application sets send in step 3, and reads attributes |
3. Connect Your Cart
This is a required step. Nothing reaches the Shopify cart until you do it. Pick the row that matches how your storefront handles its cart. A store can match more than one row — a storefront that keeps a cart and also has a buy-now button uses both A and B.
| Your storefront | What you add | Where it runs |
|---|---|---|
| Keeps a Storefront API cart and updates it (Hydrogen, Oxygen, most custom storefronts) | A. Existing Cart | Your application |
Creates the cart with cartCreate when the buyer clicks buy, then redirects to checkoutUrl | B. Cart Created at Click | Your application |
| Sends buyers to a checkout on another domain through cart permalinks | C. Cart Permalinks | Global JavaScript, below part 3 |
| Liquid theme (online store) | Nothing. The theme app extension writes the cookies and the cart attributes | — |
| Webflow-fronted Shopify | Nothing works. See Published Limits below | — |
The Storefront API Helper (Cases A and B)
Cases A and B call the Storefront API from the browser. Add this helper to your application once. Replace the placeholders at the top: your store's myshopify.com subdomain, a Storefront API version Shopify still supports, and your public Storefront API access token. A public token is designed for browser code, so it is safe to ship.
// Storefront API helper for cases A and B. Runs in your application, not in Global JavaScript.
var STOREFRONT_URL = 'https://YOUR_STORE.myshopify.com/api/YOUR_API_VERSION/graphql.json';
var STOREFRONT_TOKEN = 'YOUR_PUBLIC_STOREFRONT_ACCESS_TOKEN';
function storefrontRequest(query, variables, signal) {
return fetch(STOREFRONT_URL, {
method: 'POST',
signal: signal,
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': STOREFRONT_TOKEN
},
body: JSON.stringify({query: query, variables: variables})
})
.then(function (response) {
return response.json();
})
.then(function (result) {
if (result.errors && result.errors.length) throw new Error(result.errors[0].message);
return result.data;
});
}
function convertAttributeInput(attributes) {
return Object.keys(attributes || {}).map(function (key) {
return {key: key, value: String(attributes[key])};
});
}A. Existing Cart
Add this to your application, after the helper. It sets send, so every attributes object from now on goes to your cart, and it sends the one the stub already holds.
// Case A: an existing Storefront API cart. Runs in your application, after the helper.
function getCartId() {
var match = document.cookie.match(/(?:^|;\s*)cart=([^;]+)/);
return match ? 'gid://shopify/Cart/' + decodeURIComponent(match[1]) : null;
}
var CART_ATTRIBUTES_QUERY = 'query ($id: ID!) { cart(id: $id) { attributes { key value } } }';
var CART_ATTRIBUTES_UPDATE =
'mutation ($id: ID!, $attributes: [AttributeInput!]!) {' +
' cartAttributesUpdate(cartId: $id, attributes: $attributes) { userErrors { message } } }';
window.convertHeadlessCart = window.convertHeadlessCart || {attributes: null, signal: null, send: null};
window.convertHeadlessCart.send = function (attributes, options) {
var cartId = getCartId();
var signal = options && options.signal;
if (!cartId) return; // No cart yet: pass the attributes to cartCreate instead, as in case B.
return storefrontRequest(CART_ATTRIBUTES_QUERY, {id: cartId}, signal)
.then(function (data) {
var merged = {};
var current = (data.cart && data.cart.attributes) || [];
current.forEach(function (attribute) {
merged[attribute.key] = attribute.value;
});
Object.keys(attributes).forEach(function (key) {
merged[key] = attributes[key];
});
return storefrontRequest(
CART_ATTRIBUTES_UPDATE,
{id: cartId, attributes: convertAttributeInput(merged)},
signal
);
})
.then(function (data) {
var errors = data.cartAttributesUpdate.userErrors;
if (errors.length) throw new Error(errors[0].message);
})
.catch(function (error) {
if (error.name !== 'AbortError') console.warn('[Convert] cart attributes not written', error);
});
};
var held = window.convertHeadlessCart;
if (held.attributes) held.send(held.attributes, {signal: held.signal});Three details to check against your storefront:
- The cart ID.
getCartIdreads thecartcookie that Hydrogen's default cart handler sets, and rebuilds the fullgid://shopify/Cart/…ID from it. If your storefront keeps the cart ID somewhere else, or you made that cookieHttpOnly, return the ID from where your cart layer keeps it. Returnnullwhen there is no cart yet. - Your own cart attributes survive. The carrier reads the cart's current attributes and sends the full merged set. Shopify's reference does not say whether
cartAttributesUpdatemerges or replaces, so the merged set is correct either way. - A cart created later. When your application creates a cart with
cartCreate— on the first add to cart, say — passconvertAttributeInput(window.convertHeadlessCart.attributes)as itsattributesinput, exactly as in case B. Later firings then update that cart throughsend.
B. Cart Created at Click
Use this when there is no cart until the buyer clicks buy. The function creates the cart with the held attributes, then redirects to checkout. It needs no send.
// Case B: a buy button that creates the cart, then redirects. Runs in your application, after the helper.
var CART_CREATE =
'mutation ($input: CartInput!) {' +
' cartCreate(input: $input) { cart { checkoutUrl } userErrors { message } } }';
function convertBuyNow(variantId, quantity) {
var held = window.convertHeadlessCart;
return storefrontRequest(CART_CREATE, {
input: {
lines: [{merchandiseId: variantId, quantity: quantity}],
attributes: convertAttributeInput(held && held.attributes)
}
}).then(function (data) {
var result = data.cartCreate;
if (result.userErrors.length) throw new Error(result.userErrors[0].message);
window.location.href = result.cart.checkoutUrl;
});
}Call it from your button's click handler as convertBuyNow(variantId, quantity), where variantId is the variant's full gid://shopify/ProductVariant/… ID. If your button already calls cartCreate, you only need one change: add the attributes line above to its input.
A click in roughly the first 300 ms after snippet.goals_evaluated finds nothing held yet. That cart is created without attributes, and its order is not attributed.
C. Cart Permalinks
Use this when checkout is on a different domain from the storefront, so the cart cannot be updated from the storefront's origin. Paste it into Global JavaScript, below part 3:
// Case C: cart permalinks. Global JavaScript, below part 3.
if (!window.convertHeadlessCart.permalinks) {
window.convertHeadlessCart.permalinks = true;
document.addEventListener(
'click',
function (event) {
var attributes = window.convertHeadlessCart.attributes;
var link = event.target && event.target.closest ? event.target.closest('a[href]') : null;
if (!attributes || !link) return;
var url = new URL(link.href, window.location.href);
if (!/^\/cart\/\d+:\d+/.test(url.pathname)) return;
Object.keys(attributes).forEach(function (key) {
url.searchParams.set('attributes[' + key + ']', attributes[key]);
});
link.href = url.toString();
},
true
);
}It rewrites a cart permalink at the moment it is clicked, adding one attributes[<key>] query parameter per entry. Links your application renders later are covered too. The flag on the first line stops a second listener on each SPA route change. A clicked permalink then carries the attributes like this, with the brackets percent-encoded:
https://YOUR_CHECKOUT_DOMAIN/cart/VARIANT_ID:QUANTITY?attributes[experience_EXPERIENCE_ID]=VARIATION_ID&attributes[__data]=ENCODED_DATA
Read the permalink limit under Published Limits before you choose this path.
Check the Install
Open your storefront, wait for the page to settle, and run window.convertHeadlessCart.attributes in the browser console. You should see one experience_<id> entry per bucketed experience, plus __data. If it is null, see Troubleshooting below. In cases A and B, check the cart afterwards: its attributes now carry the same keys.
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_gand_conv_dnever 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
useEffector 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
localStorage FallbackWhen _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.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_EXPERIENCE_ID": "VARIATION_ID",
"experience_OTHER_EXPERIENCE_ID": "OTHER_VARIATION_ID",
"__data": "ENCODED_DATA"
}__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.
- Read all visitor data from the tracking script (
convert.getAllVisitorData()). - Publish one Shopify
experience_impressionper bucketed experience — skipped entirely whenwindow.Shopify.analyticsis 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. - Derive the list of bucketed
experiences. - Derive the cookie
domain. - Branch, not a stop: write
_conv_gand_conv_dif_conv_vis readable, otherwise write tolocalStorage. The handler continues either way. - Stop if running inside an iframe.
- Stop if
webhookIdis blank/falsy or the string'null'. - Stop if there is no bucketing data to attribute.
- Build the attributes object, debounce it, and hand it to the transport — the stub from part 3.
Transport: Rules Every Carrier Follows
Step 3 ships a carrier for every supported case. This section states the rules those carriers already follow, for anyone adapting one.
Important: on a Hydrogen origin
window.Shopifyis undefined and/cart/update.jsdoes not exist. Any sample — including older Convert guidance — that posts the attributes tocart/update.json a headless storefront is wrong by construction and will never write anything.
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 calls the transport as
transport(attributes, {signal}), wheresignalis theAbortSignalof the sharedAbortController. The stub passes it on tosend, and case A passes it to both of its requests. A carrier that dropssignalkeeps the debounce but loses single-flight: an in-flight request is no longer aborted when the event fires again.
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:
| Surface | What it is |
|---|---|
window.convertShopify.run() | The pricing path — per-variation prices and shipping rules on a custom storefront. Unrelated to tracking |
window.convertHeadless | The 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 late | Waiting on additional data | |
|---|---|---|
| Where the fault is | Your code's placement | The network |
| Network evidence | None — nothing was ever requested | A request for /getjs/extra/data.js that did not complete |
| Depends on your goals | No — happens on every page load | Only with goals that use a country, region, city or weather-condition trigger |
| Fix | Move registration into Global JavaScript, synchronously | Allow 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.
Attributes Are Held, But the Cart Has None
window.convertHeadlessCart.attributes shows the attributes, but the cart does not carry them. The snippet did its part, so check your side of step 3:
- Case A:
typeof window.convertHeadlessCart.sendmust print'function'. If it prints'object', the case A code has not run. Then check thatgetCartId()returns your cart's ID rather thannull. A warning starting[Convert] cart attributes not writtenin the console carries the Storefront API's own error. - Case B: confirm your buy button passes
attributesin itscartCreateinput. - Case C: confirm the case C code sits below part 3 in Global JavaScript, and that the clicked link's path starts with
/cart/<variant>:<quantity>.
If window.convertHeadlessCart itself is undefined, part 3 is missing from Global JavaScript.
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
webhookIdgates the cart write — without the webhook there is nothing to match the cart attribute against.
Updated 13 days ago