You attribute ecommerce revenue to an ad by storing the click identifier and a durable first-party visitor id the moment someone lands, then joining the paid order back to that identity on your server — using the email or phone the order already contains. The browser cookie is one join key, and the weakest one. Every reliable setup has a server-side join behind it.
That is the whole mechanism. Everything else — models, windows, dashboards — is reporting on top of a join that either happened or didn’t.
Why does your store show revenue your ad platform never claims?
Because the order and the click live in two systems that share no key. Your store knows an email, a card, an amount and a timestamp. Your ad account knows a click id, a browser and a bid. The only thing that ever connected them was a cookie sitting in one browser on one device, and that cookie has to survive every step between the ad click and the payment.
Often it doesn’t. The order still lands — money is money — but it arrives with no source attached, so it falls into Direct. Direct is not a channel. It is the name your analytics gives to revenue whose origin it lost.
It is also why your ad account and your store disagree every Monday: one reports conversions modelled from the clicks it could see, the other counts orders exactly. Neither is lying, and neither can tell you which spend produced the money.
Where does the click-to-order link actually break?
In four places, all of them normal buyer behaviour rather than a misconfiguration. Name them and most “our tracking is broken” conversations resolve into one of the four, each with a different fix.
The cookie dies before the buyer does
Browser storage written by JavaScript is aggressively capped. A visitor who clicks an ad, thinks about it for a week and comes back to buy is routinely a brand-new anonymous visitor by the time they check out. Nothing was misconfigured; the identifier simply expired faster than the consideration cycle.
The buyer switches device
She clicks on her phone in the evening and buys on a laptop the next morning. Two browsers, two cookie jars, no shared identifier — unless something joins them on an identity the person carries between devices, which in practice means their email or phone number.
Checkout happens somewhere the click never went
A landing page on one domain, a checkout on another. A hosted payment page. An in-app browser handing off to the system browser. Each hop is a chance for the first-party context to be dropped, and the order is created in a session that never saw the ad.
The browser never ran your pixel
Blockers, consent walls, a tab closed on the confirmation page, a flaky network on mobile. Client-side conversion tracking only reports what the browser cooperates with. Server-side reporting is the only path that does not depend on the buyer’s browser still being there when the payment clears.
What does attributing revenue to an ad actually require?
Four things, and you need all four. Miss any one and the join fails silently — which is the part that makes this expensive, because nothing errors. Revenue just accumulates under Direct while the campaign that earned it looks unprofitable.
- Capture the click identifier on arrival.
fbclid,gclid,gbraid,wbraid,msclkid,ttclid,epik— whatever came in on the landing URL, stored server-side, along with the UTMs and referrer. - Give the visitor a durable identity. A first-party id that survives cookie expiry and, ideally, is written by your server rather than by script, so browser privacy caps do not shorten it to days.
- Get identity onto the order. The order already has an email and usually a phone. Those are the keys that cross devices and cross browsers. They need to be normalised the same way on both sides — lowercased email, E.164 phone — or the join misses on formatting alone.
- Do the join on your server, after payment. Not in the browser, not on the confirmation page. When the order is created, you match it against stored sessions and write the source onto it.

