Client + server de-duplication — count every sale exactly once
Send the purchase from the browser and from your backend on purpose. A shared dedup key collapses them into one conversion, confirmed by both sides, with the server's amount as the truth.
if (property, dedup_key) exists → merge and mark source: both · else → insert
Client-only tracking under-counts by whatever ad blockers eat — typically 20–30% of paid traffic. Client plus server without a shared key double-counts everything that got through. The key is what makes running both sides an upgrade instead of a new bug.
A good key exists before the event fires, is identical on both sides, and is unique per fact. Order id qualifies; a timestamp does not.
Most teams that add server-side tracking make their data worse, not better. They add a Conversions API or a webhook alongside the existing pixel, ship it, and every sale that survives the browser gets counted twice.
Revenue looks great. CPA looks incredible. Nothing reconciles with the bank.
Why send it twice at all
Because the two sides answer different questions, and neither is sufficient alone.
The client event knows context. It knows which page, which referrer, which UTM parameters, what the person clicked before, how long they took. It is high volume, cheap, and real-time. It is also the first thing an ad blocker drops.
The server event knows truth. It fires because your payment processor confirmed money moved, or because a user record was actually created. It carries information the browser never had — which action caused an identification, the authoritative amount, the internal customer id. It cannot be blocked, because it never touches the browser.
Give up the client side and you get accurate counts with no idea what produced them. Give up the server side and you get a rich, incomplete story with a hole shaped like every blocked session.
What the key does
Both events carry the same dedup_key. When the second one arrives:
// browser — fast, contextual, blockable
{ "source": "client", "category": "purchase", "name": "purchase",
"value_cents": 12900, "dedup_key": "order_10482" }
// your backend — authoritative, unblockable
{ "source": "server", "category": "purchase", "name": "purchase",
"email": "hello@example.com", "value_cents": 12900,
"dedup_key": "order_10482" }
// result: ONE conversion, source: "both", value from the server
Flowsk looks up (property, dedup_key). If a record exists, it merges — marking the conversion confirmed by both sides, enriching the properties, and keeping the server’s value. If not, it inserts.
The order does not matter. Client-first and server-first produce the same record. A webhook that retries five times produces the same record. That last property means de-duplication doubles as idempotency, which is why you should use it even if you never send a client-side purchase at all.
What you can see afterwards
Because the merge is recorded rather than discarded, every conversion carries a confidence level you can inspect:
source |
Meaning | How much to trust it |
|---|---|---|
both |
Client and server independently reported it | Highest — two systems agree |
server |
Only the backend saw it | High — the money is real, the path may be partial |
client |
Only the browser saw it | Lower — nothing has confirmed the money |
A conversion count with no confidence attached is just a number. This one tells you which of your sales you can defend.
Choosing the key well
Three rules, and every broken implementation violates one of them:
- It must exist before the event fires. You cannot invent a key at report time and hope the two sides agree.
- It must be identical on both sides. Same case, same prefix, same string.
order_10482and10482are two different facts. - It must be unique per fact. One key per order, not one per session and not one per customer.
Timestamps fail rule 2 — the two sides will never agree to the millisecond. Session ids fail rule 3. Email addresses fail rule 3 for purchases (people buy twice) and are fine for identification.
The full guide, with the per-event-type recommendations, is in the de-duplication docs.
Frequently asked questions
What should I use as a dedup key?
A business identifier both sides already know: the order id for purchases, the subscription or invoice id for renewals, the user id for signups, the submission id for leads. It must be derivable independently by the browser and by your backend.
What happens if the two events disagree on the amount?
The server wins. It is the side that talked to the payment processor. The client value is kept on the record for debugging but never used for reporting.
What if my webhook retries?
Nothing happens. The second delivery carries the same key, finds the existing conversion, and is ignored. De-duplication doubles as idempotency for retrying webhooks.
Do I need to send both sides for every event?
No. Behavioural events — pageviews, clicks, submits — are client-only and cheap to lose. Anything with money or identity attached should exist on both sides.
What if I cannot generate the same key on both sides?
Then generate it on one side and pass it to the other. Put it in the cart attributes, in the checkout metadata, or in a hidden field. A key you have to invent at report time is not a key.
Watch the double count happen
Set the real number of orders, set how much the browser loses, and see four common setups report four different numbers.
Stop guessing which ad made the sale.
Flowsk Signals stitches the anonymous click to the email to the purchase — first-party, server-side, de-duplicated. One snippet, $29/mo, and every conversion comes with a receipt you can inspect.