Tillde
Menu

Case study

Three customer fields out of three were out of the pixel's reach: a server-side proxy for $7 a month

Shopify Custom Pixels run sandboxed and cannot query the store's admin data. A microservice returns the three metrics at 4 quota points out of 2,000, without ever exposing the admin token.

·Mattia Minafò
case studyshopifycustom pixelgraphqlltvdataLayer

A lifestyle e-commerce store on Shopify, physical product, continuous spend on Google, Meta and TikTok.

The constraint: Shopify Custom Pixels run in a sandboxed environment and cannot query the store’s admin data. It is not a setting to switch on, it is a design decision by the platform. Lifetime spend, historical order count and a reliable new-versus-returning status stay out of the dataLayer, and there is no configuration that lets them in.

The result: a microservice that queries the admin data on the pixel’s behalf and returns the three metrics, at a cost of 4 quota points out of the 2,000 available per call and $7 a month of infrastructure. The admin token never reaches the browser.

Below is why the classic route didn’t work, how we chose the alternative, and the check that caught a defect before production.

What this case covers

  • how to get a field to the pixel that the platform keeps out of its reach
  • how to expose protected data without putting a high-permission key inside the browser
  • how to choose between two interfaces when one has a time limit and the other doesn’t
  • how to build a test that tells a correct answer apart from an empty one

Contents

  1. The client and the starting point
  2. Proxy architecture and security model
  3. Migrating from REST to GraphQL
  4. Validation and testing in production
  5. Method note on the numbers
  6. What didn’t work
  7. What we would do now
  8. Conclusion

The client and the starting point

A Shopify store with tracking already set up and working: complete events, correct values, deduplication between browser and server channels active. The work did not come out of a fault, but out of a segmentation question. Campaigns on new customers and on returning customers were performing too similarly, and they wanted to know whether the problem was creative or measurement.

The starting position, with the source of each figure:

FieldSituation without the APISource
Customer lifetime spendnot available in the dataLayerCustom Pixel payload inspection
Historical order countnot available in the dataLayerCustom Pixel payload inspection
New versus returning statusapproximated, derived from internal analyticsCustom Pixel payload inspection
Admin tokenabsent from the browser, correctlypage source inspection

The third row is the one that mattered. The field was there and it was wrong, which is worse than not having it, because nobody questions a populated field.


Proxy architecture and security model

IN SHORT

What we did: built a microservice that sits outside the browser, holds the admin token and only answers authenticated calls.

Why: the key needed to read the admin data has high permissions, and inside the pixel’s code it would be readable by anyone inspecting the page.

What we found: the pixel can make outbound calls, and that alone is enough to move the problem outside the browser.

The number for this section: 2 endpoints, one called on every purchase and one called once at installation.

The starting decision was not to look for shortcuts inside the pixel. A sandbox is sandboxed for security reasons, and every attempt to work around it introduces the very risk it was meant to avoid.

The flow has five steps. The Custom Pixel detects checkout completed and reads the customer identifier. It calls our endpoint sending that identifier and a shared key. The server verifies the key, queries the Shopify admin data with the admin token, computes new-or-returning status, and returns the three metrics. The pixel adds them to the dataLayer, and from there they reach the advertising platforms.

Two endpoints with distinct roles. The first receives identifier and key on every purchase and returns the metrics. The second completes the authorisation flow once at installation, to obtain the permanent token.

The admin token is never transmitted to the browser. It lives only in the server’s environment variables, together with the shared key and the app parameters. It is not in the code and it is not in the repository.

The service handles every error case by always returning a valid response with fallback values, so the pixel never stalls. Wrong key, missing identifier, admin data unreachable: in all three cases the response arrives, marked as failed, with status “new customer”, lifetime spend at zero and order count at zero.

What worked

The always-valid fallback. Tracking never has a point where it can stop because of us, and that made it possible to release without a maintenance window.


Migrating from REST to GraphQL: three limits removed at once

IN SHORT

What we did: dropped the classic interface for the query-based one, using the fields the platform already aggregates on the customer node.

Why: the classic interface has three limits that compound, and they get worse precisely on your best customers.

What we found: the aggregated fields remove all three limits with a single call.

The number for this section: from N calls at proportional cost to 1 call at a fixed 4 quota points out of 2,000.

The initial implementation used the classic interface, which returns the list of orders and leaves the summing to us. Three limits, all structural.

First, the 60-day window. The basic order-read permission returns only the last two months of orders. For the full history you need an extended permission, which requires manual review by the platform.

Second, pagination beyond 250. The response is capped at 250 orders per call. A customer with 300 orders requires two calls in sequence, one with 600 requires three. Latency grows with the customer’s history.

Third, proportional cost. Every order returned consumes quota. The more a customer has bought, the more it costs to query them.

The three limits share a characteristic that makes them worse than the sum of their parts: they hit hardest precisely on the highest-value customers, the ones the data actually matters for.

The query-based interface exposes two fields on the customer node that the platform has already summed: order count and lifetime amount. The query is six lines long and asks only for those two values.

No time limit, because the fields cover the entire history. Constant precision, because there is no iteration to paginate. One call for one response. And a cost measured in testing of 4 quota points out of the 2,000 available, independent of the customer’s order count.

AspectClassic interfaceQuery-based interface
Time limit60 days on the basic permissionnone
Precision beyond 250 ordersimpreciseconstant
Calls per responseN, one per 250 orders1
Quota costproportional to volume4 fixed points

