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

Интеграция Keitaro с Яндекс Директом: yclid и офлайн-конверсии

Как связать Keitaro и Яндекс Директ: yclid в External ID, апрувы в Метрику офлайн-конверсиями по API и расходы Директа в трекере. Два готовых PHP-скрипта…

Интеграция Keitaro с Яндекс Директом: yclid и офлайн-конверсии

Яндекс Директ учится на целях Метрики, а лиды и апрувы из партнёрки знает только Keitaro. Связка строится так: Keitaro сохраняет yclid из клика, S2S-постбэк передаёт лиды и апрувы скрипту, который загружает их в Метрику как офлайн-конверсии по yclid, а расходы из Директа раз в сутки подтягиваются в Keitaro через API.

Ниже пошаговая настройка на октябрь 2026 года по справке Метрики, документации API Директа и docs.keitaro.io, с двумя готовыми PHP-скриптами. Подготовка самой рекламы описана в гайде по заливу из Яндекс Директа.

Схема интеграции

  1. Объявление Директа ведёт на ссылку кампании Keitaro. Директ добавляет в неё yclid, уникальный номер клика.
  2. Keitaro записывает yclid в параметр External ID и показывает ваш лендинг или прелендинг со счётчиком Метрики.
  3. Партнёрская сеть шлёт постбэк в Keitaro: лид, апрув, отказ.
  4. Keitaro отправляет S2S-постбэк в скрипт ym-offline.php, тот копит конверсии и по cron загружает их в Метрику.
  5. Цели с офлайн-конверсиями доступны в Директе для отчётов и стратегий.
  6. Второй скрипт раз в сутки забирает расходы по кампаниям из Директа и записывает их в Keitaro.

По справке Метрики конверсия с yclid добавляется к визиту, источником которого было объявление с этим yclid. Если Keitaro сразу отправляет посетителя на оффер партнёрки, где нет вашего счётчика, визита в Метрике нет и привязывать конверсию не к чему. Нужен свой лендинг или прелендинг со счётчиком.

Шаг 1. Счётчик и цели в Метрике

  1. Поставьте счётчик Метрики на лендинг и укажите его в настройках кампании Директа, в поле «Счётчики Метрики». По справке Директа после этого во все ссылки объявлений автоматически добавляется метка yclid.
  2. В настройках счётчика на вкладке «Загрузка данных» включите «Учет офлайн-конверсий».
  3. Создайте две цели типа «Целевое событие» (в API и на старых скриншотах «JavaScript-событие») с условием «совпадает»: kt_lead для лида и kt_sale для апрува.
  4. Получите OAuth-токен приложения с доступом metrika:offline_data или metrika:write, как описано в документации API Метрики.
Настройка цели «Депозит» в Яндекс.Метрике для передачи офлайн-конверсий

На скриншоте цель с идентификатором dep. Название не важно, главное, чтобы идентификатор в цели совпадал с тем, что указан в скрипте.

Шаг 2. Кампания Keitaro и ссылка для Директа

В кампании Keitaro на вкладке «Параметры» свяжите параметры трекера с динамическими параметрами Директа. Список параметров Директа есть в справке о параметрах URL.

Параметр KeitaroИмя в ссылкеЗначение из Директа
External IDyclid{yclid}, номер клика
Ad Campaign IDcampaign{campaign_id}, ID кампании
Creative IDad{ad_id}, ID объявления
Ключевикkeyword{keyword}, ключевая фраза
Сайтsource{source}, площадка или none для поиска
Sub ID 1source_type{source_type}: search или context
Таблица прокручивается вбок
Настройка параметров кампании в Keitaro

Ссылка для объявления получится такой:

https://ВАШ_ТРЕКЕР/ALIAS?yclid={yclid}&campaign={campaign_id}&ad={ad_id}&keyword={keyword}&source={source}&source_type={source_type}

Если счётчик привязан к кампании, Директ сам допишет yclid, и параметр yclid={yclid} можно не указывать. Если лендинг подключён в поток редиректом, а не загружен в Keitaro, передайте yclid дальше: допишите к адресу лендинга ?yclid={yclid}, иначе счётчик на лендинге не увидит метку. Метки для своих лендингов удобно собирать в генераторе UTM-меток.

