Если льёте на наши офферы с Google Ads — можно настроить так, чтобы каждая подтверждённая конверсия автоматически прилетала в ваш рекламный кабинет и привязывалась к нужному клику. Google начинает видеть реальный результат по вашим кампаниям и лучше оптимизирует показы на Поиске и в Demand Gen.
Всё разворачивается у вас, под вашим собственным Google Ads и вашим собственным Cloudflare-аккаунтом — мы просто отдаём готовый скрипт и показываем, куда какие данные вписать. Бесплатно, займёт минут 15–20.
Зачем это нужно
Когда пользователь кликает по объявлению, Google подставляет в ссылку gclid (Google Click ID). Если конверсия — лид, депозит, продажа — подтверждается не на сайте через пиксель, а у нас в системе, обычный клиентский тег её не увидит. Конверсию нужно передать вручную, привязав к тому самому gclid, чтобы Google Ads видел реальный результат.
Для этого используется официальный Google Data Manager API — актуальный способ передачи конверсий у Google.
Что нужно сделать
1. Создайте конверсию в своём Google Ads
Инструменты → Конверсии → Создать → Импорт → Клики. Тип действия — «Website (Import from clicks)».
Откройте созданную конверсию — в адресной строке параметр ctId=.... Это ваш conversion_action_id, понадобится в скрипте.
2. Создайте сервис-аккаунт в Google Cloud
- Заведите проект на console.cloud.google.com, включите в нём Data Manager API
- Создайте service account (IAM & Admin → Service Accounts → Create), скачайте JSON-ключ
- В своём Google Ads: Пользователи → Пригласить пользователя → впишите e-mail сервис-аккаунта (поле
client_emailиз JSON-ключа) → права Standard
3. Разверните скрипт на Cloudflare
- Заведите аккаунт на dash.cloudflare.com (бесплатно)
- Поставьте wrangler:
npm install -g wrangler, затемwrangler login - Скопируйте
wrangler.tomlиsrc/worker.jsиз раздела «Скрипт» ниже к себе в папку wrangler kv:namespace create TOKENS— полученный ID впишите вwrangler.toml- Задайте секреты (команды ниже) и выполните
wrangler deploy— получите свой адрес видаhttps://ваш-воркер.ваш-субдомен.workers.dev
wrangler secret put SERVICE_ACCOUNT_JSON # весь JSON-ключ из шага 2 wrangler secret put GOOGLE_ADS_CUSTOMER_ID # ваш customer_id, без дефисов wrangler secret put CONVERSION_ACTION_ID # ваш ctId из шага 1 wrangler secret put POSTBACK_SECRET # придумайте любой случайный токен
4. Прокиньте gclid до нашей трекинг-ссылки
Важно понимать: {gclid} — это макрос именно Google Ads, и подставляется он только в тот URL, который вписан напрямую в поле «Конечный URL» в настройках вашей кампании. Дальше зависит от того, ведёт реклама сразу на нашу ссылку или через прокладку.
Если объявление ведёт сразу на нашу трекинг-ссылку — всё просто, в Конечном URL в Google Ads пишете:
ссылканаблек.com?sub_id5={gclid}
Google сам подставит реальный gclid при клике, он долетит до нас без доп. действий.
Если между рекламой и нашей ссылкой стоит прокладка (клоака, промежуточный лендинг) — тогда в Google Ads вписан не наш линк, а адрес этой прокладки, и подстановка {gclid} происходит там, а не в нашей ссылке:
Конечный URL в Google Ads: сайтсклоакой.com?gclid={gclid}
В этом случае саму прокладку нужно донастроить: она должна прочитать параметр gclid из входящего запроса (реальное значение, которое уже подставил Google) и прокинуть его дальше при редиректе на нашу ссылку:Клик по рекламеGoogle Ads подставляет gclidсайтсклоакой.com?gclid=Cj0KCQjw9L3…ссылканаблек.com?sub_id5=Cj0KCQjw9L3…Наша платформаКлик сохранён с этим id
Синие блоки — это один и тот же клик, только под разными именами параметра: клоака получает значение от Google как gclid, а дальше сама подставляет его в ссылку на блек уже как sub_id5.
Если вписать {gclid} не в Google Ads, а прямо в настройки прокладки как её собственный редирект-URL — до нас долетит буквальный текст {gclid} (семь символов), а не настоящий click id: подстановку делает только Google Ads, и только для того URL, который видит сам.
Если sub_id5 занят под что-то своё — берите любой свободный слот из sub_id1-5 или aff_param1-5.
5. Пропишите постбек в личном кабинете нашей платформы
В настройках постбека на цель Approved укажите адрес своего воркера из шага 3:
https://ваш-воркер.ваш-субдомен.workers.dev/postback ?secret=ВАШ_POSTBACK_SECRET &gclid={sub_id5} &txid={transaction_id} &value={amount} ¤cy={offer_currency} &status={conv_status}
(замените {sub_id5} на тот слот, который использовали в шаге 4)
Так это будет выглядеть с уже подставленными реальными значениями, когда сработает постбек на конкретную конверсию (у вас, конечно, будут свои воркер, секрет и gclid — здесь просто наглядный пример):
https://gads-conversion-relay.egor-worker.workers.dev/postback?secret=x7K9pL2mQwZ4vN8t&gclid=Cj0KCQjw9L3BhDwARIsAM7MKi3vN2xY7pQrT4wJ8mK1lS6fH3dG9cV-abc123&txid=TX-8842910&value=45.00¤cy=USD&status=approved
Важно: в Google должны улетать только реальные подтверждённые (Approved) конверсии, привязанные к настоящим кликам. Отправка неподтверждённых или сфабрикованных событий как реальных конверсий — нарушение политик Google Ads и риск для вашего аккаунта.
Готово. Дальше ничего делать не нужно — при подтверждённой конверсии по вашему трафику скрипт сам отправит её в ваш Google Ads с привязкой к клику.
Скрипт
wrangler.toml
name = "gads-conversion-relay" main = "src/worker.js" compatibility_date = "2025-01-01" kv_namespaces = [ { binding = "TOKENS", id = "ВАШ_KV_NAMESPACE_ID" } ]
src/worker.js
export default { async fetch(request, env) { const url = new URL(request.url); if (url.pathname !== '/postback') { return new Response('Not found', { status: 404 }); } const p = url.searchParams; if (p.get('secret') !== env.POSTBACK_SECRET) { return new Response('Forbidden', { status: 403 }); } // шлём в Google только подтверждённые конверсии const status = (p.get('status') || '').toLowerCase(); if (status && status !== 'approved') { return new Response('Skipped (not approved)', { status: 200 }); } const gclid = p.get('gclid'); const transactionId = p.get('txid'); const value = parseFloat(p.get('value') || '0'); const currency = (p.get('currency') || 'USD').toUpperCase(); if (!gclid || !transactionId) { return new Response('Missing gclid or txid', { status: 400 }); } try { const accessToken = await getAccessToken(env); const result = await sendConversion(env, accessToken, { gclid, transactionId, value, currency, }); return new Response(JSON.stringify(result), { status: 200, headers: { 'content-type': 'application/json' }, }); } catch (err) { console.error(err); return new Response(`Error: ${err.message}`, { status: 500 }); } }, }; /* ---------- OAuth2: JWT-bearer flow для service account ---------- */ async function getAccessToken(env) { const cached = await env.TOKENS.get('access_token', { type: 'json' }); if (cached && cached.expiresAt > Date.now() + 30_000) { return cached.token; } const sa = JSON.parse(env.SERVICE_ACCOUNT_JSON); const now = Math.floor(Date.now() / 1000); const header = { alg: 'RS256', typ: 'JWT' }; const claims = { iss: sa.client_email, scope: 'https://www.googleapis.com/auth/datamanager', aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600, }; const unsigned = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claims))}`; const signature = await signRS256(unsigned, sa.private_key); const assertion = `${unsigned}.${signature}`; const resp = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion, }), }); const data = await resp.json(); if (!resp.ok) { throw new Error(`Token exchange failed: ${JSON.stringify(data)}`); } await env.TOKENS.put( 'access_token', JSON.stringify({ token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 }), { expirationTtl: data.expires_in } ); return data.access_token; } function base64url(input) { const bytes = new TextEncoder().encode(input); let bin = ''; bytes.forEach((b) => (bin += String.fromCharCode(b))); return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } async function signRS256(data, pem) { const key = await importPrivateKey(pem); const sig = await crypto.subtle.sign( 'RSASSA-PKCS1-v1_5', key, new TextEncoder().encode(data) ); let bin = ''; new Uint8Array(sig).forEach((b) => (bin += String.fromCharCode(b))); return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } async function importPrivateKey(pem) { const contents = pem .replace('-----BEGIN PRIVATE KEY-----', '') .replace('-----END PRIVATE KEY-----', '') .replace(/\s/g, ''); const raw = Uint8Array.from(atob(contents), (c) => c.charCodeAt(0)); return crypto.subtle.importKey( 'pkcs8', raw.buffer, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'] ); } /* ---------- Отправка события в Data Manager API ---------- */ async function sendConversion(env, accessToken, { gclid, transactionId, value, currency }) { const body = { destinations: [ { operatingAccount: { accountType: 'GOOGLE_ADS', accountId: env.GOOGLE_ADS_CUSTOMER_ID, }, productDestinationId: env.CONVERSION_ACTION_ID, }, ], events: [ { eventTimestamp: new Date().toISOString(), transactionId, adIdentifiers: { gclid }, currency, conversionValue: value, eventSource: 'WEB', }, ], validateOnly: false, // на этапе теста поставьте true }; const resp = await fetch('https://datamanager.googleapis.com/v1/events:ingest', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'content-type': 'application/json', }, body: JSON.stringify(body), }); const data = await resp.json(); if (!resp.ok) { throw new Error(`Data Manager API error: ${JSON.stringify(data)}`); } return data; }
Частые вопросы
Долго ждать, пока конверсии появятся в оптимизации ставок?
У новой конверсии в Google Ads есть 14-дневный технический период — она сразу видна в отчётах, но участвует в назначении ставок только по его истечении. Это особенность самого Google Ads.
Почему постбек только на Approved?
Отправка неподтверждённых или отклонённых конверсий как реальных — нарушение политик Google Ads и риск для вашего аккаунта. Скрипт и сам отфильтрует по статусу, но лучше сразу настроить постбек только на Approved.
Нужно ли что-то платить?
Нет. Data Manager API бесплатный, Cloudflare Workers на бесплатном тарифе покрывает 100 000 запросов в день.
Как проверить перед боем?
Пока в скрипте не выставлено validateOnly: false, Google валидирует запрос, но не засчитывает конверсию. Проверьте вручную (дёрните /postback с тестовыми параметрами, убедитесь в ответе 200 OK), и только потом переключайте на false.
Вывод
Автоимпорт конверсий — бесплатная и разовая настройка, которая даёт Google Ads реальный сигнал о результатах ваших кампаний вместо простого счётчика кликов. Как только связка настроена — она работает сама, без дальнейшего вмешательства.















