Трекинг конверсий подписки на телеграм-канал с помощью Server-side Google Tag Manager

Говорят что идеи витают в воздухе, поэтому не удивлюсь если у кого-то уже есть схожая реализация, возможно с помощью других инструментов. Как минимум в Яндекс Директе подписка тоже как-то отслеживается через бота, однако подробности их реализации мне неизвестны.

Уже некоторое время я печалюсь о том, что не хватает нативной возможности отслеживания конверсий в телеграме, так как это дало бы возможность перевести закупку подписчиков из области SMM в компетенцию перформанс-специалистов. Отслеживание конверсий предполагает, что у вас есть определенное действие (в нашем случае — подписка на канал) и набор источников трафика, с одним из которых вы можете связать ваше целевое действие. Если бы нам было достаточно простой отчетности, то достаточно было бы связывать конверсию с помощью UTM-меток. Однако это не дает рекламным системам точной информации о том, какой именно клик привел к конверсии, и, следовательно, не позволяет обеспечить адекватную оптимизацию (снижение стоимости клика).

Итак, нам нужна возможность связывать вступление подписчика в канал с конкретным кликом по результатам которого оно произошло. Кроме того, мы хотим иметь возможность закупать рекламу в разных местах, не только в Телеграме (хотя и включая его), но и в контекстных системах, баннерных сетях, cpa-сетях, прямых размещениях и т.д.

Изучая телеграмм я нашел только один способ трекать подписчиков — инвайт-ссылки. Ссылки эти даже показывают какую-то статистику своего перфоманса, но механизма доступа к этой информации нет.

То есть, допустим, если бы мы имели возможность каждому потенциальному подписчику выдать по индивидуальной ссылке, и в дополнение к этому знать

  1. вступил ли человек в канал
  2. какой click_id привел человека к какой ссылке

То мы бы получили информацию в виде:

click_id invite_link_url has_conversion
123t.me\+werttrue
234      t.me\+sdfgfgfalse

Чтож, давайте займемся реализацией.

Начнем с последнего этапа — нам нужно получать информацию о том, какая конкретная ссылка привела к конверсии. У меня уже был опыт разработки ботов для Telegram, и я знал, что Telegram обменивается информацией с ботами в виде обычных json-объектов, которые передаются через веб-хуки в формате событий Update. То есть, если в области видимости вашего бота что-то произошло — телеграмм шлет ему апдейт.

Среди возможных полей этих апдейтов нашлось поле chat_join_request. Оно появляется тогда, когда ваш бот имеет права администратора в канале, и вступление в него происходит по одобрению заявки администратором. В это поле предается другой вложенные объект — ChatJoinRequest. В его полях в свою очередь, помимо информации о вступающем, есть информация о date — временной метке запроса, и (бинго!) invite_link — поле с информацией о ссылке по которой пришел человек подающий заявку. В поле лежит очередной объект ChatInviteLink который содержит детальную инфу по ссылке.

«If the link was created by another chat administrator, then the second part of the link will be replaced with “…”.».

Важное примечание из справки. То есть для того чтобы боту узнать полную ссылку по которой подает заявку человек — надо чтобы ссылка была создана от имени этого же бота.

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

Включаем воображение дальше — предположим мы хотим купить миллиард кликов, которым покажем наше предложение вступить в канал. Не уверен что телеграмм даст нам создать миллиард ссылок. Публичной информации о том, каким количеством ограничено максимально допустимое количество инвайт-ссылок я не нашел, но руководствуясь логикой давайте предположим что оно есть. Получается что миллиард click_id у нас есть, а миллиарда ссылок — нет.

И тут вспоминается опыт такого замечательного способа трекинга конверсий как колл-трекинг — когда количество подменных номеров (которые ты можешь показать на сайте чтобы проассоциировать звонок по этому номеру) ограничено адекватностью и бюджетом, а ожидаемое количество кликов превышает ваше количество номеров.

Столкнувшись с такой проблемой в колл-трекинге люди придумали:

  1. ротацию номеров
  2. временное ограничение на атрибуцию клика к номеру

