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

Яндекс Директ учится на целях Метрики, а лиды и апрувы из партнёрки знает только Keitaro. Связка строится так: Keitaro сохраняет yclid из клика, S2S-постбэк передаёт лиды и апрувы скрипту, который загружает их в Метрику как офлайн-конверсии по yclid, а расходы из Директа раз в сутки подтягиваются в Keitaro через API.
Ниже пошаговая настройка на октябрь 2026 года по справке Метрики, документации API Директа и docs.keitaro.io, с двумя готовыми PHP-скриптами. Подготовка самой рекламы описана в гайде по заливу из Яндекс Директа.
- Схема интеграции
- Шаг 1. Счётчик и цели в Метрике
- Шаг 2. Кампания Keitaro и ссылка для Директа
- Шаг 3. Скрипт офлайн-конверсий ym-offline.php
- Почему не Measurement Protocol с cid={yclid}
- Шаг 4. Расходы Директа в Keitaro
- Проверка связки
- Частые вопросы
- Нужно ли прописывать yclid={yclid} в ссылке, если счётчик привязан?
- Можно ли загружать офлайн-конверсии без счётчика на лендинге?
- Сколько ждать, пока конверсии появятся в Метрике?
- Почему S2S-постбэк возвращает forbidden?
- Как отправлять в Метрику только апрувы?
- Можно ли передать в Метрику доход по конверсии?
Схема интеграции
- Объявление Директа ведёт на ссылку кампании Keitaro. Директ добавляет в неё
yclid, уникальный номер клика. - Keitaro записывает yclid в параметр External ID и показывает ваш лендинг или прелендинг со счётчиком Метрики.
- Партнёрская сеть шлёт постбэк в Keitaro: лид, апрув, отказ.
- Keitaro отправляет S2S-постбэк в скрипт
ym-offline.php, тот копит конверсии и по cron загружает их в Метрику. - Цели с офлайн-конверсиями доступны в Директе для отчётов и стратегий.
- Второй скрипт раз в сутки забирает расходы по кампаниям из Директа и записывает их в Keitaro.
По справке Метрики конверсия с yclid добавляется к визиту, источником которого было объявление с этим yclid. Если Keitaro сразу отправляет посетителя на оффер партнёрки, где нет вашего счётчика, визита в Метрике нет и привязывать конверсию не к чему. Нужен свой лендинг или прелендинг со счётчиком.
Шаг 1. Счётчик и цели в Метрике
- Поставьте счётчик Метрики на лендинг и укажите его в настройках кампании Директа, в поле «Счётчики Метрики». По справке Директа после этого во все ссылки объявлений автоматически добавляется метка yclid.
- В настройках счётчика на вкладке «Загрузка данных» включите «Учет офлайн-конверсий».
- Создайте две цели типа «Целевое событие» (в API и на старых скриншотах «JavaScript-событие») с условием «совпадает»:
kt_leadдля лида иkt_saleдля апрува. - Получите OAuth-токен приложения с доступом
metrika:offline_dataилиmetrika:write, как описано в документации API Метрики.

На скриншоте цель с идентификатором dep. Название не важно, главное, чтобы идентификатор в цели совпадал с тем, что указан в скрипте.
Шаг 2. Кампания Keitaro и ссылка для Директа
В кампании Keitaro на вкладке «Параметры» свяжите параметры трекера с динамическими параметрами Директа. Список параметров Директа есть в справке о параметрах URL.
| Параметр Keitaro | Имя в ссылке | Значение из Директа |
|---|---|---|
| External ID | yclid | {yclid}, номер клика |
| Ad Campaign ID | campaign | {campaign_id}, ID кампании |
| Creative ID | ad | {ad_id}, ID объявления |
| Ключевик | keyword | {keyword}, ключевая фраза |
| Сайт | source | {source}, площадка или none для поиска |
| Sub ID 1 | source_type | {source_type}: search или context |

Ссылка для объявления получится такой:
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 для ненужных статусов |
| Выгрузка в Метрику | вывод cron | uploaded и id загрузки |
| Привязка к визитам | Метрика, отчёт «Офлайн-конверсии» | конверсии привязаны, без LINKAGE_FAILURE |
Частые вопросы
Нужно ли прописывать yclid={yclid} в ссылке, если счётчик привязан?
Нет, при привязанном счётчике Директ добавляет yclid сам. Явный параметр полезен, если автоматическая разметка выключена.
Можно ли загружать офлайн-конверсии без счётчика на лендинге?
Нет. Конверсия по yclid привязывается к визиту из объявления, а визит записывает только счётчик Метрики на вашей странице.
Сколько ждать, пока конверсии появятся в Метрике?
До 3 часов после загрузки. Если с визита прошло больше 21 дня, конверсия уже не привяжется.
Почему S2S-постбэк возвращает forbidden?
Ключ key в ссылке Keitaro не совпадает с константой SECRET в скрипте. Скопируйте его заново без пробелов.
Как отправлять в Метрику только апрувы?
Оставьте в S2S-постбэке Keitaro только статус «Продажа» или уберите lead из массива GOALS в скрипте.
Можно ли передать в Метрику доход по конверсии?
Да. Скрипт кладёт выплату из Keitaro в колонку Price, а валюту в Currency по ISO 4217. Укажите в константе CURRENCY валюту своего трекера.



