Перейти к содержанию

Нулевой апрув в Keitaro: как ловить ботом в Telegram

Нулевой апрув: что это и как ловить его в Keitaro автоматически. S2S postback в Telegram, скрипт на PHP с порогом выплаты и отчёт по конверсиям через Admin API…

Нулевой апрув в Keitaro: как ловить ботом в Telegram

Нулевой апрув в арбитраже: партнёрка подтвердила лид, но выплата по нему 0. В Keitaro такая конверсия приходит со статусом «Продажа» и доходом 0, и среди обычных апрувов её легко пропустить. Искать вручную по всем кампаниям не нужно: Keitaro может сам отправить S2S postback небольшому скрипту, а скрипт напишет в Telegram, если сумма ниже порога.

Ниже наша история, три способа автоматизации и рабочий код на PHP: обработчик S2S postback и вариант с отчётом по конверсиям через Keitaro Admin API.

Что такое нулевой апрув и откуда он берётся

Апрув в арбитраже это лид, который рекламодатель подтвердил и за который партнёрка платит. Как считать процент апрува и чем он отличается от холда, мы разбирали в статье про ROI, CR, EPC, апрув и холд. Нулевой апрув выглядит как обычный: статус подтверждён, лид засчитан. Только в параметре payout партнёрка передаёт 0.

У нас это случилось примерно через неделю после запуска новой ПП. Лили около 300 лидов в день, и в какой-то момент баер заметил апрув с нулевой суммой. В логах Keitaro постбек действительно пришёл с payout=0. Поддержка ПП объяснила: если IP или телефон не соответствуют ГЕО оффера, система ставит апрув с нулевой выплатой. Тот апрув нам в итоге оплатили, но ситуация повторялась почти каждый день.

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

Если причина в номерах, часть проблемы решается ещё на форме: маска для поля с номером телефона в Keitaro и проверка номеров по API до отправки лида в ПП.

Где нулевой апрув виден в Keitaro

Партнёрка сообщает о конверсии постбеком. Обязательные параметры: subid и status, сумма выплаты передаётся в payout (документация по postback). Что из этого важно для нулевого апрува:

  • Если постбек пришёл с тем же subid, но с другими параметрами, Keitaro перезаписывает прежнюю конверсию. Лид с выплатой 10 после апрува с payout=0 превратится в продажу с доходом 0.
  • Оригинальный статус от партнёрки сохраняется в колонке «Статус» лога конверсий.
  • Если постбек повторился один в один, трекер его обработает, но S2S postback второй раз не отправит.
  • В Keitaro 11 тип конверсии Rebill удалён, каждое событие Sale учитывается отдельно (заметки к релизу 11).

Вручную нулевые апрувы ищут в логе конверсий: отбирают продажи и смотрят на доход. Это работает, пока кампаний мало. Дальше нужна автоматика.

Три способа ловить нулевой апрув автоматически

СпособЧто нужноПлюсыМинусы
S2S postback прямо в TelegramТолько ботБез сервера и кодаПриходят все продажи, порога по сумме нет
S2S postback в скриптХостинг с PHPСообщение сразу и только о маленьких выплатахСкрипту нужен публичный адрес
Отчёт по конверсиям через Admin APIТариф Expert и выше, PHPВидит и старые конверсии, публичный адрес не нуженЗадержка до следующего запуска по cron
Таблица прокручивается вбок

Способ 1: S2S postback прямо в Telegram

Самый простой вариант описан в документации Keitaro по S2S postback. В настройках кампании на вкладке «S2S Postbacks» добавьте адрес и отметьте статус «Продажа»:

https://api.telegram.org/botТОКЕН/sendMessage?chat_id=ID_ЧАТА&text={campaign_name}:{status}:{conversion_revenue}

Минус один: сравнивать сумму с порогом S2S не умеет, поэтому в чат придёт каждая продажа. Если продаж много, нулевые апрувы утонут в потоке сообщений.

Способ 2: S2S postback в скрипт с порогом

Шаг 1. Бот и ID чата

  1. Создайте бота у @BotFather командой /newbot и сохраните токен.
  2. Добавьте бота в чат с баерами и напишите туда любое сообщение.
  3. Откройте https://api.telegram.org/botТОКЕН/getUpdates и возьмите значение chat.id. У групп оно отрицательное.