Ещё одна деталь из FAQ Keitaro про источники трафика: Директ показывает в объявлении домен конечного сайта, только если редирект идёт с домена из его белого списка. Добавить свой домен можно через поддержку Директа, одно из условий: статус юридического лица.

Шаг 3. Скрипт офлайн-конверсий ym-offline.php

Скрипт работает в двух режимах. На запрос от Keitaro он проверяет секретный ключ и пишет конверсию в очередь. По cron отправляет очередь в Метрику методом POST /management/v1/counter/{counterId}/offline_conversions/upload в формате CSV с колонками Yclid, Target, DateTime, Price, Currency, как в руководстве по передаче офлайн-конверсий.

<?php
// ym-offline.php: принимает S2S-постбэк Keitaro и отправляет офлайн-конверсии в Метрику
// Веб-режим: ставит конверсию в очередь. Режим cron: php ym-offline.php upload
const SECRET   = 'ДЛИННАЯ_СЛУЧАЙНАЯ_СТРОКА';   // защищает приёмник от чужих запросов
const COUNTER  = 12345678;                     // номер счётчика Метрики
const TOKEN    = 'OAUTH_ТОКЕН';                // токен с доступом metrika:offline_data
const API      = 'https://api-metrika.yandex.net/management/v1/counter/';
const CURRENCY = 'RUB';                        // валюта трекера (ISO 4217)
const QUEUE    = '/var/lib/ym-offline/queue.csv'; // отдельный каталог вне webroot
const GOALS    = ['lead' => 'kt_lead', 'sale' => 'kt_sale']; // статус Keitaro => цель
umask(0077); // очередь, batch и lock не доступны другим пользователям

if (PHP_SAPI === 'cli') {
    if (($argv[1] ?? '') === 'upload') {
        upload();
    }
    exit;
}

if (!hash_equals(SECRET, (string)($_GET['key'] ?? ''))) {
    http_response_code(403);
    exit('forbidden');
}
$yclid  = preg_replace('/\D+/', '', (string)($_GET['yclid'] ?? ''));
$status = (string)($_GET['status'] ?? '');
$time   = filter_var($_GET['time'] ?? '', FILTER_VALIDATE_INT);
$price  = (float)str_replace(',', '.', (string)($_GET['revenue'] ?? '0'));

if ($yclid === '' || !isset(GOALS[$status])) {
    exit('skip');   // нет yclid или статус не нужен Метрике
}
if ($time === false || $time <= 0 || $time > time()) {
    http_response_code(400);
    exit('invalid event time'); // не подменяем дату события временем приёма
}

// Writer и uploader используют один lockfile: rename очереди не обгонит запись.
$lock = queue_lock();
$fp = false;
$queued = false;
try {
    $fp = fopen(QUEUE, 'a');
    if ($fp === false) {
        throw new RuntimeException('queue unavailable');
    }
    $queued = fputcsv($fp, [$yclid, GOALS[$status], $time, number_format($price, 2, '.', ''), CURRENCY]) !== false
        && fflush($fp);
} finally {
    if (is_resource($fp)) {
        fclose($fp);
    }
    flock($lock, LOCK_UN);
    fclose($lock);
}
if (!$queued) {
    http_response_code(503);
    exit('queue write failed');
}
echo 'ok';

function queue_lock()
{
    $lock = fopen(QUEUE . '.lock', 'c');
    if ($lock === false || !flock($lock, LOCK_EX)) {
        throw new RuntimeException('queue lock unavailable');
    }
    return $lock;
}

