How to combine LinkedIn Ads and HubSpot lead data
By the Analistable team · Updated · 5 min read
Export campaign spend from LinkedIn Campaign Manager and contacts (with their campaign or UTM properties and lifecycle stage) from HubSpot. Normalise the campaign key on both sides, count leads, opportunities and customers per campaign in HubSpot, then join to LinkedIn spend. That turns LinkedIn's cost per lead into cost per opportunity and cost per customer.
Part of our guide: How to answer questions across multiple spreadsheets
Why LinkedIn's own numbers stop at the lead
LinkedIn Campaign Manager can tell you spend, clicks and lead-form submissions per campaign. It can't tell you which of those leads your sales team qualified or closed — that happens in HubSpot. B2B campaigns often look very different once you move from cost per lead to cost per opportunity: a cheap lead campaign full of students and job seekers can turn out to be the most expensive source of pipeline.
What to export
| Source | Export | Columns |
|---|---|---|
| LinkedIn Campaign Manager | Campaign performance, by campaign, chosen date range | Campaign Name, Campaign ID, Total Spent, Clicks, Leads |
| HubSpot | Contacts view, exported | Email, Create date, UTM campaign (or your campaign property), Lifecycle stage, Original source |
| HubSpot (optional) | Deals view, exported | Deal name, Amount, Deal stage, associated contact or company |
How the campaign reaches HubSpot depends on your setup. If you use LinkedIn Lead Gen Forms synced to HubSpot, contacts may carry a form or campaign property. If ads link to your site with UTM parameters, the utm_campaign value needs to be captured into a contact property by your forms or tracking. Check which property is populated before you start; a join on an empty column returns nothing.
Normalise the campaign key
LinkedIn campaign names are free text (“Q4 – CFO Guide – UK”), while UTM values are usually lowercase with underscores (q4_cfo_guide_uk). Either tag every ad with a utm_campaign that matches a cleaned campaign name, or keep a mapping table:
| LinkedIn Campaign Name | campaign_key |
|---|---|
| Q4 – CFO Guide – UK | q4_cfo_guide_uk |
| Q4 – Demo Retargeting | q4_demo_retarget |
| Q4 – Webinar Finance Ops | q4_webinar_finops |
On the HubSpot side, lowercase and trim the UTM value so “Q4_CFO_Guide_UK ” matches. More on cleaning UTMs is in merging UTM campaign data with CRM leads.
Count the funnel per campaign, then join
WITH funnel AS (
SELECT LOWER(TRIM("UTM campaign")) AS campaign_key,
COUNT(*) AS leads,
COUNT(*) FILTER (WHERE "Lifecycle stage" IN ('Sales Qualified Lead', 'Opportunity', 'Customer')) AS sqls,
COUNT(*) FILTER (WHERE "Lifecycle stage" IN ('Opportunity', 'Customer')) AS opportunities,
COUNT(*) FILTER (WHERE "Lifecycle stage" = 'Customer') AS customers
FROM hubspot_contacts
GROUP BY 1
)
SELECT m.campaign_key, l."Total Spent" AS spend, f.leads, f.opportunities, f.customers,
l."Total Spent" / NULLIF(f.leads, 0) AS cost_per_lead,
l."Total Spent" / NULLIF(f.opportunities, 0) AS cost_per_opp
FROM linkedin l
JOIN mapping m ON m."LinkedIn Campaign Name" = l."Campaign Name"
LEFT JOIN funnel f ON f.campaign_key = m.campaign_key;Lifecycle stage only moves forward by default in HubSpot, so a contact who is now a Customer has passed through the earlier stages. That's why the opportunity count includes customers. If your portal lets stages move backwards, use the “became a … date” properties instead.
Worked example: Q4 campaigns to date
| campaign | spend | leads | opps | customers | cost per lead | cost per opp |
|---|---|---|---|---|---|---|
| q4_cfo_guide_uk | 6000 | 150 | 6 | 1 | £40 | £1,000 |
| q4_demo_retarget | 3000 | 30 | 10 | 3 | £100 | £300 |
| q4_webinar_finops | 4500 | 90 | 9 | 2 | £50 | £500 |
| Total | 13500 | 270 | 25 | 6 | £50 | £540 |
The CFO guide is the cheapest lead source at £6,000 / 150 = £40, and the most expensive source of pipeline at £6,000 / 6 = £1,000 per opportunity. Demo retargeting costs 2.5× more per lead but £300 per opportunity. Without the join, you'd have moved budget the wrong way.
Leads take time to become opportunities. Compare campaigns that are at least one sales cycle old, or compare them at the same age (for example, opportunities within 60 days of lead creation).
Doing it without building the query
Analistable can read the LinkedIn export and the HubSpot export side by side in your browser and answer “cost per opportunity by campaign” by writing a join like the one above. It reads the exports; it doesn't update HubSpot. The same approach for search ads is in matching Google Ads conversions to CRM deals, and the cross-spreadsheet guide lists other funnel questions.
Pitfalls specific to this join
- Campaign groups versus campaigns: LinkedIn exports can be at campaign group or campaign level. Pick one level and tag UTMs at the same level, or one side will have more rows than the other.
- Currency: Total Spent is in the ad account's currency. If HubSpot deal amounts are in another currency, convert before calculating cost per customer.
- Date basis: LinkedIn spend is by day of delivery; HubSpot leads by create date. Filter both to the same window, and accept that a lead created on 1 October may come from a September click.
- Offline and event leads: contacts imported from events have no UTM. Keep them out of the LinkedIn comparison rather than assigning them by guesswork.
Write these choices in a notes tab next to the report. The next person to refresh it will otherwise make different ones, and the numbers will shift for reasons that have nothing to do with performance.
Frequently asked questions
- Can I use HubSpot's own ad reporting instead?
- If you connect LinkedIn Ads to HubSpot's ads tool, it can report contacts and deals per campaign. The export-and-join method is useful when the integration isn't set up, or when you want to control the attribution rules yourself.
- How often should I refresh the join?
- Monthly is typical for B2B, because opportunities take weeks to form. Refresh past months too, since lifecycle stages keep moving after the campaign ends.
- Why are there more LinkedIn leads than HubSpot contacts for a campaign?
- Duplicate form fills from existing contacts update the existing record rather than creating a new one, and some syncs only start after you connect the integration. Count contacts by first-touch campaign to avoid double counting.
- Should I use contacts or deals?
- Contacts for lead and SQL counts; deals for pipeline value and revenue. If a deal has several contacts from different campaigns, decide on a rule (first contact, primary contact) before joining.
- Can I include LinkedIn impressions-only influence?
- Not from these exports. View-through influence needs LinkedIn's own conversion tracking or a multi-touch attribution tool.