Server-side tracking 17 czerwca 2026 13 min czytania

Deduplikacja zdarzeń server-side bez order_id - jak spiąć add to cart w przeglądarce i na serwerze

Purchase dedupuje się po order_id. Ale jak zdeduplikować ViewContent czy AddToCart, które nie mają numeru zamówienia? Wspólny event_id generowany w przeglądarce i przenoszony na serwer przez GA4.

Deduplikacja Purchase jest banalna. Numer zamówienia jest i w przeglądarce, i na serwerze, więc robisz z niego wspólny event_id po obu stronach, Meta widzi jeden zakup zamiast dwóch i temat zamknięty. Problem zaczyna się wyżej w lejku.

ViewContent, AddToCart i InitiateCheckout nie mają żadnego numeru zamówienia. Klient nie kupił, nie ma transakcji, nie ma naturalnego klucza, po którym dałoby się powiązać zdarzenie z przeglądarki z tym samym zdarzeniem z serwera. A chcesz je mieć w obu kanałach - i w pikselu, i w Conversions API - żeby funnel był odporny na adblock i ITP, ale bez liczenia podwójnie.

W tym artykule pokazuję mechanizm, który to rozwiązuje: jeden event_id generowany raz w przeglądarce, identyczny dla piksela i dla tagu GA4, przeniesiony na serwer parametrem zdarzenia GA4 i podany do tagu CAPI jako Event ID. Brzmi prosto, ale po drodze są dwie pułapki, które cicho rozsypują dedup, a w Events Managerze nic nie krzyczy.

Cały przykład jest oparty na realnym wdrożeniu dla sklepu modowego na Shoper Premium RWD z budżetem rzędu kilkuset złotych dziennie na Meta. Wartości zanonimizowane, mechanizm dokładnie taki, jak poszedł na produkcję.

Po co w ogóle funnel server-side

Server-side dla Purchase rozumie każdy, kto patrzył na ROAS. Ale funnel (ViewContent, AddToCart, InitiateCheckout) też warto dublować server-side, z dwóch powodów.

Po pierwsze, odporność na blokowanie. Sam piksel w przeglądarce gubi 40-50% zdarzeń przez adblockery, Safari ITP i iOS ATT. Ile dokładnie tracisz i dlaczego, rozpisałem w artykule o adblocku, iOS i ITP. Konwersja Purchase to wierzchołek lejka, ale optymalizacja Meta karmi się też zdarzeniami pośrednimi, a te giną tak samo.

Po drugie, bogatszy sygnał do optymalizacji. Smart kampanie uczą się na całym lejku, nie tylko na zakupie. Im więcej AddToCart i InitiateCheckout dociera do Meta w komplecie, tym precyzyjniej algorytm buduje grupy podobnych odbiorców. Bazą jest tu poprawnie wdrożone Conversions API bez wtyczek - dedup funnela ma sens dopiero, gdy CAPI dla zakupu już działa.

Czemu Purchase dedupuje się łatwo, a funnel nie

Deduplikacja w Meta działa na zasadzie: dwa zdarzenia o tym samym event_name i tym samym event_id, jedno z przeglądarki i jedno z serwera, są traktowane jako jedno. Meta zachowuje to z bogatszym zestawem parametrów i odrzuca duplikat.

Klucz do tego mechanizmu to event_id, który musi być identyczny po obu stronach. Dla Purchase masz go za darmo: numer zamówienia jest w dataLayer w przeglądarce i jest w danych zamówienia na serwerze. Wystarczy zbudować event_id deterministycznie z tej samej wartości w obu miejscach, na przykład purchase_ plus numer zamówienia, i masz pewność, że wyjdzie ten sam ciąg. Cały wachlarz podejść do zakupu opisałem w trzech sposobach na deduplikację purchase na Shoper.

Funnel nie ma takiego punktu zaczepienia. ViewContent na karcie produktu nie ma numeru zamówienia, bo zamówienia jeszcze nie ma. AddToCart też nie. Nie ma żadnej stabilnej wartości biznesowej, którą oba kanały widzą i z której mógłbyś deterministycznie wyliczyć identyczny event_id. Trzeba go wygenerować, a generowanie to dokładnie ten moment, w którym dedup najłatwiej rozsypać.

Pomysł: jeden event_id na zdarzenie, identyczny w przeglądarce i na serwerze

Skoro nie ma naturalnego klucza, robimy klucz syntetyczny. Generujemy losowy event_id raz, w momencie wystąpienia zdarzenia w przeglądarce, i ten sam ciąg podajemy w dwóch miejscach:

  • w pikselu, jako eventID przy fbq('track', ...),
  • w tagu GA4, jako parametr event_id zdarzenia.

Piksel strzela natychmiast z przeglądarki. Tag GA4 leci do serwera tagowania, gdzie GA4 client wystawia całe zdarzenie wraz z parametrem event_id, a tag CAPI odczytuje tę wartość i podaje ją jako Event ID do Meta. W efekcie ten sam losowy ciąg trafia do Meta dwiema drogami: z przeglądarki przez piksel i z serwera przez CAPI. Meta widzi dwa razy to samo event_id przy tym samym event_name, dedupuje i liczy raz.

