End-to-end attribution for two Shopify stores: from click to order
A US outdoor-gear brand paid for ads on Meta, Google and YouTube but didn't know which ads brought orders. I linked the whole path "ad click → order → ad account" through the Keitaro tracker, which records ad clicks, and a service of my own that sits in between. The revenue report now matches real sales. Along the way it became clear where the link between click and order gets lost.
Three sources of figures, and each showed something different
The business spent money on Meta, Google and YouTube and couldn't answer the most basic
question: which ads bring orders. Shopify recorded a large share of sales as direct visits
(Direct).
The ad accounts showed conversions that didn't match the owner's working spreadsheet. And the
spreadsheet matched neither of them.
The owner put it bluntly: "the data doesn't match". Analytics nobody trusts is useless. When three reports give three different revenue figures, every discussion turns into an argument about numbers.
In practice, budget decisions were made on gut feeling. Nobody could say what one sale cost in each channel. That meant there was no way to switch off a losing channel or deliberately put more money into a profitable one. Money was spread out of habit.
An unbroken data path where every link can be checked
Attribution means linking an order to the ad that brought it. It can break in four places. The click tag never reaches the store. The store's order notification (a webhook) arrives twice. The event sent from the server duplicates the one from the browser. The ad spend never reaches the report. So I built an unbroken chain where each link can be checked on its own.
The click tag is captured right in the store's theme and backed up by my own visit ID
Cart, checkout and order events arrive at a small service built on http.server: a signature check for each store, duplicates filtered out
Server to server: a lead for each unique click, a sale with its amount for each order
In SQLite the click ID, the email hash and the checkout number come together, and the order is linked back to the first click
The buyer's full path across touches, each channel's share and role, lifetime revenue per customer, orders that Shopify recorded as direct while the tracker linked them to ads
The click tag had to be captured in the store theme itself
The standard route, Web Pixel, didn't work. It runs in Shopify's isolated sandbox and can't
write data to the cart
(cart attributes),
so the click tag never reaches the order. I had to capture it in the store theme itself and back
it up with my own visit ID
(vid).
That way the link between click and order survives even where the built-in mechanism loses it.
The in-between service: an HMAC signature per store, duplicates filtered out
My own service, built on
http.server,
receives Shopify's notifications and does four things:
- checks the
HMACsignature with a separate secret for each of the two stores - filters out duplicates: Shopify resends notifications, and the report mustn't grow because of that
- sends data from the server to the tracker:
leadfor each unique click,salewith its amount for each order - writes every funnel event to its own
JSONLlog, so the detailed path is kept even though the tracker stores only totals
Conversions API: the browser and the server count a purchase once
Alongside the data sent to the tracker, the service sends a
Purchase
event from the server to the Facebook Conversions API. It carries the same
event_id
as the browser pixel. This is the key detail: without a shared ID the ad account counts
conversions twice and optimises the ads against inflated numbers.
Click, buyer and order are linked in one database
A separate SQLite database links the click, the buyer and the order through
ksubid,
an email hash and
checkout_token,
and links each order back to the first click. The reports are built on these links. They
answer questions that looking only at the last click can't:
- the buyer's path across several touches, and how the credit is shared between channels
- each channel's role: introducing the brand, reminding people of it or bringing the purchase
- lifetime revenue per customer (LTV) for each channel
- orders that Shopify recorded as direct visits (
Direct) while the tracker linked them to paid ads - a comparison with the post-purchase survey "how did you hear about us", as an independent check on the model
Ad spend is pulled in daily, so return is calculated with costs included
Attribution often breaks on something simple: the revenue per channel is there, but the spend stays inside the ad account. Campaign spend is pulled on a schedule from the Google Ads API and the Meta Marketing API straight into the tracker. So the cost of a sale in each channel is right there in the report.
Only Python's standard library
The whole service is written with
urllib,
http.server
and
sqlite3,
with no external dependencies at all. The reason is simple: the code has to run on a live server
that has no pip,
Python's package installer. As a bonus, there are no third-party packages to keep updated.
No external dependencies, because the server has no pip
http.server, urllib, sqlite3, and no external dependencies
An app with authorisation; cart, checkout and order notifications; a signature check for each store
Receives events from the server (lead and sale); campaigns and spend are pulled from it
The purchase is sent from the server with the same event ID as the browser pixel, so it isn't counted twice
Daily transfer of campaign spend into the tracker
A cross-check of sessions and sources against my own data
Links click ↔ customer ↔ order and ties each order back to the first click
The click tag is captured in the store theme, plus my own visit ID
The service runs as a system service, transfers run on schedule, restarts need no manual steps
The detailed funnel in my own storage, independent of the tracker's totals
The report matches revenue, and the weak spots in measurement are out in the open
the extra revenue disappeared once the calculation was fixed
cancelled orders counted as revenue (+25.7%) and discount codes never subtracted (+5.74%)
of one store's website revenue; the cause was found and confirmed
Two calculation errors that made people distrust the report
The first thing the new data path made possible was checking the report against reality, and that check found two errors right away. Cancelled orders were counted as revenue: on one day that inflated the figure by 25.7%. Worse, a cancelled order was shown as a success for the ads. Discount codes were never subtracted, which added another 5.74%. Both errors are fixed, and the daily report now matches actual revenue.
Zero tracked sales meant "nothing to measure with"
When campaign spend and tracked sales first appeared in the same report, the difference between channels looked huge. One ad network had about 186k clicks and not a single tracked sale. All of that month's tracked sales came from another network with about 12k clicks. Based on those figures, the owner moved the budget.
The follow-up turned out to be the important part. A zero in the sales column meant the purchase arrived without a campaign ID. For the same reason, 31 campaigns that accounted for 74% of the ad spend in the report showed "0 sales · 0.0×". The screen was suggesting switching off what was working. Now, if there's nothing to match sales against, no return is calculated and the report shows a dash. The report also names an ad platform failure that the platform itself doesn't announce. Once such a failure in an ad API cut the number of linked purchases in half, from 101 to 47, with not a single warning in the log.
The same lesson came up with campaign types. Working out the type from the campaign name was a mistake: thirteen campaigns with "Video" in the name had nothing to do with video. The real video campaigns over the same period numbered 17, with eight clicks between them. The type now comes from the platform itself.
A third of one store's revenue had no source, and the cause was outside the tracking
About a third of one store's website revenue arrived with no source. The cause turned out to be external. The cookie consent banner blocked the Shopify session until the visitor gave consent, so the source was lost before analytics had a chance to record it. The diagnosis was confirmed by comparing the two stores and by the same thing having happened earlier in the second store.
The system survived a change of data source
Later the external tracker was switched off, and reporting moved to the brand's own sources. The move taught its own lesson. For eight days, two timers kept sending spend data to a receiver that had been switched off, and reported success every time, with not one error in the log. Now every step writes to the log exactly what it contacted and how it ended.
The ad platform keeps click history for exactly 90 days. Links that could still be restored were expiring at a rate of about one and a half a day. So I built my own "click → campaign" cache, which is topped up twice a day. The pages take links from this cache and never fetch them from the internet: a separate test stops any code that tries. The backfill collected 247 clicks and identified 229, with none expired, in 35 seconds. Linked orders went from 46 to 97, and the share of revenue not linked to a campaign fell from 82% to 57%. The screen shows the previous value next to it, with a note that the link was restored.
Most of the budget went to a channel the report barely showed
Most of the ad money goes to bloggers, and the system showed almost nothing for that channel. The real trace turned up in the order tag. The blogger's handle is read in the same pass that already works out the sale type, so no extra requests to the store were needed. Over 50 days the channel brought 96 orders, 19.7% of all revenue, through 17 different links. I deliberately keep the links of the same person separate: two links from one blogger differ in return by 22 times, and merging them would hide exactly what the count is for.
In the end the business has one report that stands up to checking. Where there's enough data, it shows a figure. Where there isn't, it shows a dash, and a platform failure is named plainly.
Where else the same approach works
Behind this case is a common task: "link an action on the website to the money in the till and to the spend in the ad account". The approach carries over almost unchanged to any business with paid ads and orders:
- → Any store on Shopify, WooCommerce or a website builder that records suspiciously many sales as "Direct"
- → Several stores or domains sharing one ad budget: one attribution model across all ad accounts
- → Businesses that sell through enquiries with a long sales cycle (enquiry, call, deal in the CRM): the same chain "click → ID → data sent from the server"
- → Server-side events for Meta, Google and TikTok when browser tracking loses data, always with duplicates filtered out by event_id
- → A check of existing analytics when reports already exist and contradict each other. Often the cause is in the report's own calculation
- A template for the in-between service on the standard library: receiving notifications, a signature check per store, filtering duplicates, sending events from the server
- A shared event ID for the browser pixel and the Conversions API, so conversions aren't counted twice
- Links in SQLite: click ↔ customer ↔ order, with each order tied back to the first touch
- Daily transfer of spend from the ad accounts, so the return is calculated with costs included
- A set of reports on top of the tracker: the full path across touches, each channel's share and role, lifetime revenue per customer, and ad-driven orders recorded as direct visits
Your store records sales as "Direct" and the reports contradict each other? That can be fixed
The place to start is checking the whole chain: where the tag gets lost, where an event is counted twice, and where the report calculates something other than what it shows. The core of the attribution takes 2–3 weeks to build; after that come the reports and tuning on real data.
Related cases
Shopify orders land in the warehouse sheets by themselves
Order numbers, tracking numbers, statuses and cancellations are filled in every 30 minutes. An Apps Script…
File exchange with a shipping partner: costs and tracking numbers come back into the sheet
The daily file builds itself and looks just like the working sheet, row colours included. The partner's reply…
Reconciling revenue across three sources: an 8.4% gap explained down to 0.33%
The owner's working sheet, the warehouse book and the CRM each showed a different revenue figure. A one-off…