Skip to content
VC
Case Study #19 · B2B / E-commerce · Analytics

End-to-end attribution for two Shopify stores: from click to order

A US outdoor-gear brand was paying for Meta, Google and YouTube but could not tell which ads produced orders. I built a continuous "ad click → order → ad account" data path on the Keitaro tracker plus a custom middleware. Revenue reports stopped disagreeing with reality, and the owner finally had grounds to reallocate the ad budget.

Industry
E-commerce · two Shopify stores
Stack
Python (stdlib) · Shopify API · Keitaro · FB CAPI
Timeline
Core — 3 weeks, iteration — 2 months
Outcome
+25.7% phantom revenue → 0
01 · Pain Point

Three sources of numbers, and none of them agreed

The business was spending on Meta, Google and YouTube and could not answer the very first question: which ads bring orders. The Shopify storefront attributed a large share of sales to Direct, the ad accounts reported conversions that did not match the owner's operational spreadsheet, and the spreadsheet matched neither of the other two.

The business owner put it bluntly: "the data doesn't line up". That is the worst possible state — not the absence of analytics, but analytics nobody trusts. When three reports produce three different revenue figures, every meeting turns into an argument about numbers instead of a conversation about advertising.

The practical consequence: budget decisions were made on gut feel. Nobody could say what one sale cost on each channel — so there was no way to switch off a losing channel or deliberately double down on a profitable one. Money was allocated out of habit.

02 · Solution

A continuous data path: the click is never lost

Attribution doesn't break in one place, it breaks in four: the click tag never reaches the storefront, the webhook is delivered twice, the server-side event duplicates the browser one, and ad spend never lands in the report. So the deliverable wasn't "a pixel" but an unbroken path where every link can be verified on its own.

01
Click

Click tag captured in the store theme, mirrored by a custom vid

02
Webhooks

cart / checkout / order → http.server middleware, per-store HMAC, deduplication

03
Postbacks

S2S to the tracker: lead per unique click, sale with revenue per order

04
Identity

SQLite graph: ksubid / e-mail hash / checkout_token → back-link to the first click

05
Reporting

Multi-touch paths, channel credit and role, LTV, attribution wins

The click tag had to be captured in the theme itself

The standard route — Web Pixel — did not work: inside the Shopify sandbox it does not write cart attributes, so the click tag simply never reaches the order. The tag is therefore captured in the store theme and mirrored by a custom visit identifier (vid), so the click↔order link survives even where the built-in mechanism drops it.

Middleware: per-store HMAC, deduplication at the door

A custom middleware built on http.server receives Shopify webhooks and does four things:

  • verifies the HMAC signature — with a separate secret for each of the two stores
  • deduplicates events: Shopify retries delivery, and the report must not grow because of it
  • fires an S2S postback to the tracker — lead per unique click, sale with revenue per order
  • writes every funnel event to its own JSONL log — the granular path is preserved where the tracker only aggregates

Conversions API: events collapse instead of doubling

In parallel with the tracker postback, the middleware sends a server-side Purchase to the Facebook Conversions API — carrying the same event_id as the browser pixel. This is the detail that matters: without a shared identifier you don't get recovered attribution, you get doubled conversions in the ad account and optimization against invented numbers.

An identity graph instead of last-click

A separate SQLite database stitches click, customer and order together via ksubid, e-mail hash and checkout_token, with a back-link to the first click. On top of that graph sits a reporting layer that answers questions last-click cannot:

  • the buyer's multi-touch path and credit distribution across channels
  • the channel's role: opener / assist / closer
  • LTV by channel — not just first-order revenue
  • "attribution wins": the storefront said Direct, the tracker caught a paid source
  • a join with the post-purchase "how did you hear about us" survey — an independent check on the model

Ad spend syncs daily — otherwise ROI is computed without costs

Half of all attribution projects fail on something mundane: revenue per channel exists, 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 cost per sale by channel is a field in the report rather than a monthly manual reconciliation.

Standard library only

The whole path is written with urllib, http.server and sqlite3 — not a single external dependency. The reason is prosaic: the code has to run on a production server with no pip. The side effect is that there is nothing to rot and nothing to patch on a security alert.

03 · Stack

Zero dependencies — because the production box has no pip

