Shopify runs custom pixels inside a sandboxed browsing context, and that sandbox does not reliably expose navigator.userAgent. A custom pixel that reads the user agent from the browser therefore receives an empty string on a large share of sessions — in our own production data, roughly 40% of them.
Nothing errors. The event still fires, still returns a success, and still appears in your reporting. The damage lands somewhere you won’t think to look: in match quality, and in whether two sessions from the same person can be recognised as the same person.
What is Shopify’s Customer Events sandbox?
It’s the isolated environment Shopify runs merchant tracking code in, so that third-party scripts can’t reach checkout.
Shopify’s Web Pixels API lets you register a custom pixel — your own JavaScript, subscribing to events like checkout_completed, product_viewed or payment_info_submitted. Instead of running in the page, that code runs in a sandbox with a deliberately restricted view of the browsing context.
This is a sensible security decision on Shopify’s part. Checkout handles payment data, and arbitrary merchant scripts with full DOM access are exactly the attack surface that led to a decade of card-skimming incidents. The sandbox exists so that a compromised marketing script can’t read a credit-card field.
The cost of that isolation is that a lot of the browser APIs tracking code expects are either absent, restricted, or unreliable inside it — and navigator.userAgent is one of them.
Why is the user agent empty inside the sandbox?
Because the sandbox is not a normal page context, and the navigator object it exposes is not the page’s navigator.
Sandboxed execution contexts get a synthetic global environment. Some properties are populated, some are stubbed, and some vary by platform, Shopify version, and browser. navigator.userAgent falls into the unreliable set: on many sessions it returns a usable string, and on many others it returns an empty one.
Three properties of this failure make it unusually nasty:
It’s silent. An empty string is a valid string. navigator.userAgent doesn’t throw, doesn’t return undefined, and doesn’t trip any error handler. Code that does const ua = navigator.userAgent works perfectly and produces nothing.
It’s partial. If it failed every time, you’d catch it in five minutes of testing. Instead it works on your machine, on your test order, in your browser — and fails on a large fraction of real traffic. This is the single biggest reason it goes undiagnosed for months.
It’s undocumented. Shopify’s Web Pixels API documentation describes what the sandbox provides; it does not enumerate what it degrades. The behaviour surfaces mainly in scattered developer-forum threads by people who found it the hard way.
The same isolation affects cookie access and storage, which is why the other well-known Shopify tracking complaint — visitor IDs that regenerate mid-journey, so the ad click and the checkout look like two unrelated strangers — has the same root cause.