The join is tiered, because the keys are not equally trustworthy. A visitor id echoed back by your own checkout is near-certain. An email match is very strong. A phone match is strong. An IP-and-user-agent match is a guess you should label as one. Any system that treats all four as the same fact will confidently attribute revenue to the wrong campaign.
How do you attribute revenue to ads without a vendor?
You can get a long way with four moving parts you already own. Each one works; each one has a limit worth knowing before you build it.
Put the UTMs and click id into hidden form fields, and carry them to the order. On Shopify or WooCommerce you can persist them into cart attributes or order metadata, then read them back in a spreadsheet. This is the highest-value afternoon of work in this article. Its limit: it only knows about the session that placed the order. The buyer who clicked your ad last Tuesday and returned today via a bookmark still lands in Direct, because that session genuinely had no source.
Send purchases server-side instead of from the browser. Firing the order from your store’s webhook rather than the confirmation page means blockers, closed tabs and payment redirects stop costing you events. This is what the Conversions API is for on the Meta side, and every major platform now has an equivalent. Its limit: it gets the event delivered, but delivery is not attribution. The platform still has to resolve that event to a person and a click.
Hash and send the identifiers you already have. Email and phone, hashed with SHA-256 after normalisation, are what let a platform tie a server event to a real user. This is the entire content of event match quality: you can only transmit what you collected. A checkout that never asks for a phone number has a lower ceiling than one that does, no matter how good the plumbing is.
Upload offline conversions with the stored click id. If you kept the gclid or fbclid, you can hand the platform the conversion after the fact. Its limit is circular and worth saying out loud: this requires the click id to have been stored, and the click id going missing is the original problem for most stores.
The honest summary: those four steps fix delivery and fix single-session attribution. What they do not fix is identity across sessions and devices — the buyer who took nine days and three visits. That gap is where a purchased order stays Direct forever, and it is the reason attribution tools exist at all.
Why do first-touch and last-touch reports disagree?
Because they answer different questions about the same order, and both answers are correct. First touch credits the channel that introduced the buyer. Last touch credits the channel present when they paid. The same $400 order can be a Meta win and a Google win depending on which column you read.
The disagreement is the information. High first touch and low last touch means a channel is finding people who convert somewhere else — a discovery channel you will underfund if you only read last click. High last touch and near-zero first touch means a channel is harvesting demand someone else created, which is fine as long as you know that is what you are buying.
A single-number attribution report hides exactly this. What you want is both models on screen at once, on the same orders, in the same period — plus a view of which channels assisted without ever closing.
How does PartialLeads attribute ecommerce revenue to the ad click?
Every paid order enters one pipeline. Purchases arrive from Shopify (webhook plus the Customer Events pixel), WooCommerce, Stripe, GoHighLevel, or a universal webhook from anything that can POST an order. The pipeline normalises identity, deduplicates the order, then matches it to a session in confidence order: a visitor_id echoed back by checkout, then email, then phone, then IP. Refunds arrive as their own rows and net against paid revenue instead of quietly inflating it.
Identity is the part that decides whether the match lands. PartialLeads keeps a six-tier identity cluster per person — visitor id, email, phone, IP and user-agent, device fingerprint, and click id — so sessions separated by days, browsers or devices resolve to one buyer. The durable visitor id is set as a first-party cookie by the server, not by script, so Safari’s cap on script-written cookies does not shorten it. When a fbclid arrived on the landing URL but the _fbc cookie is missing, the backend reconstructs _fbc in Meta’s format from the URL value.
Matched orders write both a first-touch and a last-touch attribution row, snapshotting UTMs, click ids, referrer, landing page and time to purchase. The Attribution report then shows First Touch, Last Touch and PartialLeads Resolved side by side, each with its own purchase count and channel split — and itemises what the resolved model recovered: iOS ad clicks re-linked to a first touch, sessions with no utm_source that a classifier resolved to a real channel, and revenue from buyers whose session never finished a form. That last bucket is a partial lead that later bought, which a submit-only setup never had a record of in the first place.
Then the conversion goes back out. The same matched purchase fans out server-side to Meta, Pinterest, TikTok and Google Ads, with a deterministic event_id so a retry or a redelivered webhook cannot double-count.

Two honest constraints. Delivery and matching are different promises: because purchases fire from the store’s order webhook, every order in your admin produces a conversion event — you can reconcile that against your order count. Whether the ad platform then resolves that event to a user and a click depends on the identifiers you collected and on the platform’s own graph, which no vendor controls. And server-side events still work better alongside your client-side base tag: _fbc can be rebuilt from fbclid, but _fbp cannot be conjured from nothing. Orders that match nothing still land as unmatched and stay re-matchable later when identity arrives.
| What breaks | The mechanism | Where you see it in the dashboard |
|---|---|---|
| Order lands with no source, shows as Direct | Purchase matched to a session by visitor id, email, phone or IP, in confidence order | Purchases ledger, matched vs unmatched wedge |
| Cookie expired between ad click and purchase | Durable visitor id set as a first-party cookie by the server, not by script | Journey timeline spanning multiple sessions |
| Buyer clicked on mobile, paid on desktop | Six-tier identity cluster joins sessions on email, phone and click id | Journey ribbon on the Leads list |
| First-touch and last-touch reports disagree | Both attribution rows written per matched purchase | Attribution report, three models side by side |
| Revenue that a last-click view loses | Recovered attribution, itemised by mechanism | Recovered Attribution panel |
| Refunds inflate attributed revenue | Refund status-phase rows net against paid | Purchases ledger, net revenue |
| Conversion sent twice by a retried webhook | Deterministic event_id plus a unique dedup table |
CAPI activity log |
| Buyer abandoned the form but bought later | Partial capture on input, matched at purchase | Partial-lead revenue in Recovered Attribution |
| Ad platform stopped optimising after iOS changes | Matched purchase fanned out server-side to Meta, Pinterest, TikTok, Google Ads | CAPI activity log, platform Events Manager |
The pattern underneath all of it: form abandonment and lost attribution are the same failure seen from two ends. Both happen because a person identified themselves at a moment your systems weren’t listening, and both are fixed by listening earlier and joining later — on your server, on identity you already had.
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
https://developers.facebook.com/docs/marketing-api/conversions-api https://developers.facebook.com/docs/marketing-api/conversions-api/deduplicate-pixel-and-server-events https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event https://developers.facebook.com/docs/marketing-api/conversions-api/best-practices