Python (stdlib-only)

http.server, urllib, sqlite3 — no external dependencies at all

Shopify Admin API + Webhooks

OAuth app, cart / checkout / order webhooks, per-store HMAC

Keitaro

S2S postbacks (lead / sale) and Admin API for campaigns and spend

Facebook Conversions API

Server-side Purchase with a shared event_id — deduplicated against the browser pixel

Google Ads API · Meta Marketing API

Daily sync of campaign spend into the tracker

GA4 Data API

Cross-check of sessions and sources against first-party data

SQLite

Identity graph click↔customer↔order with a back-link to the first click

Shopify Liquid / Web Pixel

Click tag capture in the store theme plus a custom vid

systemd + nginx

Middleware as a service, syncs on timers, restarts without manual work

JSONL event log

Granular funnel in first-party storage, independent of tracker aggregates

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

The report stopped lying — and immediately changed decisions

Daily report discrepancy
+25.7% 0

phantom revenue disappeared once the calculation was fixed

Calculation defects
2

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

Revenue with no source
≈ ⅓

of one store's web revenue — root cause found and confirmed

Two calculation defects that were destroying trust in the report

The first thing the data path delivered was the ability to reconcile the report against reality — and that reconciliation immediately surfaced two bugs. Cancelled orders were counted as revenue: on one day that inflated the figure by 25.7% and, worse, presented a cancelled order as an "attribution win". Discount codes were never subtracted — another 5.74% on top. Both are fixed; the daily report no longer diverges from actual revenue.

Cost per sale by channel changed the budget decision

Once campaign spend and tracked sales met in the same report, the picture was uncomfortably clear: roughly 186k clicks from Facebook produced not a single tracked sale, while every tracked sale that month came from YouTube on about 12k clicks. On that evidence the business owner switched off the Facebook leg and reallocated the budget. That is exactly what attribution is built for — not a good-looking dashboard, but one management decision that cannot be made without numbers.

A third of the revenue with no source turned out not to be a tracking bug

About a third of one store's web revenue arrived with no source — and the right answer here was not "fix the pixel". The cause was external: the cookie consent banner blocked the Shopify session until the visitor clicked "Accept", so the source was lost before analytics ever got a turn. The diagnosis was confirmed by an A/B comparison of the two storefronts and by a historical precedent on the second store — not by guesswork.

The overall outcome, stripped of detail: the business now has a single source of truth that holds up under scrutiny. The report can be questioned again — and the answer no longer has to be re-checked by hand.

05 · Where it fits

Where else the same methodology applies

This case isn't about Shopify. It's the standard problem of "stitch a site event to the money in the till and to the spend in the ad account". The same path transfers almost unchanged anywhere there is paid traffic and orders:

  • Any store on Shopify / WooCommerce / a hosted builder where the storefront reports suspiciously much "Direct"
  • Multiple storefronts or domains on one shared ad budget — a single attribution model instead of scattered ad accounts
  • Lead-based businesses with a long cycle — enquiry, call, CRM deal: the same "click → identifier → S2S postback" chain
  • Server-side events for Meta / Google / TikTok after browser tracking loss — with mandatory event_id deduplication
  • Audit of existing analytics where reports exist but contradict each other — often that's a calculation defect, not a tracking one
What's reused on subsequent projects
  • A stdlib middleware template: webhook intake, per-store HMAC, deduplication, S2S postback
  • The shared event_id scheme for browser pixel and Conversions API — conversions never double-count
  • SQLite identity graph: click ↔ customer ↔ order with a back-link to the first touch
  • Daily spend sync from the ad APIs — ROI computed with costs in it, not without them
  • The reporting layer on top of the tracker: multi-touch, channel credit and role, LTV, attribution wins
Similar challenge?

If your storefront says "Direct" and your reports contradict each other — that's fixable

The right starting point isn't building, it's auditing the path: where exactly the tag is lost, where the event is duplicated, and where the report computes something other than what it shows. The attribution core takes 2-3 weeks; reporting and tuning on real data follow.

Ready to start?

The 5,000 ₽ audit — with a concrete report and quote

I'll tell you what to deploy in your business first, what the payback looks like, and whether you need AI for the task at all (sometimes you don't).

Or just send your question — I reply within 2 hours