Инженерный блог

Почему GA4 врет на 30% при оплате через Kaspi? Чиним атрибуцию транзакций с помощью Server-Side GTM и Cloud Functions

09.08.2026
Шарафутдинов Р.

Главная боль казахстанского e-commerce

Если вы делаете e-commerce в Казахстане, у вас 100% прикручена оплата через Kaspi. Это стандарт де-факто, монополия и просто удобно для юзера. Но для маркетологов и веб-аналитиков Kaspi Pay — это персональный филиал ада на земле.

Представьте картину: вы заливаете бюджет в Google Ads, таргетолог настраивает идеальные кампании, люди кликают, покупают. Вы заходите в Google Analytics 4 (GA4), чтобы посмотреть ROAS (окупаемость рекламы) и выписать таргетологу премию. А там — сюрприз! 30% всех транзакций имеют источник Direct / (none) или, что еще смешнее, Referral / pay.kaspi.kz. Google Ads показывает, что он принес 3 копейки, хотя в реальности продажи идут именно оттуда.

Таргетолог плачет, маркетолог пьет валерьянку, бизнес режет косты на рекламу (которая на самом деле работала). Почему так происходит? И главное — как это починить, не переписывая половину бэкенда?

Анатомия разрыва сессии: Куда пропадает атрибуция?

Давайте по шагам разберем типичный User Journey (путь пользователя) при покупке с мобильного телефона:

  1. Пользователь видит рекламу в Google (у него есть gclid и рекламный источник). Кликает, переходит на сайт. В GA4 стартует сессия, фиксируется client_id (уникальный идентификатор браузера) и session_id.
  2. Юзер кладет товар в корзину, заполняет данные доставки и нажимает «Оплатить через Kaspi».
  3. Точка невозврата: Сайт редиректит пользователя в мобильное приложение Kaspi. Браузер сворачивается (или закрывается).
  4. Пользователь подтверждает оплату Face ID, видит зеленую галочку в приложении Kaspi.
  5. А дальше развилка:
    • Сценарий А (Оптимистичный, 70% случаев): Юзер нажимает «Вернуться в магазин». Приложение Kaspi открывает браузер, юзер попадает на страницу /checkout/success. Но браузер может открыть новую вкладку, или контекст теряется, и GA4 засчитывает этот переход как новую сессию с источником pay.kaspi.kz (реферал). Да, можно добавить kaspi.kz в Referral Exclusion List, но это спасает не всегда.
    • Сценарий Б (Пессимистичный, 30% случаев): Юзер просто смахивает приложение Kaspi и кладет телефон в карман. Товар-то уже куплен, зачем возвращаться на сайт? В итоге транзакция есть в вашей базе данных, деньги на счету, а в GA4 события purchase просто нет.

Именно из-за Сценария Б ваша аналитика врет на треть. Вы теряете самые горячие лиды и убиваете алгоритмы машинного обучения Google Ads, потому что они не получают сигналы об успешных конверсиях.

Костыли, которые не работают

Обычно «сеньоры» из диджитал-агентств предлагают два решения:

  • Measurement Protocol (MP) напрямую с бэкенда. Вроде логично: после успешной оплаты бэкенд шлет POST-запрос в GA4. Но проблема в том, что бэкенд обычно не знает client_id и session_id. В итоге он шлет транзакцию от «нового» пользователя, и она падает в Direct.
  • Webhooks + Zapier/Make. Дорого, медленно и не секьюрно. Передавать данные о транзакциях (с суммами и товарами) через сторонние no-code сервисы — так себе идея для энтерпрайза.

Идеальная архитектура: Webhooks + Cloud Functions + sGTM

Чтобы раз и навсегда решить эту проблему, нам нужно построить асинхронный пайплайн, который не зависит от того, вернулся пользователь на сайт или нет. Транзакция должна отправляться в GA4 сервером в момент фактического поступления денег (по вебхуку от Kaspi).

Архитектура выглядит так:

  1. Фронтенд (Создание заказа): Когда юзер жмет «Оформить заказ», фронтенд отправляет на бэкенд не только товары, но и куки аналитики: _ga (содержит client_id) и session_id. Бэкенд сохраняет их в таблице заказа (Orders) в базе данных.
  2. Бэкенд (Ожидание): Бэкенд создает заказ со статусом pending и отдает ссылку на оплату.
  3. Kaspi (Оплата): Юзер платит в приложении. Kaspi отправляет защищенный HTTP Webhook на наш сервер: «Заказ #12345 успешно оплачен».
  4. Cloud Function (Обогащение): Чтобы не грузить основной монолит бэкенда аналитикой, мы поднимаем микросервис на Google Cloud Functions. Он принимает вебхук, лезет в БД, находит заказ #12345, достает оттуда client_id и session_id, формирует JSON с данными о товарах.
  5. Server-Side GTM (Отправка): Cloud Function отправляет этот JSON в наш серверный Google Tag Manager (sGTM). sGTM уже заботливо формирует правильный запрос Measurement Protocol и пушит его в GA4.

Шаг 1: Захват Client ID и Session ID на фронтенде

Без этих двух параметров GA4 не сможет склеить транзакцию с кликом по рекламе. Достаем их из куки и localStorage (в зависимости от настроек GA4) перед отправкой заказа на бэкенд.

// Функция для извлечения client_id из куки _ga
function getGAClientId() {
  const match = document.cookie.match(/(?:^|;)s*_ga=([^;]*)/);
  if (match) {
    // Кука выглядит так: GA1.1.123456789.1600000000
    // Нам нужны последние две части
    const parts = match[1].split('.');
    return parts.length === 4 ? `${parts[2]}.${parts[3]}` : null;
  }
  return null;
}