Шаг 2. Ссылка S2S postback

Постбек можно добавить в кампанию (вкладка «S2S Postbacks», кнопка «Добавить», метод GET, статус «Продажа») или в источник трафика, в поле «S2S postback» с отметкой «Только со статусами». Мы вешали его на источник и сохраняли источник с обновлением во всех кампаниях, чтобы не настраивать каждую отдельно.

https://ВАШ_ДОМЕН/zero-approve.php?key=СЕКРЕТ&status={status}&revenue={conversion_revenue}&campaign={campaign_name}&offer_id={offer_id}&country={country}&adset={sub_id_3}&ad={sub_id_4}&subid={subid}

Все макросы взяты из списка S2S-макросов Keitaro. Значения в них по умолчанию экранируются, поэтому пробелы и спецсимволы в названии кампании ссылку не ломают.

В прошлой версии статьи в этой ссылке стояли {{adset.name}} и {{ad.name}}. Это макросы Facebook: их подставляет Meta в ссылку объявления, а Keitaro их не знает и в постбек не подставит. Данные объявления в постбеке берутся из sub_id. В нашем шаблоне источника, который виден на скриншоте ниже, ID адсета приходит в sub_id_3, ID объявления в sub_id_4. Если нужны названия, добавьте в параметры источника, например, sub_id_5 со значением {{adset.name}} и передавайте в постбек {sub_id_5}.

На скриншоте отмечены статусы «Продажа» и «Допродажа». В Keitaro 11 допродажи как отдельного типа больше нет, отмечайте только «Продажа».

Шаг 3. Скрипт

Положите файл на любой хостинг с PHP 8 и расширением curl, например на поддомен. Впишите секрет, токен бота, ID чата и порог.

<?php
// zero-approve.php: принимает S2S postback из Keitaro и пишет в Telegram,
// если продажа пришла с выплатой ниже порога.

declare(strict_types=1);

const SECRET    = 'ЗАМЕНИТЕ_НА_ДЛИННУЮ_СЛУЧАЙНУЮ_СТРОКУ'; // то же значение, что в key= ссылки S2S
const TG_TOKEN  = '123456789:ТОКЕН_ОТ_BOTFATHER';
const TG_CHAT   = '-1001234567890';                       // ID чата или группы
const THRESHOLD = 4.0;                                    // выплаты ниже порога считаем подозрительными

ini_set('display_errors', '0');
header('Content-Type: text/plain; charset=utf-8');

if (!hash_equals(SECRET, (string) ($_GET['key'] ?? ''))) {
    http_response_code(403);
    exit('forbidden');
}

$get = static fn(string $name): string => trim((string) ($_GET[$name] ?? ''));
$esc = static fn(string $value): string => htmlspecialchars($value, ENT_NOQUOTES, 'UTF-8');

$revenue = $get('revenue');
$subid   = $get('subid');

if ($subid === '' || !is_numeric($revenue)) {
    exit('skip: нет subid или суммы');
}
if ((float) $revenue >= THRESHOLD) {
    exit('ok');
}

$text = implode("\n", [
    '<b>💸 Маленький, но апрув!</b>',
    '',
    '<b>Кампания:</b> ' . $esc($get('campaign')),
    '<b>Адсет и объявление:</b> ' . $esc($get('adset')) . ' / ' . $esc($get('ad')),
    '<b>ID оффера:</b> ' . $esc($get('offer_id')),
    '<b>ГЕО:</b> ' . $esc($get('country')),
    '<b>Статус:</b> ' . $esc($get('status')),
    '<b>Сумма:</b> ' . $esc($revenue),
    '<b>SubID:</b> <code>' . $esc($subid) . '</code>',
]);

$ch = curl_init('https://api.telegram.org/bot' . TG_TOKEN . '/sendMessage');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query(['chat_id' => TG_CHAT, 'text' => $text, 'parse_mode' => 'HTML']),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CONNECTTIMEOUT => 5,
    CURLOPT_TIMEOUT        => 10,
]);
$answer = json_decode((string) curl_exec($ch), true);

