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

Нулевой апрув в арбитраже: партнёрка подтвердила лид, но выплата по нему 0. В Keitaro такая конверсия приходит со статусом «Продажа» и доходом 0, и среди обычных апрувов её легко пропустить. Искать вручную по всем кампаниям не нужно: Keitaro может сам отправить S2S postback небольшому скрипту, а скрипт напишет в Telegram, если сумма ниже порога.
Ниже наша история, три способа автоматизации и рабочий код на PHP: обработчик S2S postback и вариант с отчётом по конверсиям через Keitaro Admin API.
- Что такое нулевой апрув и откуда он берётся
- Где нулевой апрув виден в Keitaro
- Три способа ловить нулевой апрув автоматически
- Способ 1: S2S postback прямо в Telegram
- Способ 2: S2S postback в скрипт с порогом
- Шаг 1. Бот и ID чата
- Шаг 2. Ссылка S2S postback
- Шаг 3. Скрипт
- Шаг 4. Проверка
- Способ 3: отчёт по конверсиям через Keitaro Admin API
- Что делать, когда бот прислал нулевой апрув
- Частые вопросы
- Что такое нулевой апрув?
- Почему партнёрка ставит апрув с нулевой выплатой?
- Как найти нулевые апрувы в Keitaro без бота?
- Можно ли получать уведомления в Telegram без своего сервера?
- Почему S2S postback не отправляется?
- Как посчитать процент апрува?
Что такое нулевой апрув и откуда он берётся
Апрув в арбитраже это лид, который рекламодатель подтвердил и за который партнёрка платит. Как считать процент апрува и чем он отличается от холда, мы разбирали в статье про 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 чата
- Создайте бота у @BotFather командой /newbot и сохраните токен.
- Добавьте бота в чат с баерами и напишите туда любое сообщение.
- Откройте
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. Проверка
- Откройте в браузере
https://ВАШ_ДОМЕН/zero-approve.php?key=СЕКРЕТ&revenue=0&subid=test&campaign=Test. Скрипт ответит sent, а в чат придёт сообщение. - Тот же адрес с неверным ключом должен вернуть forbidden.
- После первых реальных продаж загляните в «Обслуживание → Логи → 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');
}
}
}Как запустить:
- Впишите адрес трекера и ключ. Используйте доверенный домен с корректным TLS-сертификатом: отключать проверку TLS для передачи Admin API-ключа на постоянной основе не следует.
- Запустите
php zero-approve-cron.phpс пустым TG_TOKEN: найденные апрувы напечатаются в консоль. - Впишите токен и 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, второй ничего не отправил.
Что делать, когда бот прислал нулевой апрув
- Найдите клик по SubID. В отчёте по кликам видно страну, IP и провайдера. Если ГЕО клика не совпадает с ГЕО оффера, это и есть причина, о которой нам говорила ПП.
- Закройте дыру в потоке. Добавьте в поток с оффером фильтры «Страны: ДА» с ГЕО оффера и «Прокси: НЕТ» (фильтры Keitaro).
- Соберите список. Раз в неделю отправляйте менеджеру ПП все SubID с нулевой выплатой: так спор о пересчёте идёт по фактам.
- Считайте реальный апрув. Апрув с нулевой выплатой не приносит денег, поэтому в расчёте 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. Нулевые апрувы стоит показывать отдельной строкой, иначе процент апрува будет выглядеть лучше, чем выплаты.