Warunek konieczny: ten ciąg musi być wygenerowany dokładnie raz na zdarzenie. Nie raz dla piksela i osobno dla GA4. Dokładnie raz, a potem odczytany dwukrotnie z tym samym wynikiem. I tu zaczyna się pierwsza pułapka.

Generowanie wspólnego id i czemu zwykła zmienna losowa zawodzi

Naturalny odruch to zmienna typu Custom JavaScript w GTM, która zwraca losowy ciąg. Coś w stylu return 'evt_' + Math.random(). To nie zadziała.

Zmienne GTM nie są memoizowane. Każde odwołanie do zmiennej w tagu uruchamia jej funkcję od nowa. Piksel czyta zmienną i dostaje jedną wartość. Tag GA4 czyta tę samą zmienną i wywołuje funkcję jeszcze raz, dostając inny losowy ciąg. Wynik: piksel wysyła event_id A, GA4 wysyła event_id B, a Meta dostaje dwa różne identyfikatory dla tego samego AddToCart i liczy go podwójnie. Dedup po cichu nie działa, a w pierwszych dniach nawet wygląda dobrze, bo widzisz zdarzenia z obu źródeł.

Rozwiązaniem jest memoizacja w obrębie jednego zdarzenia. Trzymamy wygenerowane id w obiekcie na window, kluczowanym tak, żeby było stałe w obrębie jednego dataLayer.push, a różne między różnymi zdarzeniami. Idealnym kluczem jest gtm.uniqueEventId - liczba, którą GTM nadaje każdemu wpisowi do dataLayer. Jest stała przez cały czas przetwarzania jednego pushu (więc piksel i GA4 odczytają tę samą wartość), a inna dla kolejnego add_to_cart czy view_item.

// Custom JS variable: wspolny event_id na zdarzenie funnel
// memoizacja po gtm.uniqueEventId -> stale w obrebie jednego zdarzenia
function() {
  var k = 'f' + ({{dlv.gtm.uniqueEventId}} || '0');
  var w = window;
  w.__feid = w.__feid || {};
  if (!w.__feid[k]) {
    var t = (new Date()).getTime().toString(36);
    var r = Math.random().toString(36).slice(2, 8);
    w.__feid[k] = 'f_' + t + '_' + r;
  }
  return w.__feid[k];
}

Pierwsze wywołanie dla danego gtm.uniqueEventId generuje ciąg i zapisuje go w window.__feid. Każde kolejne wywołanie w obrębie tego samego zdarzenia trafia w cache i zwraca tę samą wartość. We wdrożeniu dla sklepu modowego sprawdziłem to wprost w Tag Assistant: tag piksela i tag GA4 dla tego samego add_to_cart raportowały identyczny event_id, a dla następnego zdarzenia w lejku id było już inne. Dokładnie to, czego potrzebuje dedup.

Transport id z przeglądarki na serwer: parametr GA4

Mając stabilne id w przeglądarce, trzeba je przenieść na serwer. Piksel wysyła je od razu jako eventID. GA4 jest nośnikiem dla strony serwerowej.

Konfiguracja w trzech punktach:

  1. Piksel: w tagu Meta Pixel ustaw eventID na zmienną ze wspólnym id (tę z Custom JS wyżej).
  2. Tag GA4 (web): dodaj parametr zdarzenia event_id o tej samej wartości. GA4 prześle go w hicie do serwera tagowania.
  3. Serwer: GA4 client odbiera hit i wystawia event_id w danych zdarzenia. Zakładasz zmienną Event Data o keyPath event_id i podajesz ją jako Event ID w tagu CAPI.

Krótkie mapowanie, jak parametr wędruje:

WarstwaGdzie siedzi id
Piksel (przeglądarka)parametr eventID przy fbq('track', ...)
Tag GA4 (przeglądarka)parametr zdarzenia event_id
GA4 client (serwer)pole event_id w danych zdarzenia
Tag CAPI (serwer)Event ID = zmienna Event Data (keyPath event_id)

Do tego, żeby match w ogóle miał sens, tag CAPI musi nieść też fbp (Facebook Browser ID z cookie _fbp), adres IP klienta i user agent. We wdrożeniu te trzy parametry plus event_id wystarczyły, żeby funnel raportował się jako połączenie “Wiele” (browser plus server) w Events Managerze. O tym, ile realnie podnosi match na zdarzeniach przed-PII, napiszę osobno, bo to temat na własny artykuł.

Pułapka: Inherit from client wysyła add_to_cart zamiast AddToCart

Druga pułapka jest w nazewnictwie zdarzeń i potrafi zjeść cały dedup mimo poprawnego event_id.