Отлично, очень похоже на нашу проблему и очень похоже на наше возможное решение. Как узнать сколько же ссылок нам нужно — давайте займемся математикой. Как и в колл-трекинге мы сделаем некий лендинг где у нас будет кнопка с инвайт-ссылкой на вступление в наш канал. На этот лендинг мы и будем заливать трафик.

Что нам нужно заложить в формулу расчёта пропускной способности лендинга? Нужно посчитать сколько проходит времени от нажатия на копку подписки на лендинге, до фактического принятия инвайта. Это и есть то время на которое конкретная ссылка должна быть «закреплена» за тем click_id с которым посетитель попал к нам на лендинг. Оно может немного варьироваться, в зависимости от скорости интернета у посетителя, тормозов софта, времени на принятие решения уже в телеграмме. Можете сами потестировать и посравнивать временные метки на своем трафике, а для примера мы возьмем что это время равно 1 минуте.

Итак, эта цифра даёт нам понимание наших двусторонних ограничений — если вы, предположим, заливаете трафик со скоростью 1000 ВСТУПАЮЩИХ человек в минуту — значит вам нужно 1000 ссылок. Если вы сделали 20 ссылок, значит вы можете себе позволить гнать трафика не больше чем потребуется для того чтобы в минту на кнопку подписки тыкали 20 раз.

Почему мы тут говорим о «вступающих»? Потому что наша задача бронировать ссылку на определенное время не по факту захода на лендинг, а по факту ее реального использования. Иначе мы получим ситуацию что куча потенциального времени атрибуции была забронирована за возможным подписчиком, но лендинг его не конвертировал и по ссылке он не прошёл.

И вот, мы наконец приходим к основе нашего решения по реализации всего этого процесса — Server Side Google Tag Manager.

Слава Яхве — он  умеет делать все что нам потребуется:

  1. отправлять и принимать http-запросы для общения с телеграмм-ом в виде бота
  2. отправлять и принимать http-запросы для общения с лендингом
  3. подключаться к быстрой базе данных в которой мы и будем сохранять все связки ссылок с click_id, а также жонглировать очередностью выдачи ссылок.

Теперь двинемся с начала воронки.

1) К нам на лендинг попал посетитель, у него в адресной строе есть click_id. Нам необходимо поставить на лендинг веб версию Google Tag Manager. В принципе, я бы рекомендовал завести для этой задачи отельный контейнер, в который не нужно напихивать больше тегов чем потребуется непосредственно по задаче. Тогда у нас не будет коннектов к лишним сервисам, лишних cookie утяжеляющих http-запросы и т.д. В этом веб-контейнере вам потребуется создать переменную типа «урл», с указанием имени параметра урла в котором лежит ваш click_id. Далее нужно написать небольшой js код который будет стучаться в наш серверный контейнер, получать ответ и обрабатывать его.

Условный пример логики тега

<script>
    // URL вашего серверного эндпоинта
    var endpointUrl = 'https://your-server-endpoint.your-site.com/path';

    // Дополнительные заголовки, если они необходимы
    var headers = {
        "Content-Type": "application/json",
        // Добавьте другие необходимые заголовки здесь
    };

    // Данные для отправки в запросе, если они необходимы
    var data = {
        // Ваши данные здесь
    };

    // Отправляем запрос на сервер
    fetch(endpointUrl, {
            method: 'POST', // или 'GET', в зависимости от вашей логики на сервере
            headers: headers,
            body: JSON.stringify(data) // комментируем или удаляем эту строку, если тело запроса не нужно
        })
        .then(function (response) {
            return response.json(); // Предполагается, что сервер отправляет ответ в JSON формате
        })
        .then(function (responseData) {
            // Обрабатываем ответ
            if (responseData.redirectUrl) {
                // Редиректим пользователя на полученный URL
                window.location.href = responseData.redirectUrl;
            } else {
                // Обрабатываем ошибку или отсутствие URL для редиректа
                console.error('Redirect URL is missing in the response');
            }
        })
        .catch(function (error) {
            // Обрабатываем возможные ошибки сети
            console.error('Network error:', error);
        });
</script>