// Отправляем заказ на бэкенд
async function createOrder(cartItems) {
  const payload = {
    items: cartItems,
    ga_client_id: getGAClientId(),
    ga_session_id: sessionStorage.getItem('ga_session_id') // Или достаем из gtag('get', ...)
  };
  
  await api.post('/orders', payload);
}

Совет: Если вы используете свежий gtag.js, надежнее всего получать параметры через асинхронный метод gtag('get', 'G-XXXXXXX', 'client_id', (id) => {...}).

Шаг 2: Google Cloud Function для обработки вебхука

Мы используем Serverless-функцию. Она масштабируется в ноль, стоит копейки и не падает, если Kaspi решит прислать нам 1000 вебхуков в секунду (такое бывает после сбоев).

// index.js для Google Cloud Functions (Node.js)
const fetch = require('node-fetch');
const { Firestore } = require('@google-cloud/firestore');
const db = new Firestore();

const SGTM_URL = 'https://sgtm.yourdomain.kz/kaspi-webhook'; // URL вашего Server-Side GTM

exports.handleKaspiWebhook = async (req, res) => {
  try {
    // 1. Проверяем подпись вебхука от Kaspi (Security First!)
    if (!verifyKaspiSignature(req)) {
      return res.status(403).send('Forbidden');
    }

    const { order_id, status, amount } = req.body;

    // 2. Обрабатываем только успешные оплаты
    if (status !== 'PAID') {
      return res.status(200).send('OK');
    }

    // 3. Достаем заказ из базы данных
    const orderDoc = await db.collection('orders').doc(order_id).get();
    if (!orderDoc.exists) return res.status(404).send('Order not found');
    
    const orderData = orderDoc.data();

    // 4. Защита от дублей (чтобы не отправить purchase дважды)
    if (orderData.ga_purchase_sent) {
       return res.status(200).send('Already processed');
    }

    // 5. Формируем Payload для sGTM
    const sgtmPayload = {
      event_name: 'purchase',
      client_id: orderData.ga_client_id,
      session_id: orderData.ga_session_id,
      transaction_id: order_id,
      value: amount,
      currency: 'KZT',
      items: orderData.items // Массив товаров по стандарту GA4 e-commerce
    };

    // 6. Отправляем в sGTM
    await fetch(SGTM_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(sgtmPayload)
    });

    // 7. Помечаем заказ, чтобы избежать дублей
    await db.collection('orders').doc(order_id).update({ ga_purchase_sent: true });

    res.status(200).send('Success');
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(500).send('Internal Error');
  }
};

Шаг 3: Магия в Server-Side GTM

Почему мы не шлем запрос в GA4 напрямую из Cloud Function? Потому что Measurement Protocol в GA4 — та еще штука. Там постоянно меняются требования к заголовкам (User-Agent, IP-адрес), плюс sGTM позволяет нам из одной точки рассылать эту транзакцию не только в GA4, но и в Facebook CAPI, TikTok API, Яндекс.Метрику и т.д.

В sGTM мы создаем кастомный Клиент (Client), который слушает эндпоинт /kaspi-webhook. Он парсит входящий JSON и генерирует Event Data Object.

Далее мы настраиваем стандартный тег GA4 (Google Analytics 4), который триггерится на событие purchase, созданное нашим кастомным клиентом. Тег сам правильно сформирует HTTP-запрос к серверам Google, добавит нужные секретные ключи (API Secret) и прокинет client_id.

Распределение источников транзакций (До и После)

Как изменилась атрибуция после внедрения Server-Side GTM.

Результаты: Воскрешение ROAS

Что произошло после деплоя этой архитектуры?

  • Конверсии выросли на 28% (на самом деле они не выросли, просто мы перестали их терять).
  • Доля Direct трафика упала. Транзакции, которые раньше падали с неба, теперь корректно приклеиваются к кликам из Google Ads (CPC), органики (Organic Search) и соцсетей.
  • Обучение рекламных кампаний взлетело. Google Ads начал получать в 1.5 раза больше сигналов о конверсиях. Алгоритмы Target ROAS и Maximize Conversions наконец-то поняли, какие аудитории реально приносят деньги, а не просто кликают. CPO (Cost Per Order) снизился на 15% за две недели.

Обучение алгоритмов Google Ads

Динамика CPO (Cost Per Order) в тенге по неделям.

Резюме для инженеров и маркетологов

Не пытайтесь решить системные проблемы аналитики костылями на фронтенде. Если в вашем флоу есть редирект в стороннее приложение (Kaspi, банк-клиент, платежный шлюз) — фронтенд-аналитика всегда будет терять данные.

Единственный надежный источник истины о деньгах — это ваш бэкенд и база данных. Связка Cloud Functions + Server-Side GTM дает вам Enterprise-уровень аналитики. Вы контролируете каждый байт данных, не зависите от AdBlocker-ов, браузерных ограничений (ITP от Apple) и закрытых вкладок.

Собирайте client_id, любите серверную аналитику, и пусть ваш ROAS всегда будет зеленым!

Рустам Шарафутдинов

Рустам Шарафутдинов

Автор инженерного блога

Эксперт в области архитектуры Google Cloud и Senior Full-Stack разработчик с более чем 15-летним опытом. Специализируется на отказоустойчивых архитектурах, оптимизации высоконагруженных проектов и интеграции AI (Vertex AI).

Экспертность: GCP, Kubernetes, Микросервисы, React, Node.js

Комментарии (0)