Why does an empty user agent matter?
Because the user agent is a match parameter and an identity signal, not a diagnostic field.
It degrades Conversions API match quality. client_user_agent is one of the parameters Meta and other platforms use to resolve a server event to a real person, and it’s explicitly expected to be sent unhashed alongside client_ip_address. Send an empty value and you’ve dropped a match signal from every affected event. The event is still accepted — it’s just worth less. If your Event Match Quality is lower than your form data says it should be, this is a candidate cause that never appears in any troubleshooting guide.
It breaks a tier of identity stitching. A common way to recognise that two sessions belong to the same person, when no cookie survived, is to match on IP address plus user agent within a time window. With an empty user agent, that tier can’t fire. The ad-click session and the checkout session stay separate, the purchase carries no click ID, and the conversion reports as Direct — which is the same outcome as ad conversions showing as Direct, arriving by a Shopify-specific route.
It distorts device reporting. Any breakdown by device type, browser or OS is derived from the user agent. A 40% hole doesn’t produce an obviously wrong chart; it produces a chart that’s quietly wrong, usually skewed toward whichever segment happens to succeed more often.
The pattern is consistent: nothing looks broken, and several things are.
How do you fix it?
Stop reading the user agent in the browser. Read it from the HTTP request on your server.
Every HTTP request a browser makes carries a User-Agent header. That header is set by the browser itself, before any JavaScript runs, and it is not subject to the sandbox — because the sandbox constrains what your code can see, not what the browser sends. When your pixel posts an event to your endpoint, the real user agent is sitting in the request headers of that very POST.
So the fix is an inversion:
- Don’t trust the client value. Treat
navigator.userAgentfrom inside the sandbox as a hint, not a source of truth. - Read
req.headers['user-agent']on your server when the event arrives. - Prefer the header whenever the client-supplied value is empty or missing.
- Apply it on every write path. This is the step most implementations miss — see below.
The header approach has a second advantage: it’s harder to spoof and doesn’t depend on any browser API remaining available, so it survives whatever Shopify changes in the sandbox next.
The step that gets missed: events are frequently written more than once. A session row is created when the visitor first arrives, then updated as more information appears — a form field, a checkout event, a purchase. If the header fallback is applied only on the initial insert and not on subsequent updates, a later update carrying an empty client-side user agent will overwrite the good value you already captured. The fallback has to be applied on insert and update alike, or you re-introduce the bug through the back door.
How does PartialLeads handle the empty user agent?
By never depending on the sandbox for it. The real user agent is taken from the HTTP request header on the server, and that fallback is applied on both the insert and the update paths so a later event can’t overwrite a good value with an empty one.
A server-set, ITP-durable visitor ID. The visitor ID is stored in localStorage with a first-party cookie backup, and the cookie is set by the server rather than by script — so Safari’s seven-day cap on script-written cookies doesn’t apply. This matters for Shopify specifically, because the sandbox’s restricted storage access is what makes visitor IDs regenerate mid-journey in the first place.
Identity stitching that doesn’t rest on one signal. Sessions are unioned into one person across six tiers — visitor ID, email, phone, IP and user agent, device fingerprint, and click ID. The IP-plus-user-agent tier is one of six rather than the only fallback, so a degraded user agent costs a tier instead of costing the match.
Visit-sibling attribution inheritance. Within a single visit, a session missing its attribution can inherit it from a sibling session on the same client, IP and user agent inside a 30-minute window — with a second variant that tolerates a changed IP when the Meta click cookie matches, covering a phone that switches from wifi to mobile data mid-checkout. In production this is what flips a purchase that arrived as “Direct” back to the ad click that actually caused it.
Purchases that don’t depend on the browser at all. Conversions fire from Shopify’s orders/create webhook server-side, so every order in the store admin produces a conversion event whether or not the browser cooperated. Sandbox failures, closed tabs and gateway redirects stop being tracking events.
Honest boundary. The header fallback recovers the user agent, and it genuinely restores that match parameter and that identity tier. It does not recover anything the sandbox prevented from being collected in the first place, and it doesn’t change what the ad platform does with the event once it arrives — whether Meta can resolve a given person is decided on Meta’s side of the wire. This fixes a data-loss bug; it isn’t a match-rate guarantee.

| Problem | PartialLeads mechanism | Where you see it working |
|---|---|---|
navigator.userAgent empty in the sandbox |
Real User-Agent taken from the HTTP request header server-side |
User agent present on sessions that would otherwise be blank |
| A later event overwrites a good value | Header fallback applied on insert and update paths | Device column stays populated through checkout |
| Empty UA drops a CAPI match parameter | client_user_agent populated from the header before dispatch |
Request body retained per event in the CAPI activity log |
| IP+UA identity tier can’t fire | Six-tier identity cluster, UA is one tier of six | Journey ribbon showing stitched sessions |
| Visitor ID regenerates mid-journey | Server-set, ITP-durable visitor cookie | Same visitor ID across the ad click and checkout |
| Purchase never reaches the platform | orders/create webhook dispatch, browser not in the loop |
Admin order count reconciled against dispatched events |
How do you check whether this is affecting you?
Query your own event data for sessions where the user agent is empty or null, over the last 30 days, and express it as a share of Shopify sessions. That single number tells you the size of the problem.
If you can’t query raw events, two indirect signals are worth checking. Look at your device-type breakdown for Shopify traffic and compare it against your other traffic sources — a missing or lopsided split suggests a hole rather than a real difference in audience. And compare Event Match Quality on your Shopify purchase events against the same event type from a non-Shopify source; a gap that your form data doesn’t explain points at a dropped match parameter.
Tell us what's broken. We'll fix your tracking — free.
Describe the tracking/attribution problem you're stuck on and we'll map it to a fix: server-side conversions to Meta, Google, TikTok and Pinterest, plus first-party tracking that survives Safari. No code required.
Sources
- Shopify Dev Docs — Web Pixels API: https://shopify.dev/docs/api/web-pixels-api
- Shopify Dev Docs — Webhooks: https://shopify.dev/docs/api/webhooks
- Mozilla Developer Network — User-Agent header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/User-Agent
- Mozilla Developer Network — Navigator.userAgent: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgent
- Meta for Developers — Customer information parameters: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters