Skip to content

Allow this site to count your visits with Yandex Metrica? Metrica will store cookies in your browser to do this. How the site uses cookies

VC
Case 8 of 25 · E-commerce · Analytics

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.

Industry
E-commerce · two Shopify stores
Technology
Python (stdlib) · Shopify API · Keitaro · FB CAPI
Timeline
Core in 3 weeks, then 2 months of improvements
Outcome
+25.7% extra revenue in the report → 0
01 · Problem

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.

02 · Solution

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.

01
Click

The click tag is captured right in the store's theme and backed up by my own visit ID

02
Store notifications

Cart, checkout and order events arrive at a small service built on http.server: a signature check for each store, duplicates filtered out

03
Sending to the tracker

Server to server: a lead for each unique click, a sale with its amount for each order

04
Linking click and buyer

In SQLite the click ID, the email hash and the checkout number come together, and the order is linked back to the first click

05
Reports

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 HMAC signature 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: lead for each unique click, sale with its amount for each order
  • writes every funnel event to its own JSONL log, 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.

03 · Technology

No external dependencies, because the server has no pip

Python, standard library only

http.server, urllib, sqlite3, and no external dependencies

Shopify Admin API + webhooks

An app with authorisation; cart, checkout and order notifications; a signature check for each store

Keitaro

Receives events from the server (lead and sale); campaigns and spend are pulled from it

Facebook Conversions API

The purchase is sent from the server with the same event ID as the browser pixel, so it isn't counted twice

Google Ads API · Meta Marketing API

Daily transfer of campaign spend into the tracker

GA4 Data API

A cross-check of sessions and sources against my own data

SQLite

Links click ↔ customer ↔ order and ties each order back to the first click

Shopify Liquid / Web Pixel

The click tag is captured in the store theme, plus my own visit ID

systemd + nginx

The service runs as a system service, transfers run on schedule, restarts need no manual steps

JSONL event log

The detailed funnel in my own storage, independent of the tracker's totals

Python stdlibShopify APIWebhooks + HMACKeitaro S2SFacebook CAPIGoogle Ads APIMeta Marketing APIGA4 Data APISQLiteLiquidsystemdnginx
04 · Results

The report matches revenue, and the weak spots in measurement are out in the open

Gap in the daily report
+25.7% 0

the extra revenue disappeared once the calculation was fixed

Errors in the calculation
2

cancelled orders counted as revenue (+25.7%) and discount codes never subtracted (+5.74%)

Revenue with no source
≈ ⅓

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.

05 · Where it fits

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
What I reuse in later projects
  • 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
Similar challenge?

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.