В трафик‑агентстве я отвечал за стоимость лида и ДРР (долю рекламных расходов) клиентов. Типичная картина: в кабинете Директа стоимость конверсии в норме, а отдел продаж жалуется, что половина заявок нецелевые. Причина почти всегда одна: автостратегия учится на цели «отправил форму», а о том, что было с заявкой дальше, ничего не знает.
Офлайн‑конверсии закрывают этот разрыв. Когда менеджер переводит сделку в amoCRM в статус «Квалифицирован» или «Оплачено», Метрика получает конверсию по этому посетителю, а Директ может оптимизировать кампании по ней. Ниже пошаговая схема, которую я собираю без отдельного сервера и платных коннекторов: amoCRM, Google Таблица с Apps Script, API Метрики и API Директа.
Как устроена схема
┌──────────────────┐ заявка + ClientID, yclid, UTM ┌──────────────────┐
│ Сайт (форма) │ ──────────────────────────────▶ │ amoCRM │
└──────────────────┘ └──────────────────┘
│ смена статуса
▼ (вебхук)
┌──────────────────┐ CSV: ClientId, Target, Price ┌──────────────────┐
│ Яндекс Метрика │ ◀────────────────────────────── │ Google Таблица │
└──────────────────┘ │ + Apps Script │
│ офлайн-цель └──────────────────┘
▼ ▲
┌──────────────────┐ расходы через API Директа │
│ Яндекс Директ │ ─────────────────────────────────────────┘
└──────────────────┘
Роли у каждого звена свои:
- Сайт передаёт в заявку идентификатор посетителя из Метрики (ClientID) и метку клика Директа (yclid).
- amoCRM хранит эти идентификаторы в сделке и при смене статуса шлёт вебхук.
- Google Таблица с Apps Script принимает вебхук, достаёт из сделки ClientID и сумму, раз в час отправляет пачку конверсий в Метрику и ведёт журнал отправок.
- Метрика связывает конверсию с визитом и рекламным переходом.
- Директ использует офлайн‑цель в стратегии. Через API Директа таблица забирает расходы, чтобы считать стоимость целевого лида и ДРР по данным CRM.
Загрузить конверсии напрямую в Директ через его API нельзя. Офлайн‑конверсии идут только через Метрику, а Директ видит их как обычную цель счётчика.
Шаг 1. Цели в Метрике под этапы воронки
Для каждого этапа, который хотите передавать, создайте в счётчике цель типа
«JavaScript‑событие». Идентификатор цели потом пойдёт в колонку Target
загружаемого файла. Я обычно завожу две:
qualified_lead: менеджер дозвонился и подтвердил, что заявка целевая;deal_won: оплата или успешная сделка.
Дальше проверьте в настройках счётчика период учёта офлайн‑конверсий. Конверсия засчитывается, только если с визита прошло не больше этого периода. По умолчанию он короткий, поэтому при длинном цикле сделки включите расширенный период.
Если в файл передавать сумму сделки в колонке Price, в отчётах Метрики
и Директа появится доход по цели. От него уже можно считать ДРР.
Шаг 2. Передаём ClientID и yclid из формы в amoCRM
Без идентификатора посетителя в сделке связать продажу с рекламой не получится. Поэтому начинаем с формы:
- В amoCRM заведите дополнительные поля сделки:
ClientID,yclidи UTM‑метки, если их ещё нет. - В форму на сайте добавьте скрытые поля
clientidиyclid. - В настройках интеграции Tilda (или другого конструктора) с amoCRM сопоставьте скрытые поля с полями сделки.
- Подключите на сайт скрипт, который заполняет скрытые поля.
<script> (function () { // yclid есть только в URL первого перехода, поэтому сохраняем его var p = new URLSearchParams(location.search); if (p.get('yclid')) { try { localStorage.setItem('yclid', p.get('yclid')); } catch (e) {} } function cookie(name) { var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : ''; } function fill() { var cid = cookie('_ym_uid'); // совпадает с ClientID Метрики var ycl = ''; try { ycl = localStorage.getItem('yclid') || ''; } catch (e) {} document.querySelectorAll('input[name="clientid"]').forEach(function (i) { i.value = cid; }); document.querySelectorAll('input[name="yclid"]').forEach(function (i) { i.value = ycl; }); } document.addEventListener('focusin', fill); document.addEventListener('submit', fill, true); window.addEventListener('load', fill); })(); </script>
Кука _ym_uid содержит тот же идентификатор, который Метрика отдаёт методом
getClientID, и читается без ожидания загрузки счётчика. Если ClientID пустой, например
из‑за блокировщика рекламы, выручит yclid. Отправьте тестовую заявку и проверьте, что в сделке
заполнились оба поля. Без этого дальше идти нет смысла.
Шаг 3. Доступы: токены amoCRM и Яндекса
- amoCRM. «Настройки → Интеграции → Создать интеграцию». В созданной интеграции на вкладке «Ключи и доступы» сгенерируйте долгосрочный токен.
- Яндекс. На oauth.yandex.ru создайте приложение с правами «Метрика: запись»
и «Яндекс Директ: использование API». Для Директа отдельно подайте заявку на доступ к API
в интерфейсе Директа. Токен получите по ссылке
https://oauth.yandex.ru/authorize?response_type=token&client_id=<ID приложения>. - Apps Script. Токены не пишите в код. Сохраните их в «Настройки проекта →
Свойства скрипта»:
AMO_TOKEN,YA_TOKENи случайную строкуWEBHOOK_SECRET.
Ещё понадобятся ID полей и статусов amoCRM. ID полей возвращает запрос
GET /api/v4/leads/custom_fields, ID статусов — GET /api/v4/leads/pipelines.
У статуса «Успешно реализовано» ID всегда 142.
Шаг 4. Google Таблица и скрипт выгрузки в Метрику
Создайте таблицу с тремя листами:
| Лист | Что в нём |
|---|---|
queue | Очередь событий из amoCRM: дата, ID сделки, ID статуса, состояние
(new, sent, skip, no_id, error) |
sent | Журнал отправленных конверсий: что, когда и с каким идентификатором ушло в Метрику |
direct | Расходы и конверсии по кампаниям из API Директа |
Откройте «Расширения → Apps Script» и вставьте код. Первая часть: настройки и приём вебхука. Вебхук ничего не обрабатывает, а только складывает событие в очередь. Так amoCRM быстро получает ответ, и сбой на стороне Метрики не приводит к потере событий.
const PROPS = PropertiesService.getScriptProperties(); const CFG = { amoDomain: 'example.amocrm.ru', counterId: '12345678', // номер счётчика Метрики fieldClientId: 111111, // ID поля ClientID в amoCRM fieldYclid: 222222, // ID поля yclid statusToTarget: { // статус amoCRM → идентификатор цели в Метрике '55555555': 'qualified_lead', '142': 'deal_won', // «Успешно реализовано» }, directLogin: 'client-login', // логин клиента в Директе directGoalId: 987654321, // ID цели deal_won в Метрике }; // Вебхук amoCRM: только кладём событие в очередь и сразу отвечаем function doPost(e) { if (e.parameter.key !== PROPS.getProperty('WEBHOOK_SECRET')) { return ContentService.createTextOutput('forbidden'); } const lock = LockService.getScriptLock(); lock.waitLock(10000); try { const sh = SpreadsheetApp.getActive().getSheetByName('queue'); for (let i = 0; e.parameter[`leads[status][${i}][id]`]; i++) { sh.appendRow([ new Date(), e.parameter[`leads[status][${i}][id]`], e.parameter[`leads[status][${i}][status_id]`], 'new', ]); } } finally { lock.releaseLock(); } return ContentService.createTextOutput('ok'); }
Вторая часть разбирает очередь: для каждой сделки запрашивает amoCRM, берёт ClientID (или yclid, если ClientID нет) и сумму, собирает CSV и загружает его в Метрику. Отправленные строки помечаются, поэтому повторный запуск не создаёт дублей.
// Запускается по триггеру раз в час function processQueue() { const ss = SpreadsheetApp.getActive(); const q = ss.getSheetByName('queue'); const log = ss.getSheetByName('sent'); const rows = q.getDataRange().getValues(); const batches = { CLIENT_ID: [], YCLID: [] }; for (let r = 1; r < rows.length; r++) { const [ts, leadId, statusId, state] = rows[r]; if (state !== 'new') continue; const target = CFG.statusToTarget[String(statusId)]; if (!target) { q.getRange(r + 1, 4).setValue('skip'); continue; } const lead = amoGet(`/api/v4/leads/${leadId}`); const clientId = cf(lead, CFG.fieldClientId); const yclid = cf(lead, CFG.fieldYclid); const dt = Math.floor(new Date(ts).getTime() / 1000); const price = lead.price || 0; const type = clientId ? 'CLIENT_ID' : yclid ? 'YCLID' : null; if (!type) { q.getRange(r + 1, 4).setValue('no_id'); continue; } batches[type].push({ row: r + 1, leadId, line: [clientId || yclid, target, dt, price, 'RUB'] }); } // Один файл — один тип идентификатора for (const type in batches) { const items = batches[type]; if (!items.length) continue; try { const uploadingId = uploadToMetrika(type, items.map(i => i.line)); items.forEach(i => { q.getRange(i.row, 4).setValue('sent:' + uploadingId); log.appendRow([new Date(), i.leadId, type].concat(i.line)); }); } catch (err) { items.forEach(i => q.getRange(i.row, 4).setValue('error')); console.error(err); } } } function uploadToMetrika(type, lines) { const idCol = type === 'CLIENT_ID' ? 'ClientId' : 'Yclid'; const csv = [`${idCol},Target,DateTime,Price,Currency`] .concat(lines.map(l => l.join(','))).join('\n'); const res = UrlFetchApp.fetch( `https://api-metrika.yandex.net/management/v1/counter/${CFG.counterId}` + `/offline_conversions/upload?client_id_type=${type}`, { method: 'post', headers: { Authorization: 'OAuth ' + PROPS.getProperty('YA_TOKEN') }, payload: { file: Utilities.newBlob(csv, 'text/csv', 'conversions.csv') }, // уйдёт как multipart muteHttpExceptions: true, } ); if (res.getResponseCode() !== 200) throw new Error(res.getContentText()); return JSON.parse(res.getContentText()).uploading.id; } function amoGet(path) { const res = UrlFetchApp.fetch(`https://${CFG.amoDomain}${path}`, { headers: { Authorization: 'Bearer ' + PROPS.getProperty('AMO_TOKEN') }, }); return JSON.parse(res.getContentText()); } function cf(lead, fieldId) { const f = (lead.custom_fields_values || []).find(x => x.field_id === fieldId); return f ? String(f.values[0].value).trim() : ''; }
Шаг 5. Вебхук amoCRM и триггеры
- В Apps Script нажмите «Начать развёртывание → Новое развёртывание → Веб‑приложение». Запуск от вашего имени, доступ «Все». Скопируйте URL.
- В amoCRM откройте «Настройки → Интеграции → Веб‑хуки» и укажите адрес
https://script.google.com/macros/s/<ID>/exec?key=<WEBHOOK_SECRET>. Событие: «Смена статуса сделки». - В Apps Script в разделе «Триггеры» добавьте запуск
processQueueраз в час иloadDirectCostsраз в сутки. - Переведите тестовую сделку в нужный статус и проверьте, что в листе
queueпоявилась строка, а после запускаprocessQueueона получила статусsent.
Одна ловушка, на которую я попадал сам: после правки кода веб‑приложение продолжает работать
на старой версии, пока вы не создадите новую версию развёртывания. Если вебхуки перестали
доходить или Google Таблицы недоступны, вебхук можно заменить опросом amoCRM по триггеру:
GET /api/v4/leads с фильтром по статусу и updated_at.
Шаг 6. Директ: стратегия по офлайн‑цели и расчёт ДРР
Когда конверсии начали приходить в Метрику, в кампаниях Директа выберите стратегию
с оптимизацией по конверсиям или с оплатой за конверсии и укажите офлайн‑цель.
Я начинаю с qualified_lead: по ней конверсий больше, чем по deal_won,
и автостратегия быстрее обучается. На цель «сделка» переключаюсь, когда по ней набирается
достаточный объём. После смены цели стратегия переобучается, не делайте выводов по первым дням.
Чтобы видеть стоимость целевого лида и ДРР по данным CRM, а не по кабинету, таблица раз в сутки забирает расходы через сервис отчётов API Директа:
// Запускается по триггеру раз в сутки function loadDirectCosts() { const body = { params: { SelectionCriteria: {}, FieldNames: ['Date', 'CampaignId', 'CampaignName', 'Clicks', 'Cost', 'Conversions'], Goals: [CFG.directGoalId], AttributionModels: ['LYDC'], // последний значимый переход из Директа ReportName: 'offline_' + Date.now(), ReportType: 'CAMPAIGN_PERFORMANCE_REPORT', DateRangeType: 'LAST_30_DAYS', Format: 'TSV', IncludeVAT: 'YES', }, }; const opts = { method: 'post', contentType: 'application/json; charset=utf-8', headers: { Authorization: 'Bearer ' + PROPS.getProperty('YA_TOKEN'), 'Client-Login': CFG.directLogin, processingMode: 'auto', returnMoneyInMicros: 'false', skipReportHeader: 'true', skipReportSummary: 'true', }, payload: JSON.stringify(body), muteHttpExceptions: true, }; for (let attempt = 0; attempt < 10; attempt++) { const res = UrlFetchApp.fetch('https://api.direct.yandex.com/json/v5/reports', opts); const code = res.getResponseCode(); if (code === 200) { const data = res.getContentText().trim().split('\n').map(l => l.split('\t')); const sh = SpreadsheetApp.getActive().getSheetByName('direct'); sh.clearContents(); sh.getRange(1, 1, data.length, data[0].length).setValues(data); return; } if (code === 201 || code === 202) { // отчёт ещё готовится: ждём столько, сколько просит API Utilities.sleep((Number(res.getHeaders()['retryIn']) || 10) * 1000); continue; } throw new Error(res.getContentText()); } }
Дальше считаем на отдельном листе обычными формулами:
- стоимость целевого лида = расходы / число
qualified_leadза период; - ДРР = расходы / выручка по сделкам из листа
sent× 100%.
Частые ошибки и как их найти
| Симптом | Причина | Что сделать |
|---|---|---|
Много строк no_id | Форма не передаёт скрытые поля в amoCRM | Проверить сопоставление полей в интеграции и скрипт на сайте |
| Загрузка прошла, конверсий в отчёте нет | Отчёты обновляются с задержкой или визит старше периода учёта | Подождать сутки, проверить период учёта в счётчике |
| Ошибка загрузки CSV | В одном файле смешаны ClientID и yclid или цель не того типа | Один файл — один тип идентификатора, цель — «JavaScript‑событие» |
| Конверсии задвоились | Конверсии отправляли вручную в обход таблицы | Отправлять только через очередь с отметкой sent |
| Вебхук перестал приходить | Код изменён без новой версии развёртывания | Создать новую версию веб‑приложения |
Проверить конкретную загрузку можно запросом
GET /management/v1/counter/{id}/offline_conversions/uploading/{uploadingId}. ID загрузки
скрипт пишет в лист queue.
Офлайн‑конверсии не спасут от мусора на входе
Офлайн‑конверсии учат стратегию на квалифицированных лидах, но боты и фейковые номера всё равно попадают в amoCRM, отнимают время менеджеров и портят данные для обучения, пока менеджер их не отсеет. Поэтому я ставлю офлайн‑конверсии вторым шагом, а первым делаю подтверждение номера в форме. Почему это важно при оплате за конверсии, разобрал в статье «Фрод при оплате за конверсии в Директе», а про связку офлайн‑конверсий с входом через Яндекс ID и Сбер ID — в статье «Как снизить CPL с ID‑авторизацией».
Чек‑лист запуска
- В Метрике созданы цели «JavaScript‑событие» под каждый этап и проверен период учёта.
- В тестовой сделке amoCRM заполнены ClientID, yclid и UTM‑метки.
- Токены лежат в свойствах скрипта, а не в коде.
- Вебхук amoCRM указывает на актуальную версию веб‑приложения с секретным ключом.
- Триггеры
processQueueиloadDirectCostsвключены. - Первые конверсии видны в отчёте Метрики по офлайн‑цели.
- Стратегия в Директе переключена на офлайн‑цель, результат оценивается по стоимости целевого лида и ДРР.
Мы внедряем СМС‑верификацию в формах на сайте и настраиваем передачу подтверждённых заявок с ClientID и UTM‑метками в amoCRM. Подключаем на Tilda, Bitrix, WordPress и самописные сайты за 1–3 рабочих дня.