function upload(): void
{
    $lock = queue_lock();
    try {
        clearstatcache(true, QUEUE);
        if (!is_file(QUEUE) || filesize(QUEUE) === 0) {
            return;
        }
        $batch = QUEUE . '.' . bin2hex(random_bytes(8));
        if (!rename(QUEUE, $batch)) {
            throw new RuntimeException('queue rotation failed');
        }
        // Пока держим общий lock, нет writer с открытым старым файлом.
    } finally {
        flock($lock, LOCK_UN);
        fclose($lock);
    }
    $pending = file_get_contents($batch);
    if ($pending === false) {
        throw new RuntimeException('batch read failed; keep batch file');
    }
    $csv = "Yclid,Target,DateTime,Price,Currency\n" . $pending;
    $tmp = $batch . '.csv';
    if (file_put_contents($tmp, $csv) !== strlen($csv)) {
        throw new RuntimeException('CSV write failed; keep batch file');
    }

    $ch = curl_init(API . COUNTER . '/offline_conversions/upload?comment=keitaro');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => ['file' => new CURLFile($tmp, 'text/csv', 'conversions.csv')],
        CURLOPT_HTTPHEADER     => ['Authorization: OAuth ' . TOKEN],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 60,
    ]);
    $body = curl_exec($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    unlink($tmp);

    if ($code === 200) {
        unlink($batch);
        echo "uploaded: $body\n";
    } else {
        // Возврат тоже под общим lock. При ошибке исходный batch остаётся.
        $lock = queue_lock();
        try {
            $pending = file_get_contents($batch);
            $current = is_file(QUEUE) ? file_get_contents(QUEUE) : '';
            if ($pending === false || $current === false) {
                throw new RuntimeException('queue read failed; keep batch file');
            }
            $merged = $pending . $current;
            $restore = QUEUE . '.restore.' . bin2hex(random_bytes(8));
            if (file_put_contents($restore, $merged) !== strlen($merged)
                || !rename($restore, QUEUE)) {
                throw new RuntimeException('queue restore failed; keep batch file');
            }
            unlink($batch);
        } finally {
            flock($lock, LOCK_UN);
            fclose($lock);
        }
        fwrite(STDERR, "error $code: $body\n");
    }
}

При локальной ошибке чтения, записи или восстановления исходный batch остаётся на диске: не удаляйте его, разберите причину и верните строки в очередь под тем же lock после остановки uploader. Автоматическое восстановление после аварийного завершения процесса здесь не реализовано. Не считайте запись в файл гарантией доставки: следите за ошибками cron и оставшимися batch-файлами.

PHP-приёмник разместите на своём сервере, а очередь оставьте в /var/lib/ym-offline/queue.csv вне публичной папки сайта. Заранее создайте каталог /var/lib/ym-offline с правами 0700 и владельцем, от которого работают PHP и cron. В этой версии PHP и cron должны работать от одного пользователя: umask(0077) создаёт файлы с правами 0600. Для разных пользователей нужны отдельные настройки прав и umask, одной общей группы недостаточно. Каталог не должен раздаваться веб-сервером: в нём лежат CSV, batch и lock. Запуск выгрузки раз в 30 минут:

Если обновляете старую установку, перед сменой QUEUE приостановите приём постбэков и cron. Перенесите старый ym-queue.csv в закрытый каталог с сохранением строк, проверьте количество записей и права владельца. Незавершённые batch разберите отдельно: повторное добавление уже принятого файла может создать дубли. Закройте прежний публичный путь и только после проверки возобновите приём и выгрузку. Старый backlog автоматически в новый путь не переезжает.

*/30 * * * * php /var/www/ym/ym-offline.php upload

Теперь добавьте в кампании Keitaro на вкладке «S2S postbacks» новую ссылку, метод GET, статусы «Лид» и «Продажа»:

https://ВАШ_ДОМЕН/ym/ym-offline.php?key=СЕКРЕТ&yclid={external_id}&status={status}&revenue={conversion_revenue}&time={conversion_time:U}

Макросы {external_id}, {status}, {conversion_revenue} и {conversion_time} описаны в разделе Keitaro о макросах S2S postback. Отправки видны в «Обслуживание» → «Логи» → «S2S postbacks».

