TL;DR
This tutorial shows how to wire Google Ads lead forms directly into HubSpot using OpenClaw. You will expose a secure webhook, validate the shared key, parse user_column_data, dedupe by lead_id, and upsert contacts with properties your sales team needs. The result is a reliable lead generation pipeline that moves data in seconds, not hours. Follow the steps to test with sample payloads, add enrichment, and alert a rep without manual exports. This guide focuses on clear field mapping, idempotency, and safe upserts so your lead generation stays accurate as volume grows.
What you will build
You will create a simple but production ready flow that accepts Google Ads lead form payloads, transforms them into HubSpot contact properties, applies idempotency and basic validation, enriches selectively, and routes to sales. The end state is an always on path from ad click to CRM contact with attribution captured.
- Ingest: Google Ads sends an HTTP POST to your OpenClaw webhook.
- Verify: Your handler checks the configured key and the payload shape.
- Transform: Map user_column_data to a flat object for HubSpot.
- Dedupe: Use lead_id plus email based upsert to avoid duplicates.
- Create or update: Call HubSpot Contacts API with an Authorization bearer token.
- Notify: Post a message to your internal channel when a qualified contact is created.
If you are new to ButterGrow, start with the hosted OpenClaw assistant on the ButterGrow platform, then review the AI marketing automation features to understand how webhooks, tasks, and policies fit together.
Prerequisites
- A Google Ads account with permission to create lead form assets and configure webhooks.
- A HubSpot account with a private app token that can write contacts.
- An OpenClaw gateway reachable on HTTPS. You can get started in minutes.
- A secure place to store secrets. Use your environment manager and avoid hardcoding tokens.
- A way to send test payloads. The Google Ads UI offers a Send test data button, and you can also use curl.
For a related walkthrough using a different network, see our step by step LinkedIn Leads to HubSpot workflow guide.
Architecture and data flow
- Google Ads lead form submits to your webhook. The body is JSON that contains lead_id, metadata such as campaign_id and gcl_id, and an array named user_column_data with column_id and string_value pairs.
- Your OpenClaw playbook receives the POST, validates a shared key, and normalizes the fields.
- The playbook checks an idempotency store with the lead_id. If already processed, return HTTP 200 without writing to avoid duplicates.
- If new, the playbook upserts a HubSpot contact. It sets standard properties like email and firstname, plus custom properties for attribution.
- The playbook optionally enriches the record and posts a message to your sales channel.
Data model cheat sheet
Use a simple mapping from Google column IDs to HubSpot properties. Extend as needed.
| Google column_id | Meaning | HubSpot property |
|---|---|---|
| Email address | ||
| PHONE_NUMBER | Phone | phone |
| FIRST_NAME | First name | firstname |
| LAST_NAME | Last name | lastname |
| CITY | City | city |
| POSTAL_CODE | Postal code | zip |
For attribution, also capture lead_id, form_id, campaign_id, adgroup_id, creative_id, and gcl_id in custom properties. This lets your downstream workflows score, segment, and run audits without guesswork.
Implementation
Step 1Create a webhook route in OpenClaw
Define a route and minimal processing steps in a playbook. This example shows a POST endpoint, a lightweight guard that validates the shared key, and a transformer step that maps the payload into HubSpot ready properties.
# openclaw/playbooks/google_ads_leads.yaml
name: google_ads_leads
triggers:
- type: http
method: POST
path: /hooks/google-ads/lead
auth: none
timeout: 8s
steps:
- id: verify_key
run: js
source: |
const provided = request.query.key || request.headers['x-google-ads-key'] || request.body.google_key;
if (!provided || provided !== env.GADS_WEBHOOK_KEY) {
return respond(400, {error: 'invalid key'});
}
- id: normalize
run: js
source: |
const b = request.body || {};
const map = {};
for (const col of b.user_column_data || []) {
const id = (col.column_id || '').toUpperCase();
const val = col.string_value || '';
if (id === 'EMAIL') map.email = val;
if (id === 'PHONE_NUMBER') map.phone = val;
if (id === 'FIRST_NAME') map.firstname = val;
if (id === 'LAST_NAME') map.lastname = val;
if (id === 'CITY') map.city = val;
if (id === 'POSTAL_CODE') map.zip = val;
}
state.contact = {
...map,
gads_lead_id: b.lead_id,
gads_campaign_id: String(b.campaign_id || ''),
gads_form_id: String(b.form_id || ''),
gads_gclid: b.gcl_id || b.gclid || '',
gads_is_test: Boolean(b.is_test)
};
- id: idempotency_check
run: kv.get
with:
store: leads
key: "gads:lead:${{ state.contact.gads_lead_id }}"
- id: short_circuit_if_seen
when: "steps.idempotency_check.value != null"
run: respond
with:
status: 200
json:
deduped: true
- id: write_hubspot
run: http
with:
url: https://api.hubapi.com/crm/objects/2026-09/contacts
method: POST
headers:
Authorization: "Bearer ${{ env.HUBSPOT_TOKEN }}"
Content-Type: application/json
json:
properties:
email: "${{ state.contact.email }}"
firstname: "${{ state.contact.firstname }}"
lastname: "${{ state.contact.lastname }}"
phone: "${{ state.contact.phone }}"
city: "${{ state.contact.city }}"
zip: "${{ state.contact.zip }}"
gads_lead_id: "${{ state.contact.gads_lead_id }}"
gads_campaign_id: "${{ state.contact.gads_campaign_id }}"
gads_form_id: "${{ state.contact.gads_form_id }}"
gads_gclid: "${{ state.contact.gads_gclid }}"
gads_is_test: "${{ state.contact.gads_is_test }}"
- id: upsert_on_conflict
when: "steps.write_hubspot.status >= 400 && state.contact.email"
run: http
with:
url: "https://api.hubapi.com/crm/objects/2026-09/contacts/search"
method: POST
headers:
Authorization: "Bearer ${{ env.HUBSPOT_TOKEN }}"
Content-Type: application/json
json:
filterGroups:
- filters:
- propertyName: email
operator: EQ
value: "${{ state.contact.email }}"
- id: maybe_update
when: "steps.upsert_on_conflict.status == 200 && steps.upsert_on_conflict.body.total > 0"
run: http
with:
url: "https://api.hubapi.com/crm/objects/2026-09/contacts/${{ steps.upsert_on_conflict.body.results[0].id }}"
method: PATCH
headers:
Authorization: "Bearer ${{ env.HUBSPOT_TOKEN }}"
Content-Type: application/json
json:
properties: "${{ state.contact }}"
- id: mark_processed
run: kv.put
with:
store: leads
key: "gads:lead:${{ state.contact.gads_lead_id }}"
value: "ok"
ttl: 172800
- id: ack
run: respond
with:
status: 200
json:
ok: true
Notes:
- The route accepts the shared key in a query parameter, header, or body field named google_key. Pick one and standardize in production.
- The idempotency store uses a two day TTL. Adjust to your sales cycle and retry policy.
- The HubSpot endpoint version in the path is illustrative. Use the version your tenant supports.
Step 2Configure the webhook in Google Ads
In the Google Ads UI, open the lead form asset and add a webhook delivery endpoint that points to your OpenClaw path. Use the same key you configured as env.GADS_WEBHOOK_KEY. Google offers a Send test data button to validate end to end. The payload includes is_test so you can branch logic during early setup.
Step 3Validate keys and shape with a lightweight Express server
If you prefer to run a small service behind OpenClaw or to test locally, this Node.js snippet shows the same verification and mapping logic. It extracts column values into a flat object and returns HTTP 200 only after a successful write to HubSpot.
// server.ts
import express from 'express';
import fetch from 'node-fetch';
const app = express();
app.use(express.json());
const GADS_WEBHOOK_KEY = process.env.GADS_WEBHOOK_KEY || '';
const HUBSPOT_TOKEN = process.env.HUBSPOT_TOKEN || '';
const seen = new Map<string, number>();
function normalize(body: any) {
const out: any = {};
for (const col of body.user_column_data || []) {
const id = String(col.column_id || '').toUpperCase();
const val = col.string_value || '';
if (id === 'EMAIL') out.email = val;
if (id === 'PHONE_NUMBER') out.phone = val;
if (id === 'FIRST_NAME') out.firstname = val;
if (id === 'LAST_NAME') out.lastname = val;
if (id === 'CITY') out.city = val;
if (id === 'POSTAL_CODE') out.zip = val;
}
out.gads_lead_id = body.lead_id;
out.gads_campaign_id = String(body.campaign_id || '');
out.gads_form_id = String(body.form_id || '');
out.gads_gclid = body.gcl_id || body.gclid || '';
out.gads_is_test = Boolean(body.is_test);
return out;
}
app.post('/hooks/google-ads/lead', async (req, res) => {
const provided = req.query.key || req.header('x-google-ads-key') || req.body.google_key;
if (!provided || provided !== GADS_WEBHOOK_KEY) return res.status(400).json({ error: 'invalid key' });
const c = normalize(req.body || {});
if (!c.gads_lead_id) return res.status(422).json({ error: 'missing lead_id' });
if (seen.has(c.gads_lead_id)) return res.status(200).json({ deduped: true });
const resp = await fetch('https://api.hubapi.com/crm/objects/2026-09/contacts', {
method: 'POST',
headers: {
Authorization: `Bearer ${HUBSPOT_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ properties: c })
});
if (resp.status >= 400 && c.email) {
const search = await fetch('https://api.hubapi.com/crm/objects/2026-09/contacts/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${HUBSPOT_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ filterGroups: [{ filters: [{ propertyName: 'email', operator: 'EQ', value: c.email }] }] })
});
if (search.ok) {
const data = await search.json();
if (data.total > 0) {
const id = data.results[0].id;
const upd = await fetch(`https://api.hubapi.com/crm/objects/2026-09/contacts/${id}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${HUBSPOT_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ properties: c })
});
if (!upd.ok) return res.status(502).json({ error: 'hubspot update failed' });
}
}
}
seen.set(c.gads_lead_id, Date.now());
res.status(200).json({ ok: true });
});
app.listen(8787, () => console.log('listening on 8787'));
Step 4Test the path end to end
Use the Google Ads Send test data function first. Then run a local test to validate your mapping and idempotency before you point production traffic at the endpoint. The following curl sends a simplified sample with two user_column_data entries and a fake key.
curl -sS \
-X POST "https://your-domain.example/hooks/google-ads/lead?key=YOUR_SHARED_KEY" \
-H 'Content-Type: application/json' \
-d '{
"lead_id": "TeSter-123-ABC",
"campaign_id": 22404897829,
"form_id": 317442547680,
"gcl_id": "GCL.1234",
"is_test": true,
"user_column_data": [
{"column_id": "EMAIL", "string_value": "alex@example.com"},
{"column_id": "FIRST_NAME", "string_value": "Alex"}
]
}'
Expect HTTP 200 for success. If you see 400 with invalid key, verify you used the same shared key in Google Ads and your receiver. If you see 422, ensure lead_id and user_column_data are present.
Step 5Add guardrails and enrichment
Good flows handle the messy bits. Add the following safeguards and extras.
- Rate limits: If volume spikes, use OpenClaw queues and a small worker pool. This prevents hot spots in your downstream APIs.
- Idempotency: Keep your cache TTL longer than the network retry window. Two days is a safe baseline.
- Field validation: Reject payloads that have no email and no phone. Return 422 so the sender knows it failed validation.
- Enrichment: For non test records, you can add a business email check or company lookup. Only proceed when you have a compliant basis for enrichment.
- Notifications: Send a succinct post to your internal channel when a qualified contact is created. Include contact name, source, and campaign.
Here is a small add on step that posts to a channel once the contact exists:
- id: notify_sales
when: "steps.ack.status == 200 && !state.contact.gads_is_test"
run: post.message
with:
channel: sales
text: "New contact from Google Ads. ${ {state.contact.firstname} } ${ {state.contact.lastname} } ${ {state.contact.email} }"
Step 6Map variants and custom questions
Google Ads lead forms allow custom questions that arrive as column_id values you define. Keep a dictionary that maps those IDs to custom properties in HubSpot. Unknown fields should be ignored rather than causing the entire payload to fail. This keeps your flow resilient when marketers tweak forms.
// custom-map.ts
export const CUSTOM_QUESTION_MAP: Record<string, string> = {
// column_id: hubspot_property
'WHAT_IS_YOUR_BUDGET': 'budget_range',
'HOW_SOON_ARE_YOU_PLANNING_TO_BUY': 'buying_timeline'
};
Blend this with the earlier normalize function. If a column_id exists in your map, write the string_value into the corresponding property name.
Step 7Production hardening checklist
Before you roll out, complete this short checklist.
- HTTPS. Terminate TLS at the edge and forward requests to the gateway securely.
- Secrets. Store your Google key and HubSpot token in your secret manager.
- Logging. Log failures with lead_id and reason. Avoid logging PII.
- Monitoring. Alert on error rate, not raw volume. Track HTTP 5xx and 4xx.
- Rollback. Keep a fast switch to disable the route or pause writes if something goes wrong.
If you are evaluating whether to use OpenClaw yourself or through a hosted option, the AI marketing automation features page and answers to common questions provide more context on guardrails, cost controls, and reliability patterns.
Step 8Variations to consider
Once the base flow is stable, extend it with one or two quick wins.
- Lead gen from multiple networks. Reuse the same pattern for other sources that post webhooks. Keep a separate id namespace per source.
- Fast handoff. Assign owner based on territory, product line, or campaign using a small routing table.
- Scoring. Add a score based on firmographic data and campaign intent.
- Follow up. Create a trackable task for a rep and send a reminder if there is no activity after a set time.
Troubleshooting common errors
- 400 invalid key. The shared key in Google Ads must match env.GADS_WEBHOOK_KEY in your receiver. Update one side and test again.
- 422 missing lead_id. Ensure you are not posting through an intermediate tool that strips fields. Post the raw body to your handler.
- 5xx during HubSpot write. Back off with jitter and retry. If HubSpot rate limits, respect the headers and queue.
- Duplicate contacts. Confirm you mark processed lead_id values and prefer update when email already exists.
If you want this wired without the lift of hosting and upkeep, ButterGrow gives you a hosted OpenClaw assistant with playbook templates for Google Ads forms, HubSpot upsert, and alerting. Use the onboarding flow to get started in minutes, then import the mapping and test with your own campaign.
References
- Lead form webhook overview - Summary of how Google Ads lead form webhooks deliver data and how the shared key works.
- Lead form webhook implementation and payload schema - Field descriptions and guidance for parsing user_column_data and handling duplicates.
- Create a contact using the HubSpot Contacts API - Official endpoint details for creating a contact with properties.
Frequently Asked Questions
Where do I find the webhook URL and key for Google Ads lead forms in the UI?+
Open the lead form asset in Google Ads and choose Webhook delivery to set a destination endpoint and key. The key is a value you control. Store the same value in your receiver so incoming requests can be verified before processing.
How do I map Google Ads user_column_data to HubSpot contact properties?+
Each entry has a column_id and string_value. Map EMAIL to email, PHONE_NUMBER to phone, FIRST_NAME to firstname, and LAST_NAME to lastname. Keep a lookup for optional fields like CITY or POSTAL_CODE and ignore unknown columns for forward compatibility.
How should I dedupe leads so HubSpot does not create duplicates?+
Use the Google lead_id as an idempotency key and also upsert on email in HubSpot. Cache processed lead_id values for 24 to 48 hours and always prefer update if a contact with the same email already exists.
What is the difference between test leads and real leads from Google Ads?+
Test leads include is_test set to true and often use placeholder values. Accept them for validation, skip enrichment and scoring, and mark them as test in your logs. Do not route test records to sales.
How do I handle retries from Google Ads and avoid processing the same record twice?+
Return HTTP 200 only after a successful write to HubSpot and your idempotency cache. If something transient fails, return a 500 so Google retries. Use a short TTL cache or a durable store keyed by lead_id to prevent duplicates.
Can I add consent flags or UTM parameters to the contact when creating it in HubSpot?+
Yes. Extend your mapping to include consent status, form ID, campaign ID, gcl_id, and UTM parameters if present. Persist these as custom properties so downstream workflows can segment and audit properly.
Ready to try ButterGrow?
See how ButterGrow can supercharge your growth with a quick demo.
Book a Demo