if (empty($answer['ok'])) {
    error_log('zero-approve: Telegram не принял сообщение: ' . ($answer['description'] ?? curl_error($ch)));
    exit('telegram error');
}
echo 'sent';

Что исправлено по сравнению с прошлой версией кода:

  • Адрес Telegram. В старом коде токен стоял сразу после домена, без слова bot. По документации Bot API адрес выглядит так: https://api.telegram.org/bot<token>/METHOD_NAME. Мы проверили: запрос без bot получает ответ 404 Not Found, то есть сообщение не уходило бы вообще.
  • Экранирование. В режиме parse_mode HTML символы <, > и & вне тегов нужно заменять на сущности. Название кампании с амперсандом или угловой скобкой могло сломать сообщение, теперь все значения проходят через htmlspecialchars.
  • Секретный ключ. Без него любой, кто узнал адрес скрипта, мог бы слать сообщения в ваш чат.
  • Пустая сумма. Если макрос не подставился, старый код превращал пустую строку в 0 и поднимал ложную тревогу. Теперь такой запрос пропускается.
  • Ошибки не выводятся наружу. В старом коде был включён display_errors, а функция curl_close с PHP 8.5 считается устаревшей и выдала бы предупреждение прямо в ответ (php.net). Вызов убран: начиная с PHP 8.0 он ничего не делает.
  • Ответ Telegram проверяется, а ошибка пишется в лог сервера.

Мы прогнали скрипт локально на PHP 8.2 с подменой адреса Telegram на тестовый сервер: неверный ключ получает 403, выплата выше порога и пустая сумма пропускаются, выплаты 0 и 3.5 уходят в чат, название кампании со знаками < и & доходит без ошибки.

Шаг 4. Проверка

  1. Откройте в браузере https://ВАШ_ДОМЕН/zero-approve.php?key=СЕКРЕТ&revenue=0&subid=test&campaign=Test. Скрипт ответит sent, а в чат придёт сообщение.
  2. Тот же адрес с неверным ключом должен вернуть forbidden.
  3. После первых реальных продаж загляните в «Обслуживание → Логи → S2S postbacks»: там видно, на какой адрес ушла конверсия и что ответил скрипт.

Способ 3: отчёт по конверсиям через Keitaro Admin API

Если поднимать публичный обработчик не хочется, можно раз в несколько минут спрашивать у трекера сами конверсии. В Keitaro Admin API для этого есть метод POST /admin_api/v1/conversions/log: в теле передаются период, колонки, фильтры и сортировка, ключ идёт в заголовке Api-Key. Колонки sub_id, postback_datetime, status, revenue, campaign, offer и country есть в списке полей документации.

Admin API доступен на тарифах Expert, Team и Enterprise. Ключ создаётся в разделе «Пользователи» кнопкой «Править» или в «Профиль → API ключи».

<?php
// zero-approve-cron.php: забирает из Keitaro продажи за последние дни
// через Admin API и присылает в Telegram те, где выплата ниже порога.
// Запуск по cron, например раз в 10 минут: php zero-approve-cron.php

declare(strict_types=1);

const KEITARO_URL = 'https://ВАШ_ТРЕКЕР';   // адрес, где открывается админка Keitaro
const KEITARO_KEY = 'API_KEY';              // Профиль → API ключи
const KEITARO_SSL = true;                   // не отключайте проверку при передаче Admin API-ключа
const TIMEZONE    = 'Europe/Moscow';
const TG_TOKEN    = '';                     // пусто = печать в консоль вместо Telegram
const TG_CHAT     = '';
const THRESHOLD   = 4.0;
const DAYS_BACK   = 14;   // Keitaro датирует конверсию первым постбеком, апрув приходит позже
const STATE_FILE  = __DIR__ . '/zero-approve-seen.json'; // уже отправленные конверсии