Ограничения Метрики, которые стоит помнить:

  • конверсия привязывается к визиту, если с последнего визита до обработки файла прошло не больше 21 дня;
  • данные появляются в отчётах в течение 3 часов после загрузки;
  • передавайте настоящий Unix timestamp события в секундах, не миллисекунды. Пустая, дробная, неположительная или будущая дата отклоняется с HTTP 400. Скрипт не подменяет её временем приёма: исправьте часы или формат даты у отправителя и повторите запрос;
  • HTTP 200 подтверждает приём файла, но не успешную привязку всех строк; окончательный статус каждой загрузки виден в отчёте «Офлайн-конверсии» и в ответе API (PROCESSED, LINKAGE_FAILURE и другие).

Когда цели начнут получать конверсии, их можно выбрать в стратегии Директа: так предлагает справка Метрики об офлайн-загрузке по yclid. Похожая схема для Google Ads разобрана в статье про передачу апрувов из Keitaro в Google.

Почему не Measurement Protocol с cid={yclid}

В первой версии этой статьи мы советовали слать постбэки на mc.yandex.ru/collect с cid={yclid}. Это ошибка. По документации Measurement Protocol в cid передаётся ClientID посетителя, а не номер клика Директа. События дописываются только к существующему визиту с этим ClientID и не позже 12 часов после его окончания. Запрос с yclid вместо ClientID создаёт визит нового посетителя без связи с объявлением, и Директ не может засчитать такую конверсию кампании.

Measurement Protocol подходит, если лендинг сам сохраняет настоящий ClientID и передаёт его в Keitaro, а конверсия приходит в течение нескольких часов. Апрувы CPA-сетей обычно приходят позже, поэтому для них офлайн-конверсии по yclid надёжнее.

Шаг 4. Расходы Директа в Keitaro

Директ не передаёт цену клика в ссылке, поэтому расход в Keitaro появляется двумя способами. Вручную: «Кампания» → «Обновить расходы», так советует FAQ Keitaro. Автоматически: скрипт ниже забирает вчерашние расходы из сервиса Reports API Директа и пишет их в Keitaro методом POST /admin_api/v1/clicks/update_costs с фильтром по ad_campaign_id.

<?php
// direct-costs.php: забирает вчерашние расходы из Директа и записывает их в Keitaro
// Запуск по cron раз в сутки: php direct-costs.php
const DIRECT_TOKEN = 'OAUTH_ТОКЕН_ДИРЕКТА';
const DIRECT_API   = 'https://api.direct.yandex.com/json/v501/reports';
const KT_URL       = 'https://ВАШ_ТРЕКЕР/admin_api/v1/clicks/update_costs';
const KT_KEY       = 'КЛЮЧ_ADMIN_API';
const KT_CAMPAIGN  = 1;                 // ID кампании Keitaro
const TZ           = 'Europe/Moscow';   // часовой пояс аккаунта Директа
const CURRENCY     = 'RUB';             // валюта аккаунта Директа

$report = [
    'params' => [
        'SelectionCriteria' => new stdClass(),
        'FieldNames'        => ['Date', 'CampaignId', 'Cost'],
        'ReportName'        => 'kt-costs-' . date('Y-m-d'),
        'ReportType'        => 'CAMPAIGN_PERFORMANCE_REPORT',
        'DateRangeType'     => 'YESTERDAY',
        'Format'            => 'TSV',
        'IncludeVAT'        => 'NO',
    ],
];

for ($try = 0; $try < 20; $try++) {
    $ch = curl_init(DIRECT_API);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode($report),
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . DIRECT_TOKEN,
            'Accept-Language: ru',
            'processingMode: auto',
            'returnMoneyInMicros: false',
            'skipReportHeader: true',
            'skipColumnHeader: true',
            'skipReportSummary: true',
        ],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HEADER         => true,
        CURLOPT_TIMEOUT        => 60,
    ]);
    $raw  = (string)curl_exec($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $head = substr($raw, 0, (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE));
    $body = substr($raw, (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE));
    if ($code === 200) {
        break;
    }
    if ($code !== 201 && $code !== 202) {
        exit("Директ вернул $code: $body\n");
    }
    $wait = preg_match('/^retryIn:\s*(\d+)/mi', $head, $m) ? (int)$m[1] : 10;
    sleep(max(1, $wait));   // отчёт строится офлайн, ждём и повторяем
}
if ($code !== 200) {
    exit("Отчёт не готов, запустите позже\n");
}

