Skip to content

shopify

Joseph Samir edited this page May 27, 2026 · 2 revisions

Shopify (Custom Storefronts, Headless, Hydrogen)

The Convert Shopify App provides a turn-key integration for merchants using a Convert Web project. It does not currently support Fullstack projects. If your Convert project is a Fullstack project and you want to experiment on a Shopify store — particularly a custom storefront, headless setup, or Hydrogen build — you'll be wiring it together yourself with the JavaScript SDK.

This guide walks through the moving parts:

  1. Where to make the bucketing decision (server, storefront, or both).
  2. How to carry the bucketing decision and visitor identity from the storefront across the boundary into Shopify's checkout.
  3. Why you need to ship a custom Web Pixel Extension to track checkout-side conversions, and how that pixel triggers conversions back through the SDK.

As with the rest of the guides, the patterns below are options to pick from, not a single mandatory recipe.

See also: Client-Side Experimentation, Server-Side Experimentation, Tracking Conversions.

The Checkout Boundary

Shopify renders most of a store in two distinct contexts:

  • The storefront — pages under your merchant domain (e.g. shop.example.com). Your code runs here directly: storefront pages, your custom React/Hydrogen components, the storefront layout, custom themes.
  • The checkout — pages Shopify hosts and controls (under checkout.shopify.com or your custom checkout domain on Shopify Plus). Your storefront JavaScript does not run on these pages. You cannot mount your own components there. The only sanctioned customer-events surface inside checkout is the Web Pixel Extension.

So a typical purchase flow crosses the boundary:

  1. Visitor lands on a storefront page → your code can bucket them, render the variant, and track storefront-side goals (CTA clicks, signups).
  2. Visitor adds to cart → still on the storefront.
  3. Visitor enters checkout → Shopify takes over. Your storefront JS is gone. The Web Pixel sandbox is the only place you can run code that observes purchase-related events.

Anything that needs to span both halves (the visitor's variation assignment, the visitor's Convert ID) has to be carried through a transport that survives the transition. The two transports Shopify gives you are:

  • Cart attributes (cart.attributes / cart.note_attributes). Key-value strings attached to the cart, available to the storefront, the Web Pixel, and order webhooks. Survives the storefront→checkout transition reliably.
  • The Customer Events bus (analytics.publish / analytics.subscribe for custom topics). Available within the pixel sandbox lifetime; less reliable across the checkout transition because the pixel can re-initialize.

For carrying state (a visitor's variation assignment, their Convert visitor ID) cart attributes are the safer choice. The events bus is fine for in-pixel custom topics where you only need them within one pixel's run.

1. Decide Where to Bucket

You can bucket on the storefront, on your server, or both. Pick based on where the visitor lands first and what data the decision needs.

Server-side bucketing (recommended for SSR / Hydrogen)

If you're running Hydrogen, Remix on Oxygen, Next.js with Shopify Storefront API, or any other SSR setup, the request handler is the cleanest place to bucket. The visitor lands on a server-rendered page; you bucket once, render the variant directly into the HTML, write the bucketing payload into cart attributes (so it survives into checkout), and you're done.

See Server-Side Experimentation for the general server-side pattern. Shopify-specific addition: instead of (or in addition to) returning the variation through your framework's data channel, write it to the cart so the checkout-side pixel can read it later.

Storefront-side bucketing

If your store is a classic Liquid theme or a non-SSR storefront, you can bucket on the storefront with the client-side SDK. See Client-Side Experimentation. The constraint is the same: whatever you bucket has to make it into cart attributes before the visitor reaches checkout, so the pixel can attribute the conversion.

Hybrid

Bucket the experiences relevant to a given page where it's most convenient (server-side for pages that SSR, client-side for pages that don't), and accumulate the assignments into the cart as the visitor moves through the funnel. The pixel reads them all at checkout.

2. Persist Visitor Identity + Bucketing Across the Boundary

The pixel needs to know two things at conversion time:

  • Which visitor this is (the same ID you used in createContext(...) earlier).
  • Which experiences they're bucketed into (so the conversion attaches to the right variations).

The reliable transport is the cart. A common pattern:

  • Keep the visitor ID in a first-party cookie on the storefront, the way you would for any server-side experimentation (see Server-Side Experimentation > Stable Visitor Identity). Read it server-side, set it if absent.
  • On each bucketing call, write the assignment into cart attributes. One simple shape — a single JSON-encoded attribute with everything the pixel needs:
// Storefront / Hydrogen — call after running an experience.
// `variations` is the array returned by `context.runExperiences()`.
// Each entry is a BucketedVariation: the experience-side fields are on the
// object itself (`experienceId`, `experienceKey`); the variation's own
// identity is `id` / `key` (spread from ExperienceVariationConfig).
async function writeCartAttribute(cartId, visitorId, variations) {
  const payload = {
    visitorId,
    bucketing: variations.map((v) => ({
      experienceId:  v.experienceId,
      experienceKey: v.experienceKey,
      variationId:   v.id,
      variationKey:  v.key
    }))
  };
  // Encode to keep it opaque (cart attributes are visible to the customer).
  const encoded = btoa(JSON.stringify(payload));
  // Use whichever Shopify API your stack already uses for cart mutations
  // (Storefront API GraphQL `cartAttributesUpdate`, Ajax `/cart/update.js`, etc.).
  await updateCartAttributes(cartId, { __cv_data: encoded });
}

Two practical notes:

  • Cart attributes are visible to the customer (they show up in some checkout views). Encoding (base64) is enough to keep them opaque without being security; don't put anything sensitive in there.
  • Update the attribute whenever the bucketing set changes (new experience bucketed mid-funnel, visitor properties updated, etc.). The pixel will read whatever the cart has at checkout time.

A second cookie or attribute for just the visitor ID is also fine if you'd rather keep the bucketing list separate from the identity.

3. Trigger Storefront-Side Conversions Normally

Goals that complete on the storefront (CTA clicks, newsletter signups, anything pre-checkout) work the same as in any client-side or server-side flow — call trackConversion from the SDK on the storefront or server side. See Tracking Conversions.

You only need the pixel for goals that fire inside Shopify's checkout context.

4. Ship a Custom Web Pixel to Track Checkout Conversions

When you use the JavaScript SDK directly rather than the Convert Shopify App, you are responsible for the Web Pixel as well. The App's pixel ships pre-wired with the App's V1 visitor cookie scheme; it can't read a Fullstack-issued visitor ID. You'll author a custom Web Pixel Extension that:

  1. Subscribes to the Shopify customer events you care about.
  2. Reads the cart attribute you wrote on the storefront — that's how it gets the visitor ID and the bucketing list.
  3. Triggers Convert conversions when a relevant event fires.

The relevant Shopify events for most stores are:

Reading the cart attribute in the pixel

For the events that carry a checkout payload (checkout_started, payment_info_submitted, checkout_completed), the cart attribute you set on the storefront is available at event.data.checkout.attributes:

analytics.subscribe('checkout_completed', (event) => {
  const attributes = event.data?.checkout?.attributes ?? [];
  const cvAttr = attributes.find((a) => a.key === '__cv_data');
  if (!cvAttr?.value) return;

  let cv;
  try { cv = JSON.parse(atob(cvAttr.value)); }
  catch { return; }

  const visitorId = cv.visitorId;
  const bucketing = cv.bucketing; // [{ experienceId, variationId, ... }]

  // … hand these off to your conversion-trigger of choice (see below) …
});

For events that don't carry a checkout payload, you have to have stashed enough state earlier in the pixel's lifetime — either via another event that carried the attributes, or via the Customer Events bus from the storefront.

Triggering the conversion — two patterns

You have two reasonable options for actually firing the conversion once the pixel has the visitor ID and the goal.

Option A — forward to a merchant backend that calls the SDK. The pixel POSTs the event details to an endpoint you own. The endpoint reconstructs a Convert Context with the visitor ID and calls trackConversion. This is the cleanest split — the pixel stays small, the SDK lives on a server where you can use any data store, and conversion logic isn't duplicated across the pixel and the server.

// Pixel — forwarding pattern
analytics.subscribe('checkout_completed', async (event) => {
  const cv = readCvAttribute(event);
  if (!cv) return;
  await fetch('https://your-backend.example.com/convert/conversion', {
    method: 'POST',
    keepalive: true,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      visitorId:     cv.visitorId,
      goalKey:       'purchase-completed',
      // subtotalPrice = price before duties/shipping/tax. Matches what the
      // official Convert Shopify App pixel uses. Pick totalPrice instead if
      // you want the all-in number — both are valid Shopify fields.
      amount:        event.data.checkout.subtotalPrice.amount,
      // checkout.token is always present at checkout_completed and is the
      // canonical idempotency key. checkout.order may not be populated yet
      // when the event fires, so don't rely on it.
      transactionId: event.data.checkout.token
    })
  });
});
// Backend — calls the SDK on the pixel's behalf
app.post('/convert/conversion', async (req, res) => {
  await sdk.onReady();
  const ctx = sdk.createContext(req.body.visitorId);
  ctx.trackConversion(req.body.goalKey, {
    conversionData: [
      { key: 'amount', value: Number(req.body.amount) },
      { key: 'transactionId', value: req.body.transactionId }
    ]
  });
  await ctx.releaseQueues();
  res.sendStatus(204);
});