function keitaro(string $path, array $body): array
{
    $ch = curl_init(rtrim(KEITARO_URL, '/') . '/admin_api/v1' . $path);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode($body),
        CURLOPT_HTTPHEADER     => ['Api-Key: ' . KEITARO_KEY, 'Content-Type: application/json'],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT        => 60,
        CURLOPT_SSL_VERIFYPEER => KEITARO_SSL,
        CURLOPT_SSL_VERIFYHOST => KEITARO_SSL ? 2 : 0,
    ]);
    $raw  = curl_exec($ch);
    $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    if (!is_string($raw) || $code !== 200) {
        throw new RuntimeException("Keitaro $path: HTTP $code " . curl_error($ch));
    }
    return json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
}

function send_telegram(string $text): bool
{
    if (TG_TOKEN === '') {
        echo $text, "\n";
        return true;
    }
    $ch = curl_init('https://api.telegram.org/bot' . TG_TOKEN . '/sendMessage');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query(['chat_id' => TG_CHAT, 'text' => $text, 'parse_mode' => 'HTML']),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
    ]);
    $answer = json_decode((string) curl_exec($ch), true);
    if (empty($answer['ok'])) {
        fwrite(STDERR, 'Telegram: ' . ($answer['description'] ?? 'нет ответа') . "\n");
        return false;
    }
    return true;
}

// Один cron-процесс держит lock на весь цикл read/send/write.
$runLock = fopen(STATE_FILE . '.lock', 'c');
if ($runLock === false || !flock($runLock, LOCK_EX | LOCK_NB)) {
    exit("another run or state lock unavailable\n");
}
$tz = new DateTimeZone(TIMEZONE);
$report = keitaro('/conversions/log', [
    'range'   => [
        'from'     => (new DateTime('-' . DAYS_BACK . ' days', $tz))->format('Y-m-d'),
        'to'       => (new DateTime('today', $tz))->format('Y-m-d'),
        'timezone' => TIMEZONE,
    ],
    'columns' => ['sub_id', 'postback_datetime', 'status', 'revenue', 'campaign', 'offer', 'country'],
    'filters' => [['name' => 'status', 'operator' => 'EQUALS', 'expression' => 'sale']],
    'sort'    => [['name' => 'postback_datetime', 'order' => 'DESC']],
    'limit'   => 1000,
]);

$seen = [];
if (is_file(STATE_FILE)) {
    $rawState = file_get_contents(STATE_FILE);
    if ($rawState === false) {
        throw new RuntimeException('state read failed');
    }
    $seen = json_decode($rawState, true, 512, JSON_THROW_ON_ERROR);
    if (!is_array($seen)) {
        throw new RuntimeException('invalid state');
    }
}
$seen = array_filter($seen, static fn(int $ts): bool => $ts > time() - (DAYS_BACK + 1) * 86400); // состояние живёт дольше всего окна отчёта

$esc = static fn($v): string => htmlspecialchars((string) $v, ENT_NOQUOTES, 'UTF-8');
$lines = [];
foreach ($report['rows'] ?? [] as $row) {
    // ключ: клик + время постбека (повторный постбек с новой суммой придёт отдельно)
    $id = ($row['sub_id'] ?? '') . '|' . ($row['postback_datetime'] ?? '');
    if (isset($seen[$id]) || (float) ($row['revenue'] ?? 0) >= THRESHOLD) {
        continue;
    }
    $lines[$id] = $esc($row['postback_datetime'] ?? '') . ' | ' . $esc($row['campaign'] ?? '')
        . ' | ' . $esc($row['offer'] ?? '') . ' | ' . $esc($row['country'] ?? '')
        . ' | <b>' . $esc($row['revenue'] ?? '') . '</b> | <code>' . $esc($row['sub_id'] ?? '') . '</code>';
}

if ($lines) {
    $text = '<b>Апрувы с выплатой ниже ' . THRESHOLD . ': ' . count($lines) . "</b>\n";
    $sent = [];
    foreach ($lines as $id => $line) {
        if (mb_strlen($text . $line) > 3800) { // лимит Telegram 4096 символов
            $text .= '…и ещё ' . (count($lines) - count($sent)) . ', смотрите лог конверсий';
            break;
        }
        $text .= $line . "\n";
        $sent[$id] = time();
    }
    if (send_telegram($text) && TG_TOKEN !== '') {
        // Консольный dry-run не помечает события отправленными.
        $state = json_encode($seen + $sent, JSON_THROW_ON_ERROR);
        $tmpState = STATE_FILE . '.tmp.' . bin2hex(random_bytes(8));
        if (file_put_contents($tmpState, $state) !== strlen($state)
            || !rename($tmpState, STATE_FILE)) {
            throw new RuntimeException('state save failed; events may be sent again');
        }
    }
}