$costs = [];
foreach (preg_split('/\R/', trim($body)) as $line) {
    [$date, $campaignId, $cost] = array_pad(explode("\t", $line), 3, '');
    if ($campaignId === '' || (float)$cost <= 0) {
        continue;
    }
    $costs[] = [
        'start_date' => "$date 00:00:00",
        'end_date'   => "$date 23:59:59",
        'cost'       => (float)$cost,
        'filters'    => ['ad_campaign_id' => $campaignId],
    ];
}
if (!$costs) {
    exit("Расходов за вчера нет\n");
}

$ch = curl_init(KT_URL);
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => json_encode([
        'campaign_ids' => [KT_CAMPAIGN],
        'costs'        => $costs,
        'timezone'     => TZ,
        'currency'     => CURRENCY,
    ]),
    CURLOPT_HTTPHEADER     => ['Api-Key: ' . KT_KEY, 'Content-Type: application/json'],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 60,
]);
$res = curl_exec($ch);
echo 'Keitaro: ' . curl_getinfo($ch, CURLINFO_HTTP_CODE) . " $res\n";

Что нужно для запуска:

  • Доступ к API Директа: приложение в Яндекс ID с правом direct:api и одобренная заявка. По документации API Директа заявку рассматривают до 7 дней, а раздел настроек API открывается после создания хотя бы одной кампании.
  • Ключ Admin API Keitaro из профиля. Admin API есть в редакциях Expert, Team и Enterprise, список методов в документации Admin API.
  • Параметр Ad Campaign ID в кампании Keitaro, заполненный из {campaign_id}: по нему скрипт делит расход между кампаниями Директа.

Скрипт обрабатывает офлайн-режим отчётов: на ответ 201 или 202 он ждёт столько секунд, сколько указано в заголовке retryIn, и повторяет запрос. Суммы приходят в валюте аккаунта с двумя знаками благодаря заголовку returnMoneyInMicros: false. С расходами и доходом в одном отчёте ROI можно сверить в калькуляторе арбитражника.

Проверка связки

Что проверитьГдеНорма
yclid дошёл до трекераKeitaro, лог кликов, колонка External IDдлинное число в каждом клике из Директа
Постбэк сети записан«Обслуживание» → «Логи» → «Postbacks»нет ошибок, конверсия в отчёте
S2S ушёл в скрипт«Обслуживание» → «Логи» → «S2S postbacks»ответ ok или skip для ненужных статусов
Выгрузка в Метрикувывод cronuploaded и id загрузки
Привязка к визитамМетрика, отчёт «Офлайн-конверсии»конверсии привязаны, без LINKAGE_FAILURE
Таблица прокручивается вбок

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

Нужно ли прописывать yclid={yclid} в ссылке, если счётчик привязан?

Нет, при привязанном счётчике Директ добавляет yclid сам. Явный параметр полезен, если автоматическая разметка выключена.

Можно ли загружать офлайн-конверсии без счётчика на лендинге?

Нет. Конверсия по yclid привязывается к визиту из объявления, а визит записывает только счётчик Метрики на вашей странице.

Сколько ждать, пока конверсии появятся в Метрике?

До 3 часов после загрузки. Если с визита прошло больше 21 дня, конверсия уже не привяжется.

Почему S2S-постбэк возвращает forbidden?

Ключ key в ссылке Keitaro не совпадает с константой SECRET в скрипте. Скопируйте его заново без пробелов.

Как отправлять в Метрику только апрувы?

Оставьте в S2S-постбэке Keitaro только статус «Продажа» или уберите lead из массива GOALS в скрипте.

Можно ли передать в Метрику доход по конверсии?

Да. Скрипт кладёт выплату из Keitaro в колонку Price, а валюту в Currency по ISO 4217. Укажите в константе CURRENCY валюту своего трекера.

Редакция BoostClicks

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