What worked

Changing interface instead of requesting the extended permission. A manual review request would have solved only the first of the three limits, and would have added a wait of unpredictable length.


Validation: the check that stopped a defect before production

IN SHORT

What we did: verified the service against a record whose exact answer we already knew, counted by hand in the back office.

Why: a “zero orders” answer is plausible for most customers, so a test on a random identifier cannot tell correct from empty.

What we found: the service returned zero on a record with known history, and the defect was a schema one.

The number for this section: 34 orders expected against 0 returned, then 34 after the fix, re-verified on another 3 real customers.

The verification was designed before it was run, and the design is the part that counts.

A service like this always answers something, and the plausible answers are the trap. Zero orders is a credible value: it is credible for most customers of any store. If you run the check on a random identifier you see zero, and there is no way to know whether it is the right answer or the sign that the data isn’t getting through.

So the test was run against a record with known, verifiable purchase history: 34 orders counted by hand in the back office.

The service returned zero. The check had done exactly its job.

The cause was a schema one. The implementation looked for the amount by following a path borrowed from the structure of individual orders. That path exists on an order, but it does not exist on the customer node as an aggregate value. The service was asking for a field that doesn’t exist at that level, received nothing, and returned zero without flagging anything.

It was not an authentication problem, nor a connectivity one. Replaced with the two native aggregated fields, the answer went back to 34.

There were three production checks after release. Service health check, positive response. Query verified on three real customers of the store, with quota consumption of 4 points out of 2,000. Endpoint call with a valid identifier, returning correct status, lifetime spend and order count.

The final classification the pixel receives has three states: zero orders means no previous purchase, one order means a first purchase ever, two or more means an existing customer.

What worked

Choosing the test record on the basis of what we already knew, instead of at random. It is the only test setup in which a plausible-but-wrong answer cannot get through.


Method note on the numbers

Declared source of truth: the Shopify order ledger. The three metrics the service returns are recomputed on every call directly from the admin data, neither cached nor derived.

Published numberSourceWindowModel
4 quota points out of 2,000API response in testingper callcost stated by the platform on the executed query
34 ordersorder ledger, counted by handlifetimeorder count for the individual customer
3 customers verifiedtesting in production1 June 2026API response compared against the ledger
60 days, 250 ordersplatform documentationstructural limitsbasic order-read permission
$7 a monthhosting provider invoicerecurringalways-on instance

What we are not able to measure, and with what degree of certainty.

The effect of correct segmentation on campaign performance is not measured in this engagement, and we don’t claim it. The scope ended at delivering the data into the dataLayer, and measuring the effect would take a comparison between two periods with the same spend and the same creative, which was not set up.

What is verified by active test is that the three metrics reach the pixel correctly, compared one by one against the order ledger on three real customers. That is a verification of data correctness, not of commercial impact, and the two should not be conflated.


What didn’t work

We built the first version on the wrong field path. The initial implementation looked for lifetime spend by following the structure of individual orders instead of the aggregated fields on the customer node. The defect produced no errors: it returned zero, which is a plausible value. What we did instead: the known-answer record test, which has become the rule for every service we write. If we don’t know the expected answer before asking, it isn’t a test.

We started on the wrong interface. The first implementation was on REST, and it was rewritten entirely. The reason is that we had assumed the more widespread interface was also the suitable one, without first comparing the limits of the two. The time lost is that of the first draft. What we did instead: comparing the available interfaces is now an explicit step before writing code, and it is judged on behaviour at the limits rather than on familiarity.


What we would do now

Measure the effect of segmentation over 60 days. With the three metrics in production there is, for the first time, the possibility of comparing campaigns on new and returning customers with real data instead of an estimate.

Extend the service to events upstream of purchase. Today it answers on checkout completed. The same three metrics on a product view would allow retargeting to be segmented by customer value.

Add a short cache. A call fires on every purchase. With 4 points out of 2,000 the headroom is wide, but a few minutes of cache on the identifier would remove repeat calls when the confirmation page is reloaded.

Assess the free hosting alternative. The code requires no changes to run on a provider with a free tier suited to low traffic. Seven dollars a month is little, but it is recurring.


Conclusion

The three customer fields the pixel could not see now arrive in the dataLayer, at a cost of 4 quota points out of 2,000 per call and $7 a month of infrastructure.

What this engagement leaves behind, picking the four opening points back up.

How to get the pixel a field out of its reach: you don’t work around the sandbox, you move the request outside the browser and have someone else answer it.

How to expose protected data without putting the key in the browser: the key stays on the server, the browser sends only an identifier and a shared password.

How to choose between two interfaces: you compare them on their limits, not on familiarity. The one with pre-aggregated fields removes time window, pagination and variable cost together.

How to build a test that tells correct from empty: you pick a record whose answer you already know. If the expected answer is a number and a different one comes back, the test works. If the expected answer is zero, the test can tell you nothing.


When this work makes sense, and when it doesn’t

It makes sense if you segment campaigns by new versus returning customer and want that distinction to rest on order history instead of an estimate, and if you sell on a platform that keeps customer data out of tracking’s reach.

It doesn’t make sense if your order volume is such that segmenting by customer value changes no budget decision. And it doesn’t make sense if you expect better data on its own to improve campaigns, because data enables a decision, it doesn’t replace it.

The first step is to open the dataLayer of a purchase on your store and look at whether there is an order count next to the customer status. If there isn’t, that field is an estimate.