Как запустить:

  1. Впишите адрес трекера и ключ. Используйте доверенный домен с корректным TLS-сертификатом: отключать проверку TLS для передачи Admin API-ключа на постоянной основе не следует.
  2. Запустите php zero-approve-cron.php с пустым TG_TOKEN: найденные апрувы напечатаются в консоль.
  3. Впишите токен и ID чата и добавьте задание в cron: */10 * * * * php /opt/zero-approve/zero-approve-cron.php >> /var/log/zero-approve.log 2>&1

Скрипт запрашивает продажи за последние 14 дней. Окно взято с запасом: по документации Keitaro датирует конверсию первым постбеком по subid, обычно это лид, а апрув приходит позже. Сумму скрипт сравнивает с порогом сам, а не фильтром API: так проще поменять порог и не зависеть от того, как трекер фильтрует денежные поля. Пример запоминает только конверсии, вошедшие в успешно отправленное сообщение. Состояние хранится дольше 14-дневного окна, поэтому обрезанные строки можно отправить следующим запуском. Но запрос ограничен 1000 строками без пагинации: это не полный мониторинг произвольного объёма. При больших объёмах добавьте проверенную по документации API пагинацию или используйте меньшие интервалы выборки. В предыдущей версии статьи приведён тест на сервере с форматом ответа Admin API: первый запуск прислал две конверсии с выплатой 0 и 2.00, второй ничего не отправил.

Что делать, когда бот прислал нулевой апрув

  1. Найдите клик по SubID. В отчёте по кликам видно страну, IP и провайдера. Если ГЕО клика не совпадает с ГЕО оффера, это и есть причина, о которой нам говорила ПП.
  2. Закройте дыру в потоке. Добавьте в поток с оффером фильтры «Страны: ДА» с ГЕО оффера и «Прокси: НЕТ» (фильтры Keitaro).
  3. Соберите список. Раз в неделю отправляйте менеджеру ПП все SubID с нулевой выплатой: так спор о пересчёте идёт по фактам.
  4. Считайте реальный апрув. Апрув с нулевой выплатой не приносит денег, поэтому в расчёте EPC и ROI его лучше учитывать отдельно. Цифры удобно проверить в калькуляторе арбитражника.

Если у вас несколько доменов под кампаниями, посмотрите и наш бот мониторинга доменов для Keitaro: он предупредит, пока домен не истёк.

Частые вопросы

Что такое нулевой апрув?

Это подтверждённый лид, за который партнёрка передала выплату 0. В Keitaro он выглядит как продажа с доходом 0.

Почему партнёрка ставит апрув с нулевой выплатой?

Правила задаёт конкретная ПП. В нашем случае причиной было несоответствие IP или номера телефона ГЕО оффера. Уточните правила у менеджера до запуска.

Как найти нулевые апрувы в Keitaro без бота?

Откройте лог конверсий, отберите продажи и посмотрите на доход. Тот же отчёт можно получить через Admin API методом conversions/log.

Можно ли получать уведомления в Telegram без своего сервера?

Да, S2S postback можно отправить прямо на api.telegram.org, этот способ описан в документации Keitaro. Но порога по сумме у него нет, в чат придёт каждая продажа.

Почему S2S postback не отправляется?

Проверьте, что постбек добавлен в кампанию или источник и что отмечен нужный статус. Лог отправки лежит в «Обслуживание → Логи → S2S postbacks». Учтите, что постбек, повторённый один в один, S2S второй раз не запускает.

Как посчитать процент апрува?

Разделите число подтверждённых лидов на все лиды за период и умножьте на 100. Нулевые апрувы стоит показывать отдельной строкой, иначе процент апрува будет выглядеть лучше, чем выплаты.

Редакция BoostClicks

Материалы о настройке рекламы, трекинге и автоматизации: инструкции, примеры кода и расчёты с указанием условий проверки.