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:

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 Three Parts to Global JavaScript

Everything in this step goes in Project Settings → Global JavaScript in the Convert app, in this order:

OrderPartWho writes it
1Your configurationYou — the code below, with your own seven values
2The generated blockConvert — copy it unedited from the recipe
3The carrier stubYou — 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:

ValueRequiredWhere to find it
accountIdYesThe first number in your tracking script's file name, [account_id]-[project_id].js, from step 1
projectIdYesThe second number in that file name. It is the project selected on the Shopify app's Project Configuration screen
currencyNoThe three-letter currency code selected on Project Configuration
revenueGoalIdNoThe 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
subscriptionGoalIdNoThe Subscription Goal on the Shopify app's Other Goals screen. Copy its ID from the Goals list the same way
oneTimePaymentGoalIdNoThe Non-Subscription Goal on Other Goals. Copy its ID the same way
webhookIdNoSet 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 in convertHeadless.config. window.convertShopify is 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:

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 snippetSet 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.convertHeadlessCartThe stub in part 3Your 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 storefrontWhat you addWhere it runs
Keeps a Storefront API cart and updates it (Hydrogen, Oxygen, most custom storefronts)A. Existing CartYour application
Creates the cart with cartCreate when the buyer clicks buy, then redirects to checkoutUrlB. Cart Created at ClickYour application
Sends buyers to a checkout on another domain through cart permalinksC. Cart PermalinksGlobal JavaScript, below part 3
Liquid theme (online store)Nothing. The theme app extension writes the cookies and the cart attributes—
Webflow-fronted ShopifyNothing 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. getCartId reads the cart cookie that Hydrogen's default cart handler sets, and rebuilds the full gid://shopify/Cart/… ID from it. If your storefront keeps the cart ID somewhere else, or you made that cookie HttpOnly, return the ID from where your cart layer keeps it. Return null when 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 cartAttributesUpdate merges 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 — pass convertAttributeInput(window.convertHeadlessCart.attributes) as its attributes input, exactly as in case B. Later firings then update that cart through send.

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_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_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.

  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 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.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.

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}), where signal is the AbortSignal of the shared AbortController. The stub passes it on to send, and case A passes it to both of its requests. A carrier that drops signal keeps 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:

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.

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.send must print 'function'. If it prints 'object', the case A code has not run. Then check that getCartId() returns your cart's ID rather than null. A warning starting [Convert] cart attributes not written in the console carries the Storefront API's own error.
  • Case B: confirm your buy button passes attributes in its cartCreate input.
  • 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 webhookId gates the cart write — without the webhook there is nothing to match the cart attribute against.

Did this page help you?