TL;DR
This tutorial shows how to connect LinkedIn Lead Gen Forms to HubSpot using OpenClaw, with polling, field mapping, and safe upserts. You will set up auth, design a mapping layer, and schedule runs that pick up only new submissions. The result is a fast pipeline that reduces manual CSV uploads and shortens time to first touch. The primary focus is workflow automation, but we will also cover practical guardrails like rate limits, retries, and idempotency. You will also see a minimal playbook and example requests that you can run right away.
What You Will Build
You will build an automated workflow that pulls new submissions from LinkedIn Lead Gen Forms and creates or updates contacts in HubSpot. The flow uses OpenClaw for orchestration and reliability features such as retries, idempotency keys, and schedules. By the end, new LinkedIn leads will appear in HubSpot with normalized names, emails, phone numbers, and UTMs, and the run will safely skip anything it already processed.
If you are new to ButterGrow or OpenClaw, skim the AI marketing automation features at what ButterGrow does. You can always jump to answers to common questions if you need quick clarifications while implementing.
For adjacent techniques, see how we build lead scoring that feeds your CRM. It pairs well with this pipeline once contacts start flowing.
Prerequisites
- A LinkedIn Ads account with Lead Gen Forms and a developer app with the correct permissions.
- A HubSpot account with API access to the CRM objects you plan to update.
- An OpenClaw workspace in ButterGrow where you can create playbooks and schedules.
- Familiarity with JSON payloads, OAuth credentials, and basic REST.
Environment secrets to prepare
LI_ACCESS_TOKENfor the LinkedIn Marketing API.HS_PRIVATE_TOKENfor the HubSpot CRM API.ACCOUNTSandFORM_IDSas lists if you manage multiple ad accounts and forms.
Store these values in your workspace secret manager and reference them in steps. Never hardcode credentials in playbooks committed to source control.
Architecture Overview
At a high level, the pipeline performs five stages in serial order.
- Poll the LinkedIn Marketing API for new lead submissions since the last run checkpoint.
- Normalize and validate fields, including name splitting, email validation, phone formatting, and UTM coercion.
- Query HubSpot to find an existing contact by email or fallback composite key.
- Upsert the contact and attach original raw lead data to a debug property for traceability.
- Save the latest processed timestamp per form so the next run queries only incremental data.
The orchestrator is OpenClaw. We will implement the polling logic without webhooks to reduce moving parts and to keep permissions simple. The mapping layer sits in a dedicated transform step so you can unit test it in isolation and reuse it for other destinations.
Implementation Steps
Step 1Prepare API access
Create a LinkedIn developer app tied to your ad account and obtain an access token with scopes that include lead read for ads. Generate a HubSpot private app token with CRM read and write for contacts. Put both tokens in your workspace secret manager under the names LI_ACCESS_TOKEN and HS_PRIVATE_TOKEN.
Test tokens with curl before you wire them into a playbook.
curl -sS -H "Authorization: Bearer $LI_ACCESS_TOKEN" \
"https://api.linkedin.com/v2/adAccounts?q=search&search.account~{id}" | jq '.elements | length'
curl -sS -H "Authorization: Bearer $HS_PRIVATE_TOKEN" \
"https://api.hubapi.com/crm/v3/properties/contacts" | jq '.results | length'
If either call fails, recheck scopes and token freshness. For LinkedIn, confirm the app is approved for the advertiser you plan to poll.
Step 2Plan the data model
List the fields captured by your LinkedIn lead form, then map them to HubSpot contact properties. Decide which fields are required and which are optional. Set validation and normalization rules up front so you do not ship malformed data into your CRM.
Example mapping plan:
| LinkedIn field label | Normalized key | HubSpot property | Rule |
|---|---|---|---|
| First Name | first_name | firstname | Trim whitespace and capitalize |
| Last Name | last_name | lastname | Trim whitespace and capitalize |
| Lowercase and validate format | |||
| Phone Number | phone | phone | E164 format with country if present |
| Company Name | company | company | As provided |
| Job Title | title | jobtitle | As provided |
| UTM Source | utm_source | hs_latest_source | Keep raw and normalized copies |
| UTM Campaign | utm_campaign | hs_utm_campaign | Keep raw and normalized copies |
Record this plan in version control. It will become the contract between marketing and sales for this integration.
Step 3Build the OpenClaw playbook
Start with a minimal playbook that polls one form and writes one contact. Then add multiple forms and accounts once the happy path is working.
# playbooks/linkedin_to_hubspot.yaml
name: linkedin-to-hubspot
version: 1
triggers:
- type: schedule
cron: "*/5 * * * *" # poll every 5 minutes
vars:
form_id: "123456" # replace with your form id
account_id: "urn:li:sponsoredAccount:999999"
since_minutes: 10
steps:
- id: fetch_leads
uses: http.request
with:
method: GET
url: "https://api.linkedin.com/v2/leadFormResponses"
headers:
Authorization: "Bearer {{ secrets.LI_ACCESS_TOKEN }}"
params:
q: "leadGenFormId"
leadGenFormId: "{{ vars.form_id }}"
createdAfter: "{{ now() | minus_minutes(vars.since_minutes) | to_unix_ms }}"
retry:
attempts: 3
backoff: exponential
max_delay_ms: 20000
- id: normalize
uses: js.transform
with:
input: "{{ steps.fetch_leads.data.elements }}"
script: |
function toTitle(s) { return s ? s.trim().toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) : null; }
function cleanPhone(s) { return s ? s.replace(/[^\d+]/g, '') : null; }
function isEmail(s) { return /.+@.+\..+/.test(s || ''); }
const out = [];
for (const e of input) {
const li = e || {};
const f = li.fieldData || {};
const record = {
firstname: toTitle(f.firstName || f.first_name || null),
lastname: toTitle(f.lastName || f.last_name || null),
email: (f.email || f.Email || '').toLowerCase(),
phone: cleanPhone(f.phoneNumber || f.phone || null),
company: f.companyName || f.company || null,
jobtitle: f.jobTitle || f.title || null,
hs_latest_source: f.utm_source || null,
hs_utm_campaign: f.utm_campaign || null,
_raw: li
};
if (isEmail(record.email)) {
out.push(record);
}
}
return out;
- id: upsert
uses: foreach
with:
items: "{{ steps.normalize.data }}"
concurrency: 3
body:
- id: find_contact
uses: http.request
with:
method: POST
url: "https://api.hubapi.com/crm/v3/objects/contacts/search"
headers:
Authorization: "Bearer {{ secrets.HS_PRIVATE_TOKEN }}"
Content-Type: "application/json"
json:
filterGroups:
- filters:
- propertyName: email
operator: EQ
value: "{{ item.email }}"
properties: ["email"]
retry:
attempts: 3
backoff: exponential
- id: upsert_contact
uses: http.request
with:
method: "{{ steps.find_contact.data.total > 0 ? 'PATCH' : 'POST' }}"
url: "{{ steps.find_contact.data.total > 0 ? 'https://api.hubapi.com/crm/v3/objects/contacts/' + steps.find_contact.data.results[0].id : 'https://api.hubapi.com/crm/v3/objects/contacts' }}"
headers:
Authorization: "Bearer {{ secrets.HS_PRIVATE_TOKEN }}"
Content-Type: "application/json"
json:
properties:
email: "{{ item.email }}"
firstname: "{{ item.firstname }}"
lastname: "{{ item.lastname }}"
phone: "{{ item.phone }}"
company: "{{ item.company }}"
jobtitle: "{{ item.jobtitle }}"
hs_latest_source: "{{ item.hs_latest_source }}"
hs_utm_campaign: "{{ item.hs_utm_campaign }}"
li_raw_payload: "{{ item._raw | to_json }}"
retry:
attempts: 3
backoff: exponential
- id: checkpoint
uses: storage.write
with:
key: "li_form_{{ vars.form_id }}_latest_ts"
value: "{{ now() | to_unix_ms }}"
Notes:
- The fetch step filters by a createdAfter timestamp so each run only processes new submissions.
- The transform step rejects records without a valid email to keep your CRM clean.
- The upsert uses the HubSpot search endpoint to decide whether to update or create.
- The checkpoint writes the run timestamp to simple storage for the next poll.
Tip for multiple forms and accounts: parameterize form_id, account_id, and since_minutes, then iterate over a list of objects. Use a unique checkpoint key per form so you can move accounts independently without losing state.
Step 4Validate the mapping logic
Before you touch production, feed the transform with captured payloads from a test form. Keep three fixtures that represent common cases: a complete submission, a missing optional field, and malformed phone. Run the transform step locally and assert that it emits the correct properties for HubSpot. The goal is a stable mapping that does not drift when someone edits the form.
Example unit test for the normalization function:
// tests/normalize.test.js
const { normalize } = require('../transforms/normalize');
test('keeps only valid emails', () => {
const input = [
{ fieldData: { Email: 'ok@example.com', firstName: 'a', lastName: 'b' } },
{ fieldData: { Email: 'bad', firstName: 'x' } }
];
const out = normalize(input);
expect(out.length).toBe(1);
expect(out[0].email).toBe('ok@example.com');
});
You can also add property assertions for country calling codes and capitalization rules.
Step 5Test end to end in a sandbox
Create a sandbox form that posts predictable sample data, such as a test email domain and a unique campaign tag. Run the playbook once with the schedule disabled and inspect the contact created in HubSpot. Confirm that normalization rules applied and that the raw payload property stored the submission for audit.
Then run the playbook twice and verify that the second run performs no new writes. This checks your incremental query parameters and your search before upsert logic.
Step 6Schedule and monitor
Enable the schedule at 5 minutes. If your sales team needs faster handoffs, try 2 minutes but watch your LinkedIn and HubSpot rate limits. Add an alert for nonzero error counts and for sudden drops in processed items, which often signal a credential expiration or a form change.
For reporting, add a metric that tracks latency from submission time to CRM create time. Keep the 95th percentile under 10 minutes for most campaigns. You can improve this by increasing concurrency on the foreach step, but do not exceed your API rate limits.
Step 7Add enrichment and routing
Once the base contact is created, many teams enrich with firmographics and then route to sales by territory or segment. Insert an enrichment call after normalization. Merge the returned fields into the HubSpot payload. After that, call your router that creates a task or assigns the contact to an owner.
If you want a deeper play, pair this pipeline with our guide on build lead scoring that feeds your CRM.
Troubleshooting
Common API errors
- Unauthorized or permission denied. Recreate the token with appropriate scopes and confirm the ad account is in the app configuration.
- Too many requests. Increase the polling interval, enable exponential backoff, and lower foreach concurrency.
- Property not found in HubSpot. Create the custom property first or rename your key to match an existing property.
Dedupe collisions
When email is missing or malformed, rely on a composite key such as phone plus country code and last name. Store the composite in a hidden property and use it only as a last resort. Monitor the rate of composite matches and investigate any spikes.
Field drift after form edits
Marketing may change a field label or add a new question. Guard against drift by writing a unit test per field you depend on and checking the transform step into source control. Fail the run if a required field is missing rather than silently dropping data.
Data governance
Always collect only what you need. Avoid storing sensitive personal data unless you have a legitimate purpose. Keep your retention settings in sync with your privacy policy and your data processing agreements.
What this solves for your team
This automation workflow removes manual CSV downloads, improves lead response time, and reduces data quality issues. Sales gets contacts with consistently formatted names and phones. Marketing gains reliable UTMs and a clear audit trail. The operations team retains control through schedules, retries, and strict mapping contracts.
Building this pipeline is a concrete starting point for bigger systems. If you later add paid media feedback loops, revisit your upsert step to write scoring properties.
Long tail scenarios to keep in mind
- How to connect LinkedIn Lead Gen to HubSpot when some fields are optional.
- Field mapping for LinkedIn leads to HubSpot contacts with strict validation.
- Schedule a LinkedIn lead sync to run every 5 minutes with safe retries.
This checklist helps the implementation pass a real audit by sales and marketing operations.
Finally, if you need help designing the mapping or testing path, see more from the ButterGrow blog for tutorials that build on the same patterns.
Your next step is simple. Open your workspace, add the tokens, paste the playbook, and press run.
You now have a fast path from LinkedIn lead forms to a sales ready contact in HubSpot with a clean audit trail.
ButterGrow bundles OpenClaw with a visual playbook editor, shared secrets, and built in schedules. If you want a guided path, follow get started in minutes to import the playbook, connect tokens, and schedule your first run. With that in place, your team can focus on routing, scoring, and reporting.
References
- LinkedIn Marketing Developer Platform lead gen docs: official concepts and endpoints for lead forms and retrieval.
- HubSpot CRM Contacts API: reference for search, create, and update operations used by the upsert step.
Frequently Asked Questions
How do I authenticate the LinkedIn Marketing API in OpenClaw without exposing tokens?+
Store the access token and client secret in your workspace secret manager, then reference them in the HTTP step as environment variables. Rotate tokens on a schedule and scope permissions to the specific ad accounts you need.
What is the best dedupe rule for creating HubSpot contacts from LinkedIn leads?+
Use email as the primary key when possible, then fall back to a composite key such as hashed phone plus country code. Query HubSpot for an existing contact before creating one, and upsert rather than insert to avoid duplicates.
How often should I poll for new LinkedIn lead form submissions to keep ad SLAs tight?+
Polling every 5 minutes strikes a balance between freshness and API limits for most teams. Start at 5 minutes, measure latency to first touch in your CRM, and increase to 2 minutes only if your rate limits allow it.
Where should I put UTM parameters captured by lead forms in HubSpot?+
Map UTMs into custom contact properties like hs_latest_source and hs_utm_campaign, or your own prefixed fields. Keep raw values for analytics and also store normalized values for reporting.
How do I handle LinkedIn API rate limits when running multiple ad accounts?+
Stagger schedules per account, enable backoff and retry with jitter, and cache the latest processed timestamp per form. If you hit hard limits, prioritize high spend forms and delay low volume forms to the next run.
Can I extend this pipeline to do lead enrichment before HubSpot?+
Yes. Insert an enrichment step after normalization and before the HubSpot upsert. Many teams call an enrichment API with an email, then merge the returned firmographic fields into the contact payload.
Ready to try ButterGrow?
See how ButterGrow can supercharge your growth with a quick demo.
Book a Demo