TL;DR
This tutorial shows you how to connect your CRM to Google Ads and import verified offline conversions using ButterGrow and OpenClaw. You will capture click identifiers, map CRM events, hash PII for Enhanced Conversions, and post to the correct API endpoints using robust retries. The result is accurate bid signals that reflect sales outcomes instead of only page views. We keep the core flow accessible with no-code automation where it fits and a small amount of server side code for hashing and API calls.
Why offline conversions matter
Google Ads optimizes against the signals you send. If you only upload form submits, the system often overvalues cheap leads and undervalues qualified opportunities. Importing offline conversions aligns bidding with pipeline and revenue. Brands that switch from form submissions to qualified lead or opportunity creation as the primary signal typically see steadier CPAs and better budget allocation within a few weeks.
What you will build
You will implement a reliable pipeline that:
- Captures
gclid,gbraid, orwbraidon landing pages and threads them into your CRM. - Normalizes and hashes emails or phone numbers for Enhanced Conversions for leads.
- Uses an OpenClaw playbook to transform CRM events into Google Ads API requests.
- Enforces idempotency, retries with backoff, and a dead letter path for failures.
- Backfills historical conversions without creating duplicates.
Prerequisites
- A Google Ads account with permission to create conversion actions and access to the Google Ads API via a developer token and OAuth credentials.
- A CRM or database that stores form submissions or deals with timestamps and, ideally, the original click identifier.
- Access to ButterGrow with an OpenClaw workspace.
- One conversion action id in Google Ads for the event you plan to import, for example Qualified Lead or Won Opportunity.
Architecture overview
At a high level the flow is: web session captures a click id, the visitor submits a form or calls a tracked number, your CRM stores the lead with the click id, and OpenClaw picks up the CRM event to post an offline conversion. For lead quality, also send hashed email or phone as Enhanced Conversions for leads. The playbook ensures the upload happens once per event and retries safely on transient errors.
Step 1Create the conversion action in Google Ads
Before any uploads, create a dedicated conversion action for the offline event you will import. Set the category to Lead or Purchase depending on your use case and decide whether to include the action in Conversions for bidding. Record the conversion action id for API calls.
Tips
- Use separate actions for form submit and qualified lead so you have clean reporting.
- Keep the currency consistent with your CRM. If you value a qualified lead at 50, use the same currency code in uploads.
Step 2Capture click identifiers on your site
You need to propagate ad click identifiers through forms and into your CRM. The gclid is used for web clicks. On some iOS and Android flows Google Ads may supply gbraid or wbraid. Store any identifier you receive alongside the lead.
Example: capture from URL and store in a cookie
// capture-click-id.js
function getParam(name) {
const params = new URLSearchParams(window.location.search);
return params.get(name);
}
function setCookie(name, value, days) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
}
['gclid', 'gbraid', 'wbraid'].forEach((k) => {
const v = getParam(k);
if (v) setCookie(k, v, 90);
});
Example: inject a hidden field before form submit
// attach-click-id-to-form.js
function readCookie(name) {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? decodeURIComponent(match[2]) : null;
}
function addHidden(name, value, form) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value;
form.appendChild(input);
}
document.addEventListener('submit', (e) => {
const form = e.target;
const gclid = readCookie('gclid');
const gbraid = readCookie('gbraid');
const wbraid = readCookie('wbraid');
if (gclid) addHidden('gclid', gclid, form);
if (gbraid) addHidden('gbraid', gbraid, form);
if (wbraid) addHidden('wbraid', wbraid, form);
});
Step 3Persist identifiers in your CRM
When the form lands in your backend, attach the click id to the contact or lead record with the event timestamp in UTC. For phone calls, choose a call tracking provider that can collect a gclid or a call specific id that maps to an ad interaction. The more complete the thread from click to lead to opportunity, the higher your match rate and the better your reporting.
Suggested field mapping
| CRM field | Description | Google Ads field |
|---|---|---|
click_id |
gclid or gbraid or wbraid | gclid or gbraid or wbraid |
lead_created_at |
UTC datetime when the lead was created | conversion_date_time |
conversion_action_id |
Numeric id from Google Ads | conversionAction |
email |
Lowercased and trimmed | Hashed email for Enhanced Conversions |
phone |
E164 formatted | Hashed phone for Enhanced Conversions |
value |
Monetary amount for the conversion | value with currencyCode |
Step 4Normalize and hash PII for Enhanced Conversions for leads
Google accepts hashed identifiers for Enhanced Conversions for leads. Always normalize before hashing. Hashing should happen on the server and not in the browser.
// hash-identifiers.js (Node.js)
import crypto from 'node:crypto';
function normalizeEmail(email) {
return email.trim().toLowerCase();
}
function normalizePhone(phone) {
// Expect phone with country code. Remove non digits.
return phone.replace(/\D/g, '');
}
function sha256(value) {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
}
export function hashIdentifiers({ email, phone }) {
const result = {};
if (email) result.hashedEmail = sha256(normalizeEmail(email));
if (phone) result.hashedPhoneNumber = sha256(normalizePhone(phone));
return result;
}
Step 5Configure an OpenClaw playbook to listen for CRM events
In ButterGrow, create a playbook that subscribes to your CRM webhook for new or updated leads and deals. The playbook enriches the event, builds a Google Ads payload, and posts it with retries. Below is a minimal example that you can adapt.
# openclaw-playbooks/google-ads-offline.yml
name: google-ads-offline-conversions
triggers:
- type: http
path: /webhooks/crm/lead-updated
methods: [POST]
steps:
- id: normalize
uses: js/transform
with:
code: |
const { hashIdentifiers } = require('./hash-identifiers');
const body = event.body;
const ids = hashIdentifiers({ email: body.email, phone: body.phone });
const key = `${body.click_id || 'no-click'}:${body.conversion_action_id}:${new Date(body.lead_created_at).toISOString()}`;
return {
key,
clickId: body.click_id,
conversionActionId: body.conversion_action_id,
conversionDateTime: new Date(body.lead_created_at).toISOString().replace('T', ' ').replace('Z', ' +00:00'),
value: body.value || 0,
currencyCode: body.currency || 'USD',
enhanced: ids,
};
- id: upload
uses: http/request
with:
url: https://googleads.googleapis.com/v17/customers/${{ secrets.GADS_CUSTOMER_ID }}:uploadClickConversions
method: POST
headers:
Authorization: Bearer ${{ secrets.GADS_ACCESS_TOKEN }}
developer-token: ${{ secrets.GADS_DEV_TOKEN }}
content-type: application/json
body: |
{
"partialFailure": true,
"validateOnly": false,
"customerId": "${{ secrets.GADS_CUSTOMER_ID }}",
"conversions": [
{
"gclid": ${ steps.normalize.output.clickId && steps.normalize.output.clickId.startsWith('gclid') ? "\"" + steps.normalize.output.clickId + "\"" : null },
"gbraid": ${ steps.normalize.output.clickId && steps.normalize.output.clickId.startsWith('g') ? "\"" + steps.normalize.output.clickId + "\"" : null },
"wbraid": ${ steps.normalize.output.clickId && steps.normalize.output.clickId.startsWith('w') ? "\"" + steps.normalize.output.clickId + "\"" : null },
"conversionAction": "customers/${{ secrets.GADS_CUSTOMER_ID }}/conversionActions/${{ steps.normalize.output.conversionActionId }}",
"conversionDateTime": "${{ steps.normalize.output.conversionDateTime }}",
"currencyCode": "${{ steps.normalize.output.currencyCode }}",
"value": ${{ steps.normalize.output.value }},
"userIdentifiers": [
${ steps.normalize.output.enhanced.hashedEmail ? `{ "hashedEmail": "${steps.normalize.output.enhanced.hashedEmail}" }` : '' }
${ steps.normalize.output.enhanced.hashedPhoneNumber ? `, { "hashedPhoneNumber": "${steps.normalize.output.enhanced.hashedPhoneNumber}" }` : '' }
]
}
]
}
retry:
attempts: 6
backoff: exponential
on:
- 429
- 500
- 503
- id: record-success
uses: storage/put
with:
bucket: conversions-log
key: ${ steps.normalize.output.key }
value: ${ steps.upload.response.body }
onFailure:
- id: dead-letter
uses: storage/put
with:
bucket: conversions-dlq
key: ${ steps.normalize.output.key }
value: ${ event.body }
This playbook relies on secrets for your Google Ads developer token, OAuth access token, and customer id. The key is a deterministic idempotency token that prevents duplicates when the same event is retried or backfilled.
Step 6Call the Google Ads API directly from a server if you prefer low code
Some teams want the hashing and HTTP call in their own service. The example below shows a minimal Node.js POST to the Upload Click Conversions endpoint. Replace the version in the URL with the latest supported version in your account.
// upload-click-conversion.js
import fetch from 'node-fetch';
import { hashIdentifiers } from './hash-identifiers.js';
export async function uploadClickConversion({
developerToken,
accessToken,
customerId,
gclid,
conversionActionId,
conversionDateTime,
value,
currencyCode,
email,
phone,
}) {
const ids = hashIdentifiers({ email, phone });
const body = {
partialFailure: true,
conversions: [
{
gclid,
conversionAction: `customers/${customerId}/conversionActions/${conversionActionId}`,
conversionDateTime,
value,
currencyCode,
userIdentifiers: [
ids.hashedEmail ? { hashedEmail: ids.hashedEmail } : null,
ids.hashedPhoneNumber ? { hashedPhoneNumber: ids.hashedPhoneNumber } : null,
].filter(Boolean),
},
],
};
const res = await fetch(`https://googleads.googleapis.com/v17/customers/${customerId}:uploadClickConversions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'developer-token': developerToken,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Upload failed ${res.status} ${text}`);
}
return res.json();
}
Step 7Validate timestamps and time zones
Google requires the conversion time in the format YYYY-MM-DD HH:MM:SS+|-HH:MM in UTC or with an explicit offset such as +00:00. Keep your servers synchronized and store both the ad interaction time and the conversion time. If you are importing a later stage such as Qualified Opportunity, use the time that stage was reached, not the original form submit time.
Step 8Add idempotency and retries
Idempotency prevents double counting. Use a stable key constructed from the click id, the conversion action id, and the conversion timestamp. Persist each successful upload with that key. Retries should be limited with exponential backoff and target only transient errors such as 429 or 5xx.
Example idempotency key
<click_id>:<conversion_action_id>:<ISO8601_UTC_timestamp>
Step 9Backfill historical conversions safely
Export the last 60 to 90 days of won deals or qualified leads with their original click ids and stage times. Run them through the same playbook with idempotency enabled. Pace the backfill at a few hundred per minute to stay within rate limits. After the backfill, keep the playbook running in stream mode for new events.
Step 10Verify and monitor
Use Google Ads conversion diagnostics to check status and match rates. Expect a delay of several hours before conversions show in reporting. Add a dashboard in ButterGrow that tracks events sent, accepted, and failed by reason code. Keep an eye on three metrics: partial failure rate below 3 percent, match rate of hashed identifiers above 50 percent for lead gen, and the ratio of qualified conversions to form submissions after launch.
Optional: Upload call conversions
If you track calls, use the Upload Call Conversions endpoint with the caller id and the call start time. Map your call tracking provider fields to the required API fields and follow the same idempotency and retry guidelines.
Optional: Use low code workflow patterns
The core of this tutorial is a no code workflow inside ButterGrow, but you can mix in small code steps where it improves clarity or control. For example, keep hashing and validation in code, while leaving event listening, retries, and logging in OpenClaw. This hybrid pattern keeps the pipeline maintainable without adding brittle glue scripts.
Testing checklist before launch
- Capture test clicks with a unique email and phone, then submit a form.
- Confirm the CRM record contains the click id and UTC timestamp.
- Run a dry run of your playbook with validate only set to true if available.
- Send a single conversion to a hidden Sandbox Lead action and verify receipt.
- Remove test labels and switch to the production conversion action id.
Optimization tips after launch
- Keep form submit uploads if they help with volume, but set them to secondary in Google Ads so bidding focuses on qualified outcomes.
- Revisit value settings every quarter so the imported values reflect current close rates and average deal sizes.
- If you see a low match rate, review normalization and hashing and include both email and phone when possible.
Internal resources
- Learn about the AI marketing automation features in ButterGrow by reviewing what ButterGrow does.
- If you are new to the product, you can get started in minutes with a template that includes webhooks and an HTTP request step.
- For a related server side pattern, see our guide to Meta Conversions API setup with OpenClaw.
- To explore more tutorials and analysis, browse other articles on the ButterGrow blog.
Ready to ship this pipeline without glue code headaches. Spin up the Google Ads offline import template inside ButterGrow and follow the onboarding flow in how to set it up. You will have a working playbook that sends qualified lead signals to Google in under an hour, with transparent logs and safe retries.
References
- Upload Click Conversions documentation - official Google Ads API reference for web click based imports.
- Enhanced Conversions for leads overview - hashing rules and payload format for emails and phone numbers.
Frequently Asked Questions
What identifiers should I capture for Google Ads offline conversions in web forms and call tracking?+
Collect the gclid for web clicks, and gbraid or wbraid for iOS and Android app traffic when present. Pass these values into hidden form fields and store them in your CRM with a timestamp. For calls, use a call tracking provider that records the gclid or a forwarding number id so you can map the call to an ad click.
How do I hash customer data correctly for Enhanced Conversions for leads?+
Normalize email and phone by trimming whitespace, lowercasing emails, removing punctuation from phone numbers, and applying SHA256 before sending. Hash on the server, not the browser. Include the country code for phone numbers. This improves match rates and keeps PII out of logs.
Which Google Ads API endpoint should I use to upload offline conversions?+
For web click based imports use the Upload Click Conversions endpoint. For phone call based imports use the Upload Call Conversions endpoint. The exact versioned path changes over time, so rely on the latest endpoint documented in Google Ads API docs and configure it in your HTTP step.
What attribution window does Google Ads apply to imported conversions?+
Click based offline conversions typically support up to 90 days from the ad interaction date to the conversion time. Always set the conversion timestamp in UTC and keep your pipeline clock in sync to avoid rejections. Check the account level setting in Google Ads for the precise lookback.
How can I backfill historical CRM deals into Google Ads without duplicates?+
Export historical won deals with their original click ids and timestamps, then run them through your OpenClaw playbook with idempotency keys. Use a deterministic key such as <gclid>-<conversion_action>-<timestamp> and log all uploads. The playbook should skip any key that has already been sent.
What is the simplest way to test uploads end to end before going live?+
Create a temporary conversion action named Sandbox Lead and set its status to hidden. Drive a test click, submit a form with a known email, and upload a conversion with a small test value. Confirm receipt in Google Ads within several hours, then swap to your production conversion action id.
Ready to try ButterGrow?
See how ButterGrow can supercharge your growth with a quick demo.
Book a Demo