The backend also gets you dedup, retries, and the SDK's batching for free — and you can layer in extra logic (idempotency keys, fraud filtering, currency conversion) without re-deploying the pixel.

Option B — POST directly to the Convert tracking endpoint from the pixel. Skip the merchant backend hop and have the pixel construct the tracking payload itself. Lighter and one fewer round-trip, but you re-implement the payload shape the SDK would otherwise build for you, and you take on dedup and retries by hand. Reasonable only if you have a strong reason to avoid the extra hop and you're comfortable maintaining the payload by hand. Most stores are better off with Option A.

If you go this route — or if you adapt Option A to call the tracking endpoint from a backend HTTP client instead of forwarding through the SDK — read Direct Tracking Endpoint first. The tracking server applies a bot filter to incoming requests, and default server-side HTTP-client User-Agents (node, undici, axios/X, python-requests/X, GuzzleHttp/X, etc.) are silently dropped: the request returns 200 OK and your client thinks the event landed, but it never reaches reports. The fix is a User-Agent: ConvertAgent/<version> header on every direct POST. The browser-side pixel itself sends the browser's UA, so it isn't affected by the bot filter; the warning matters specifically when you put a server in the loop.

You can also use a Shopify order webhook (orders/paid, orders/create) as a fallback or replacement for checkout_completed. Order webhooks are server-to-server and can't be blocked by an ad-blocker the way a pixel-side fetch can, but they typically arrive a few seconds after the purchase. Many stores fire both and dedup on transaction ID.

Dedup matters at checkout

checkout_completed can fire more than once for a single purchase (refresh on the thank-you page, certain post-purchase flows). Treat the conversion path as needing dedup:

  • If you're forwarding to a backend (Option A), the backend goes through the SDK, which dedups native goal conversions by visitor + goal automatically. Revenue / transaction events with distinct transactionId values aren't deduped (each unique transaction accumulates revenue) — which is what you want. See Tracking Conversions for the full force-multiple-transactions model.
  • If you're POSTing directly (Option B), implement dedup yourself — typically by tracking a set of seen transactionIds in the pixel's storage between events.

Subscription / Granular Goal Triggering

If your store sells subscriptions and you want distinct goals for subscription vs one-time purchase (or per subscription tier), the pixel is where you'd inspect event.data.checkout.lineItems for the subscription markers Shopify exposes (sellingPlanAllocation, etc.) and pick the appropriate goalKey before forwarding. The general structure is the same as the basic checkout-completed flow; only the goal-key selection logic changes.

Putting It Together

The shape of a Fullstack-on-Shopify integration:

  1. Storefront / server: bucket experiences with the SDK using a stable visitor-ID cookie. Write { visitorId, bucketing[] } into a cart attribute on every bucketing decision. Render variants directly (server-side) or apply them client-side per Client-Side Experimentation.
  2. Pre-checkout goals: call trackConversion on the storefront / server using the SDK, same as any other site.
  3. Custom Web Pixel: subscribe to the Shopify customer events you need. On each event, read the cart attribute, then either (Option A) forward to your backend which calls trackConversion via the SDK, or (Option B) POST to the Convert tracking endpoint directly.
  4. Optional fallback: a Shopify order webhook handler that calls trackConversion server-side using the visitor ID stored in the order's note-attributes. Dedup by transaction ID.

That's the whole picture. None of the parts are uniquely hard, but all four have to be wired up by you because the App doesn't cover the Fullstack-project case today.

Common Pitfalls

  • Using Shopify's pixel event.clientId as the Convert visitor ID. It's a pixel-scoped identifier with a different lifetime and scope than the visitor ID you used when bucketing on the storefront. Use the visitor ID you wrote into the cart attribute, not event.clientId.
  • Bridging via analytics.publish only. It works inside one pixel's lifetime but the pixel can re-initialize between the storefront and checkout, leaving the subscriber with an empty cache exactly when checkout_completed arrives. Cart attributes survive the boundary; the events bus alone doesn't.
  • Importing the full SDK into the pixel. The pixel sandbox has CSP and bundling restrictions that make importing arbitrary npm packages awkward. Forwarding to a backend that uses the SDK (Option A) avoids the issue entirely.
  • Skipping exposure events to "save bandwidth". Don't. Without exposure events you have no denominator for the conversion rate — the experiment becomes unmeasurable. The SDK already emits exposures lazily and batched; leave them on.
  • Not deduping checkout_completed. It can fire more than once per order. If you POST directly, track seen transaction IDs. If you forward to the SDK, the SDK handles it.
  • Cart attribute is too big. Cart attributes have a per-attribute size limit (Shopify's documented cap is generous but not unlimited). If you bucket many experiences, keep the payload to the minimum the pixel needs — usually {visitorId, bucketing:[{experienceId, variationId}]} is enough.

Clone this wiki locally