@polar-analytics/pixel-sdk
v0.18.0
Published
A JavaScript/TypeScript SDK for reporting customer touchpoints to Polar Analytics
Readme
Pixel SDK
A lightweight JavaScript utility for reporting user touchpoints, similar to the Polar Analytics Pixel.
Contents
- Installation
- Example usage
- Headless Shopify Integration
- Cart Attribute Injection
- API Reference
- For SDK developers
Installation
Install via npm (recommended for Next.js, Hydrogen, Remix, Vite, any bundled app):
npm install @polar-analytics/pixel-sdkLoading via <script> (non-npm sites)
If you can't run a bundler on your site (plain HTML, Webflow, Wix, WordPress, a
headless CMS that only lets you paste <script> tags), load the SDK's IIFE
bundle from unpkg and use the polarPixel global:
<script src="https://unpkg.com/@polar-analytics/[email protected]/dist/index.global.js"></script>
<script>
const { generatePayloadBase, sendPixelEvent, eventFactory } = polarPixel;
// …same API as the npm import, just accessed off the global.
</script>Pin to an exact version (@0.17.0) in production rather than @latest — that
way a new SDK release can't silently change behavior on your site.
Example usage
Every event payload has three "common" fields — two required, one optional:
| Field | Required? | What to pass |
| ------------------ | --------- | ----------------------------------------------------------------------------------------------------------------- |
| shopifyEventId | Yes | A unique UUID per event. Use crypto.randomUUID() in the browser. |
| shopifyShopURL | Yes | The store's *.myshopify.com domain (e.g. my-store.myshopify.com). This is the Shopify store domain, not the customer-facing storefront URL or a theme page. |
| shopifyClientId | No | Shopify's anonymous visitor id. Omit it if you don't have one (e.g. any non-Shopify-theme environment). The SDK will automatically fall back to the anonymous userId it persists in localStorage via generatePayloadBase(). |
Headless / non-Shopify-theme integration? Jump to Headless Shopify Integration — you can safely skip
shopifyClientId.
import {
generatePayloadBase,
sendPixelEvent,
eventFactory,
} from "@polar-analytics/pixel-sdk";
// 1. Build the payload base. This reads URL, referrer, title, userAgent, etc.
// from window.document/navigator and persists an anonymous userId + sessionId
// in localStorage.
const payloadBase = await generatePayloadBase({
// Optional — include only if you already know who the customer is.
// customer: {
// id: <variable-containing-customer-id>,
// email: <variable-containing-customer-email>,
// },
});
// 2. Required: the store's myshopify.com domain.
const shopifyShopURL = "my-store.myshopify.com";
// 3. Build the event-specific payload with the matching eventFactory.
const data = eventFactory.cart_viewed({
shopifyEventId: crypto.randomUUID(), // required, must be unique per event
shopifyShopURL, // required
// for the client_id
// Please refer to https://community.shopify.com/c/shopify-apps/web-pixel-clientid/m-p/2664271/highlight/true#M80886
// As a fallback you can also use payloadBase.data.user_id
// shopifyClientId: "<optional>",
});
// 4. Send to the Polar pixel endpoint (provided by Polar Analytics).
sendPixelEvent(
pixelEndpoint,
payloadBase,
{ data },
);When should I set
shopifyClientIdexplicitly? Only inside a Shopify Web Pixel extension where Shopify hands you a realclientId(Shopify community thread). Anywhere else — headless storefronts, Hydrogen, custom Next.js/React apps — leave it out.
Sending a custom event
Use eventFactory.custom only for events not covered by the standard
eventFactory.* helpers — using it for a standard event will drop
event-specific data on the floor.
const data = eventFactory.custom({
shopifyEventName: "<the-name-of-the-custom-event>",
shopifyEventId: crypto.randomUUID(),
shopifyShopURL,
});(Optional) With callbacks and retries
sendPixelEvent retries failed requests with exponential backoff
(default: 3 retries) and returns a SendPixelEventResult.
const result = await sendPixelEvent(
pixelEndpoint,
payloadBase,
{ data },
{
maxRetries: 5,
onSuccess: (response) => {
console.log("Event delivered", response.status);
},
onError: (error, attempts) => {
console.error(`Failed after ${attempts} attempts`, error);
},
onRetry: (attempt, error, nextDelayMs) => {
console.warn(`Retry ${attempt} in ${nextDelayMs}ms`);
},
},
);
if (!result.success) {
// result.error and result.attempts are available
}(Optional) With sendBeacon (page unload)
Use useBeacon: true for page unload / visibilitychange scenarios.
When the beacon fires successfully the function returns immediately
with { success: true, usedBeacon: true } — onSuccess/onError
callbacks are not invoked. If the beacon fails, the SDK falls
back to fetch with retries.
sendPixelEvent(pixelEndpoint, payloadBase, { data }, { useBeacon: true });Headless Shopify Integration
Why this guide exists
On a standard Shopify Liquid storefront, the Polar app installs a Web Pixel that fires events automatically — you don't write any SDK code. On a headless storefront (React, Next.js, Hydrogen, Remix, a custom storefront, a mobile app, or any site not served by Shopify's theme engine) there is no Web Pixel sandbox and no auto-install, so Polar can't inject the script or read Shopify's event context. You install the SDK manually and call it from your own route and cart code. This guide shows a developer what to pass, how to identify visitors, and how to verify events are arriving.
If you have a site manager or developer on your team, these instructions are intended for them.
What you need to provide
| Field | Required? | How to source it in headless |
| ------------------ | --------- | ---------------------------------------------------------------------------------------------------------------- |
| shopifyEventId | Yes | Generate a fresh UUID per event: crypto.randomUUID() (available in all modern browsers and Node ≥ 19). |
| shopifyShopURL | Yes | Your store's *.myshopify.com domain. This is your admin-level store domain (e.g. my-store.myshopify.com), not the customer-facing storefront URL, and not a theme page URL. You can hard-code it or read it from an env var. |
| shopifyClientId | No | Skip it in headless. Shopify's clientId is only exposed inside the Web Pixel sandbox, which you don't have. The SDK will automatically use the anonymous userId it persists in localStorage via generatePayloadBase(). |
Anything else (URL, referrer, page title, userAgent, UTM parameters,
marketing cookies) is read for you by generatePayloadBase() from
window.document / navigator — you do not pass those manually.
Minimal page_viewed example in a headless storefront
import {
generatePayloadBase,
sendPixelEvent,
eventFactory,
} from "@polar-analytics/pixel-sdk";
const SHOP_URL = "my-store.myshopify.com"; // your *.myshopify.com domain
async function trackPageView() {
const payloadBase = await generatePayloadBase({
// Pass the customer only if your app already knows who is logged in.
// customer: { id: user?.id, email: user?.email },
});
const data = eventFactory.page_viewed({
shopifyEventId: crypto.randomUUID(), // required — fresh UUID per event
shopifyShopURL: SHOP_URL, // required
// for the client_id
// Please refer to https://community.shopify.com/c/shopify-apps/web-pixel-clientid/m-p/2664271/highlight/true#M80886
// As a fallback you can also use payloadBase.data.user_id
// shopifyClientId: "<optional>",
});
await sendPixelEvent(pixelEndpoint, payloadBase, { data });
}Identifying the visitor
When a visitor logs in, signs up, or submits a form with their contact info,
pass their id and/or email through generatePayloadBase(). Polar uses
this to stitch their anonymous pre-login sessions to their identified sessions
in the customer journey.
// Call this once right after login / signup / form submission.
// Subsequent events in the same session will carry the customer identity.
const payloadBase = await generatePayloadBase({
customer: {
id: user.id, // your internal customer id, if you have one
email: user.email, // at least one of { id, email } should be set
},
});
// Re-send any event you want tied to the identified session, e.g. a
// post-login page_viewed:
const data = eventFactory.page_viewed({
shopifyEventId: crypto.randomUUID(),
shopifyShopURL: SHOP_URL,
});
await sendPixelEvent(pixelEndpoint, payloadBase, { data });Before login, generatePayloadBase() persists an anonymous userId in
localStorage; Polar uses that to stitch the pre-login activity to the
identified customer the first time you pass customer.id / customer.email.
Minimal headless payloads by event
The full API Reference below documents every field Shopify's schema defines. In a headless integration you usually need only a small subset. The snippets below show the common shape — enough to get attribution and revenue tracking working. They are illustrative (some nested Shopify types are abbreviated); consult the matching entry in the API Reference for every required field when you build the real payload.
eventFactory.page_viewed({
shopifyEventId: crypto.randomUUID(),
shopifyShopURL: SHOP_URL,
shopifyPageType: "home", // optional but recommended: "home" | "product" | "collection" | "cart" | "checkout" | ...
});eventFactory.product_viewed({
shopifyEventId: crypto.randomUUID(),
shopifyShopURL: SHOP_URL,
shopifyPageDetail: product.handle,
shopifyPageProductId: product.id, // Shopify product id (gid or numeric)
shopifyPageType: "product",
shopifyEventData: {
productVariant: {
id: variant.id,
sku: variant.sku,
title: variant.title,
untranslatedTitle: variant.title,
image: { src: variant.imageUrl ?? null },
price: { amount: variant.price, currencyCode: variant.currencyCode },
product: {
id: product.id,
title: product.title,
untranslatedTitle: product.title,
type: product.type ?? null,
url: product.url ?? null,
vendor: product.vendor,
},
},
},
});eventFactory.product_added_to_cart({
shopifyEventId: crypto.randomUUID(),
shopifyShopURL: SHOP_URL,
shopifyEventData: {
cartLine: {
quantity,
cost: {
totalAmount: { amount: lineTotal, currencyCode },
},
merchandise: {
id: variant.id,
sku: variant.sku,
title: variant.title,
price: { amount: variant.price, currencyCode },
product: {
id: product.id,
title: product.title,
vendor: product.vendor,
},
},
},
},
});eventFactory.checkout_started({
shopifyEventId: crypto.randomUUID(),
shopifyShopURL: SHOP_URL,
shopifyEventData: {
checkout: {
token: checkoutId,
currencyCode,
totalPrice: { amount: cartTotal, currencyCode },
email: customer.email ?? null,
lineItems: cart.lines.map((line) => ({
id: line.id,
quantity: line.quantity,
variant: {
id: line.variant.id,
sku: line.variant.sku,
price: { amount: line.variant.price, currencyCode },
product: { id: line.product.id, title: line.product.title },
},
})),
},
},
});checkout_completed has more required top-level fields than the other events
— Polar uses these directly for revenue attribution and reconciliation, so
don't skip any.
eventFactory.checkout_completed({
shopifyEventId: crypto.randomUUID(),
shopifyShopURL: SHOP_URL,
shopifyPageType: "thank_you",
shopifyOrderId: order.id,
shopifyCustomerEmail: order.email,
shopifyOrderSubtotalPrice: order.subtotal,
shopifyOrderTotalPrice: order.total,
shopifyOrderCurrency: order.currencyCode,
shopifyOrderLineItems: order.lineItems.map((line) => ({
lineItemId: line.id,
lineItemProductId: line.product.id,
lineItemTitle: line.title,
lineItemVariantId: line.variant.id,
})),
shopifyEventData: {
checkout: {
token: order.id,
order: { id: order.id },
currencyCode: order.currencyCode,
totalPrice: { amount: order.total, currencyCode: order.currencyCode },
email: order.email,
lineItems: order.lineItems.map(/* …same shape as checkout_started… */),
},
},
});SPA route changes
In single-page apps the browser does not fire a full navigation when the URL
changes, so page_viewed will not re-fire automatically. Call
trackPageView() from your router's route-change hook.
Next.js App Router (usePathname + useSearchParams):
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
export function PolarPageViewTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
trackPageView();
}, [pathname, searchParams]);
return null;
}React Router v6 (useLocation):
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
export function PolarPageViewTracker() {
const location = useLocation();
useEffect(() => {
trackPageView();
}, [location.pathname, location.search]);
return null;
}Vue Router:
import { useRouter } from "vue-router";
const router = useRouter();
router.afterEach(() => {
trackPageView();
});Plain <script> install without a router — listen for the browser's
popstate event and wrap history.pushState:
<script>
(function () {
const fire = () => trackPageView();
window.addEventListener("popstate", fire);
const origPush = history.pushState;
history.pushState = function () {
origPush.apply(this, arguments);
fire();
};
})();
</script>Server-side deduplication
If you send events from both the client and a server (e.g. a Shopify webhook
worker that fires checkout_completed from order webhooks while the client
also fires one on the thank-you page), you need to deduplicate the two copies
so your analytics counts the conversion once. The SDK does not dedupe for
you; your backend decides what counts as a duplicate.
The pattern:
- Generate
shopifyEventId = crypto.randomUUID()once on the client. - Send the client-side event with that id.
- Persist the same id somewhere the server will see it — a Shopify cart / checkout attribute, your own order metadata, or a hidden form field.
- When the server fires its copy, reuse the same
shopifyEventId. - Your ingestion or warehouse deduplicates by
(event_type, shopifyEventId).
// Client
const shopifyEventId = crypto.randomUUID();
await sendPixelEvent(pixelEndpoint, payloadBase, {
data: eventFactory.checkout_completed({
shopifyEventId,
shopifyShopURL: SHOP_URL,
shopifyEventData: { checkout: { /* … */ } },
}),
});
// Attach the id to the cart / order so the server can read it back.
await generateCartAttribute(); // existing `polar_attr` flow
await fetch("/orders", {
method: "POST",
body: JSON.stringify({ ...orderPayload, polarEventId: shopifyEventId }),
});// Server (e.g. Shopify order-paid webhook handler)
await sendPixelEvent(pixelEndpoint, serverPayloadBase, {
data: eventFactory.checkout_completed({
shopifyEventId: order.polarEventId, // ← reuse, do NOT regenerate
shopifyShopURL: SHOP_URL,
shopifyEventData: { checkout: { /* … */ } },
}),
});Verify your install
After deploying, verify that events are actually reaching Polar. These three checks catch 90% of install problems:
Open the browser devtools Network tab, filter by your pixel endpoint (
pixel-api.polaranalytics.comor your configured endpoint), reload the page. You should see aPOSTper event with status 200 or 204. Inspect the request payload —shopifyEventId,shopifyShopURL, anduser_idmust all be set.Log the payload base in a staging build to sanity-check UTM capture,
localStorageuserId persistence, and marketing cookies:const payloadBase = await generatePayloadBase({ /* … */ }); console.log("polar payloadBase", payloadBase); // Check: payloadBase.data.user_id is set, UTM fields populated if you // landed from a tagged URL, shopifyShopURL is your *.myshopify.com domain.Check the event in Polar — events typically land in Polar's ingestion within seconds. Check your Polar dashboard's pixel event viewer (ask your Polar CSM if you don't know where this is) and filter by the
shopifyEventIdyou just generated. If it's not there, jump to Troubleshooting.
Troubleshooting
Check, in order:
- Network tab shows no request → the SDK isn't being called. Re-check
your route-change hook (SPA) or
<head>script placement. - Network tab shows request, status is 0 or CORS error → your site's
Content Security Policy (
connect-src) is blocking the pixel endpoint. Add the Polar pixel hostname toconnect-src. - Status 200 but events missing in Polar →
shopifyShopURLis probably wrong (e.g. you passedmy-store.cominstead ofmy-store.myshopify.com). Polar matches events to the tenant by the*.myshopify.comdomain.
generatePayloadBase() persists the anonymous userId in localStorage.
It will not persist if:
- Safari ITP / third-party context — if your storefront runs in an iframe
on another domain,
localStoragemay be partitioned or wiped after 7 days. Use a first-party subdomain for your storefront. - Incognito / private browsing —
localStorageis wiped when the window closes; this is expected. - Consent banner blocks storage before first call — if your consent
manager gates
localStorageuntil the visitor accepts, nothing persists for refusers. CallgeneratePayloadBase()only after consent if you need session stitching.
Most common cause: client and server both fire the same event with
different shopifyEventIds. See
Server-side deduplication — generate the
UUID once on the client and have the server reuse it.
Less common: the client fires page_viewed twice on a single SPA route
change because the route-change hook runs twice (React Strict Mode in dev,
or a router that fires useEffect on both mount and first navigation).
Guard with a ref or compare against the previous pathname.
If you updated your <script> tag and the old one is still loading:
purge your CDN cache (Cloudflare, Fastly, Vercel edge). For Shopify-hosted
pages, theme edits can take a minute to propagate; hard-reload with
devtools open and "Disable cache" checked.
You're on a very old browser or on Node < 19. Polyfill with
uuid:
import { v4 as uuidv4 } from "uuid";
const shopifyEventId = typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: uuidv4();(Recommended) Cart Attribute Injection
Cart attributes persist tracking data (marketing cookies, UTM params, click IDs) through Shopify checkout, enabling accurate attribution.
The examples below use @shopify/storefront-api-client.
import {
generateCartAttribute,
POLAR_ATTR_KEY,
} from "@polar-analytics/pixel-sdk";
import { createStorefrontApiClient } from "@shopify/storefront-api-client";
const client = createStorefrontApiClient({
storeDomain: "https://your-store.myshopify.com",
apiVersion: "2025-04",
publicAccessToken: "your-public-access-token",
});Creating a cart with polar_attr
const result = await generateCartAttribute();
const { data } = await client.request(
`mutation cartCreate($input: CartInput!) {
cartCreate(input: $input) {
cart { id attributes { key value } }
}
}`,
{
variables: {
input: {
lines: [{ merchandiseId: variantId, quantity: 1 }],
attributes: [{ key: POLAR_ATTR_KEY, value: result.encoded }],
},
},
},
);Updating cart attributes (with merge)
When updating an existing cart, pass the current polar_attr value so
first-touch attribution data is preserved:
const existingPolarAttr = cart.attributes.find(
(a) => a.key === POLAR_ATTR_KEY,
)?.value;
const result = await generateCartAttribute({
existingPolarAttr,
});
await client.request(
`mutation cartAttributesUpdate($cartId: ID!, $attributes: [AttributeInput!]!) {
cartAttributesUpdate(cartId: $cartId, attributes: $attributes) {
cart { id attributes { key value } }
}
}`,
{
variables: {
cartId: cart.id,
attributes: [{ key: POLAR_ATTR_KEY, value: result.encoded }],
},
},
);Best Practices
- Call
generateCartAttribute()on every cart mutation (create, add line, update). This ensures the latest session info is always attached. - Always pass
existingPolarAttron cart updates. This preserves first-touch attribution (e.g. the originalgclid).
API Reference
Supported Events
- eventFactory.page_viewed
- eventFactory.product_viewed
- eventFactory.collection_viewed
- eventFactory.search_submitted
- eventFactory.cart_viewed
- eventFactory.product_added_to_cart
- eventFactory.product_removed_from_cart
- eventFactory.checkout_started
- eventFactory.checkout_address_info_submitted
- eventFactory.checkout_contact_info_submitted
- eventFactory.checkout_shipping_info_submitted
- eventFactory.payment_info_submitted
- eventFactory.checkout_completed
- eventFactory.custom
eventFactory.page_viewed
The
page_viewedevent logs an instance where a customer visited a page. This event is available on the online store, checkout, and Order status pages.
eventFactory.product_viewed
The
product_viewedevent logs an instance where a customer visited a product details page. This event is available on the product page.
eventFactory.collection_viewed
The
collection_viewedevent logs an instance where a customer visited a product collection index page. This event is available on the online store page.
eventFactory.search_submitted
The
search_submittedevent logs an instance where a customer performed a search on the storefront. The products returned from the search query are in this event object (the first product variant for each product is listed in the array). This event is available on the online store page.
eventFactory.cart_viewed
The
cart_viewedevent logs an instance where a customer visited the cart page.
eventFactory.product_added_to_cart
The
product_added_to_cartevent logs an instance where a customer adds a product to their cart. This event is available on the online store page.
eventFactory.product_removed_from_cart
The
product_removed_from_cartevent logs an instance where a customer removes a product from their cart. This event is available on the online store page.
eventFactory.checkout_started
The
checkout_startedevent logs an instance of a customer starting the checkout process. This event is available on the checkout page. For Checkout Extensibility, this event is triggered every time a customer enters checkout. For non-checkout extensible shops, this event is only triggered the first time a customer enters checkout.
eventFactory.checkout_address_info_submitted
The
checkout_address_info_submittedevent logs an instance of a customer submitting their mailing address. This event is only available in checkouts where Checkout Extensibility for customizations is enabled.
eventFactory.checkout_contact_info_submitted
The
checkout_contact_info_submittedevent logs an instance where a customer submits a checkout form. This event is only available in checkouts where Checkout Extensibility for customizations is enabled.
eventFactory.checkout_shipping_info_submitted
The
checkout_shipping_info_submittedevent logs an instance where the customer chooses a shipping rate. This event is only available in checkouts where Checkout Extensibility for customizations is enabled.
eventFactory.payment_info_submitted
The
payment_info_submittedevent logs an instance of a customer submitting their payment information. This event is available on the checkout page.
eventFactory.checkout_completed
The
checkout_completedevent logs when a visitor completes a purchase. It's triggered once for each checkout, typically on the Thank you page. However, for upsells and post purchases, the checkout_completed event is triggered on the first upsell offer page instead. The event isn't triggered again on the Thank you page. If the page where the event is supposed to be triggered fails to load, then the checkout_completed event isn't triggered at all.