Кастомный тег и метод sendPixel тут не подойдут т.к. там нет обработки ответа.

В теге добавляем переменную с click_id, а временную метку клика код должен подставлять сам. Данные тег должен отправлять на эндпоинт клиента в серверном контейнере вашего SSGTM.

2) В серверном контейнере нам надо написать шаблон кастомного клиента. Шаблон должен работать с методом getRequestQueryParameters. Преобразовываем параметры которые мы передавали c запросом в параметры нативного события и делаем claimRequest и runContainer. Это означает что клиент получил реквест, обработал преобразовав в нативное событие, запустил обработку этого события тегами, и по мере обработки вернет какой-то ответ.

3) В серверном контейнере нам надо написать шаблон кастомного тега. Шаблон должен получать из события временную метку его отправки и click_id. Далее нам потребуется три раза обратится к Firestore через встроенные в SSGTM нативные методы.

  • Первым запросом нам надо получить из коллекции инвайт-ссылок ссылку с самой старой датой использования. Делаем это через Firestore.query предварительно создав в Firestore коллекцию со всеми вашими инвайт-ссылками. То есть внутри коллекции должны быть документы с полями url, и какой-нибудь last_use_at например. ID документа следует формировать исходя из содержимого инвайт-ссылки. То есть, зная ссылку вы должны понимать каким должен быть путь для получения документа в котором она содержится.
  • Вторым запросом мы сохраним в другую коллекцию документ с тремя полями — click_id, url инвайт-ссылки, и click_time — временную метку клика по кнопке на лендинге. Это будет наша коллекция для кликов.
  • Третьим запросом мы обновляем данные о использовании нами конкретной ссылки сохранив в первую коллекцию документ по её id и устанавливая там метку времени клика click_time в поле last_use_at. Это перезапишет время последнего использования ссылки и она сместится в очереди ссылок отсортированных по времени.

Далее нам нужно будет воспользоваться методами setPixelResponse и returnResponse Это вернет ответ нашего тега клиенту который принял реквест от веб-контейнера, а клиент вернет ответ на реквест веб-контейнеру.

4) В веб-контейнере получив от сервера в ответе урл инвайт-ссылки — редиректим на него человека кликнувшего по кнопке.

5) Если человек подтверждает что хочет вступить в канал — на бота должен прилететь апдейт. Чтобы принимать апдейты создаем соответствующий клиент в SSGTM. Регистрируем эндпоинт этого клиента как вебхук для бота. Далее, прямо в самом клиенте стучимся в Firestore и по ключу (сформированному на основе того по какой ссылке подал заявку наш юзер) запрашиваем из коллекции с кликами документ в котором указана такая ссылка в url и last_use_at (время клика по кнопке) попадает в наше временное окно. Из этого документа узнаем какой у человека подавшего заявку был click_id. Из данных о заявке нам потребуются chat_id и user_id. Формируем из этих данных нативный евент и делаем claimRequest и runContainer.

6) На триггере из этого эвента запускаем кастомный тег. В его шаблоне надо сделать получение из эвента chat_id и user_id заявки — тег должен от имени бота сделать http-реквест к телеграмму с методом approveChatJoinRequest. То есть, как только клиент в SSGTM будет получать запрос на вступление — этот тег будет сразу же его принимать.

7) На том же триггере создадим другой тег. Возможно тут кастомный шаблон не нужен и у вас уже есть какое-то решение? Этот тег и должен передавать click_id в целевую систему, информируя о том что он привёл к конверсии.

В качестве click_id можно использовать gclid если закупаетесь в гугле, fbclid если закупаетесь в фейсбуке, или вообще завернуть все источники в трекер с единым форматом клика и отслеживать его.

Недостающие мелочи можно дошлифовать вручную:

  • пока несколько миллисекунд кнопка ждет ссылку — покрутить какую-нибудь анимацию
  • ссылки от имени бота сгенерировать вручную, например с помощью jupiter-ноутбука
  • померить разницу между click_id и date
  • и т.п.

Ну в общем-то и всё, конверсии трекаются, cpa может оптимизироваться, вы восхитительны.