GA4 nazywa zdarzenia w snake_case: view_item, add_to_cart, begin_checkout. Meta oczekuje standardowych nazw w PascalCase: ViewContent, AddToCart, InitiateCheckout. Jeśli w tagu CAPI ustawisz Event Name na “Inherit from client”, to na serwer pójdzie add_to_cart, a Meta potraktuje to jako custom event o nazwie add_to_cart. Piksel w tym samym czasie wysyła standardowe AddToCart. To są dla Meta dwa różne zdarzenia o różnych nazwach, więc nie dedupują się, choćby event_id był idealnie spójny.

Fix: w tagu CAPI ustaw Event Name na Override i zmapuj ręcznie:

GA4 (client)Meta (CAPI, Override)
view_itemViewContent
add_to_cartAddToCart
begin_checkoutInitiateCheckout
purchasePurchase

Dopiero z poprawną nazwą po stronie serwera Meta zestawia zdarzenie piksela ze zdarzeniem CAPI i dedup zaskakuje.

Weryfikacja, że dedup naprawdę działa

Najgorsze w tym mechanizmie jest to, że gdy event_id się rozjeżdża, nic nie krzyczy. Widzisz zdarzenia z obu źródeł, ROAS wygląda wręcz lepiej, bo wszystko liczy się podwójnie. Dlatego po wdrożeniu sprawdź dwie rzeczy.

W Events Managerze, w widoku zdarzeń, połączenie powinno pokazywać “Wiele” lub “Received from Browser and Server” dla ViewContent, AddToCart i InitiateCheckout. Jeśli widzisz dwa osobne wpisy z tą samą nazwą, dedup nie zaskakuje.

Po stronie serwerowej zajrzyj w body requestu lecącego do graph.facebook.com. Tag CAPI po udanym wywołaniu zwraca odpowiedź z events_received: 1, a w wysłanym payloadzie event_id musi być obecny i musi być tą samą wartością, którą piksel podał jako eventID. We wdrożeniu dla sklepu modowego potwierdziłem to wprost: POST do Meta, status 200, events_received: 1, event_id zgodny z pikselem, połączenie “Wiele”.

Po stronie przeglądarki narzędzia do podglądu dataLayer, tagów i wysyłanych parametrów opisałem w przewodniku po debugowaniu tagów GTM dla e-commerce - to tam sprawdzisz, czy piksel i GA4 faktycznie niosą identyczny event_id zanim cokolwiek dotrze do serwera.

Czego nie ruszać: Purchase ma swój mechanizm

Pokusa, żeby ujednolicić całość i wpiąć Purchase w ten sam syntetyczny event_id, jest duża. Nie rób tego.

Purchase ma deterministyczny klucz w postaci numeru zamówienia. Zbudowany z niego event_id jest stabilny nawet wtedy, gdy zdarzenie poleci dwa razy z różnych sesji albo gdy przeglądarka odpali piksel z opóźnieniem, a serwer już wysłał konwersję z backendu. Syntetyczne id memoizowane po gtm.uniqueEventId żyje tylko w obrębie jednego dataLayer.push w jednej karcie - dla zakupu, gdzie zależy ci na deduplikacji między niezależnymi ścieżkami, to za słaby klucz.

Trzymaj dwie ścieżki rozdzielone: Purchase po numerze zamówienia, funnel po syntetycznym id memoizowanym po gtm.uniqueEventId. Wpinanie zakupu w tę drugą ścieżkę to proszenie się o duplikaty tam, gdzie najbardziej bolą, czyli na konwersji, na którą Meta optymalizuje budżet.

Podsumowanie

Deduplikacja funnela server-side bez numeru zamówienia sprowadza się do trzech rzeczy zrobionych dokładnie. Generujesz event_id raz na zdarzenie i memoizujesz go po gtm.uniqueEventId, żeby piksel i tag GA4 odczytały identyczną wartość. Przenosisz to id na serwer parametrem GA4 i podajesz tagowi CAPI jako Event ID, dokładając fbp, IP i user agent dla match. I ustawiasz nazwy zdarzeń przez Override, mapując add_to_cart na AddToCart, bo “Inherit from client” wyśle nazwę w snake_case i Meta nie zdedupuje, mimo poprawnego id.

Dwie pułapki, które cicho rozsypują cały setup, to brak memoizacji (zmienna losowa zwraca inną wartość przy każdym odczycie) oraz dziedziczenie nazwy zdarzenia po kliencie. Obie wyglądają dobrze przez pierwsze dni i obie liczą zdarzenia podwójnie, więc po wdrożeniu zawsze potwierdź połączenie “Wiele” w Events Managerze i zgodność event_id w body requestu do Meta.

Tak ułożony funnel daje Smart kampaniom pełny, niezdublowany obraz lejka, odporny na adblock i ITP. Jeśli chcesz mieć to wdrożone i zweryfikowane na własnym sklepie, zaczynam zawsze od czystego pomiaru w warstwie motywu Shoper Storefront - szczegóły na stronie Tracking Shoper.

Porozmawiajmy o Twoim trackingu

30 minut bez zobowiązań. Bez sesji sprzedażowej. Powiem czy mogę pomóc i co realnie da się odzyskać.