← back to blog
    Berner SetterwallSeptember 7, 20266 min read

    Meta Says 40 Leads, HubSpot Says 65: How to Calculate Real Cost Per Lead

    A B2B team we onboarded this month had a question their previous AI marketing tool could not answer: what does a Meta lead actually cost us?

    Meta Ads Manager said one number. HubSpot had more contacts from the same campaigns than Meta had leads. The gap was not a bug. It was consent.

    This post explains where the gap comes from, gives you the reconciliation method, and shows how to run it as a single question in Cogny so you never build the spreadsheet again.

    TL;DR

    • Meta's pixel only sees visitors who accept tracking. Declined-consent visitors who still convert exist in your CRM and nowhere in Meta.
    • So Meta's cost per lead is too high, and the campaigns with the most privacy-conscious audiences look the worst.
    • The fix is a join: Meta spend by campaign, HubSpot contacts by UTM campaign, divided. Report both CPLs and the coverage ratio.
    • In Cogny that is one prompt across two connected sources, or one SQL statement once both are in BigQuery.

    Where the gap comes from

    Three things push Meta's lead count below the CRM's:

    1. Consent declines. Under GDPR and ePrivacy, the Meta pixel should not fire until the visitor accepts marketing cookies. In EU B2B traffic, decline rates of 30–50% are common. Those visitors can still read your page and submit the form. HubSpot records the contact; Meta records nothing.
    2. Browser and device blocking. Safari ITP, Firefox ETP, ad blockers and iOS App Tracking Transparency all reduce what the pixel can report, consent or not.
    3. Attribution windows. Meta attributes a lead only within its click and view windows (default 7-day click, 1-day view). A prospect who clicks, thinks about it for two weeks and then fills the form is a HubSpot lead with no Meta attribution.

    And two things can push it the other way, so the sign of the gap matters:

    • Meta lead ads (on-platform forms) count as leads in Meta the moment they are submitted. If the CRM sync is broken, HubSpot never sees them.
    • Loose lead events. A pixel Lead event fired on a thank-you page that people can reload, or on a page view rather than a submission, inflates Meta.

    Critical Insight: Meta's number is the right one for optimising the algorithm — it is the signal Meta bids on. The CRM's number is the right one for deciding budget. Most teams use the first for the second, and under-invest in exactly the campaigns whose audience declines tracking most.

    The method

    You need two tables and one join key.

    Step 1: Make UTMs land in HubSpot

    Every Meta ad URL needs utm_source=facebook&utm_medium=paid&utm_campaign={{campaign.name}} (Meta fills the dynamic parameter). In HubSpot, confirm the form captures first-touch UTMs — the standard analytics properties hs_analytics_first_touch_converting_campaign, hs_analytics_source and hs_analytics_source_data_1, or custom utm_* properties if you have them.

    Send a test lead through a live ad URL and check the contact record before trusting any of the numbers below. Our UTM strategy doc covers naming.

    Step 2: Pull Meta spend by campaign

    For the period — say the last 28 days — you want, per campaign: campaign_name, spend, impressions, clicks, and the lead action count. That is a single insights call at campaign level.

    Step 3: Pull HubSpot contacts created in the period

    Per contact: createdate, hs_analytics_source (Original Source), hs_analytics_source_data_1 and _2 (the drill-downs, which carry the campaign for paid social), and any utm_campaign property. Filter to contacts created inside the same window as the spend.

    Step 4: Join, and bucket the leftovers

    Match the contact's campaign string to Meta's campaign_name. Lowercase both, strip whitespace. What does not match goes into one of two buckets:

    • Unattributed Meta — Original Source is Paid Social / facebook but no campaign string. Counts toward total Meta CPL, not toward any single campaign.
    • Not Meta — everything else. Excluded.

    Step 5: Compute both numbers

    CampaignSpendMeta leadsMeta CPLHubSpot leadsReal CPLCoverage
    Whitepaper – DACH$2,40012$20022$10955%
    Demo – Nordics$3,10028$11143$7265%
    Retargeting – all$9009$1008$113113%

    (Illustrative numbers.) Notice the retargeting row: coverage above 100% is the "Meta higher than CRM" case and is worth investigating rather than celebrating.

    Coverage — Meta leads ÷ HubSpot leads — is the number to watch over time. If it drops, consent or tracking changed; if it jumps past 100%, a lead event is misfiring.

    Step 6: Schedule it

    Once the join is right, you want it every week without touching it. Which brings us to the tooling.


    Doing it in Cogny

    With Meta Ads and HubSpot both connected in a Cogny workspace, the whole method above is one prompt:

    Pull Meta Ads insights by campaign for the last 28 days (spend, clicks, leads). Pull HubSpot contacts created in the same window with their original source and first-touch campaign. Join on campaign name, case-insensitive. Give me a table with Meta CPL, real CPL from HubSpot contacts, and coverage per campaign, plus an "unattributed Meta" row. Flag any campaign with coverage over 100%.

    The agent calls the Meta insights tool and the HubSpot contact tools, does the join, and returns the table. Run it in the Cogny app, or from Claude Code or Cursor through the Cogny MCP endpoint — same tools, your client.

    To keep the history, ask it to land both pulls in BigQuery with create_bigquery_table and append_bigquery_rows, then the join is a view you can chart:

    with meta as (
      select lower(trim(campaign_name)) as campaign_key,
             sum(spend) as spend,
             sum(leads) as meta_leads
      from `your_project.marketing.meta_campaign_insights`
      where date between date_sub(current_date(), interval 28 day) and current_date()
      group by 1
    ),
    hubspot as (
      select lower(trim(first_touch_campaign)) as campaign_key,
             count(distinct contact_id) as crm_leads
      from `your_project.marketing.hubspot_contacts`
      where createdate between date_sub(current_date(), interval 28 day) and current_date()
        and lower(original_source) in ('paid_social', 'paid social')
      group by 1
    )
    select coalesce(m.campaign_key, h.campaign_key) as campaign,
           m.spend,
           m.meta_leads,
           safe_divide(m.spend, m.meta_leads) as meta_cpl,
           h.crm_leads,
           safe_divide(m.spend, h.crm_leads) as real_cpl,
           safe_divide(m.meta_leads, h.crm_leads) as coverage
    from meta m
    full outer join hubspot h using (campaign_key)
    order by m.spend desc nulls last;
    

    Then save the prompt as a weekly scheduled report and the table arrives Monday morning.

    Quick Tip: Add Google Ads and LinkedIn to the same join. The consent gap hits every pixel-based platform; once the HubSpot side is built, each extra channel is one more union all.


    Why this was hard on a closed platform

    The team above had been on an AI marketing platform whose agents reported on each channel well but did not expose the underlying data for a cross-source join. Meta's leads were Meta's leads. HubSpot was a separate connection. "Cost per lead" came back as Meta's number.

    That is the structural difference between an agent fleet that reasons inside its own platform and a data layer your AI reasons over. If your questions cross sources — and cost per lead in a consent-first market always does — you need the join to be yours. We compare the two approaches in Cogny vs Epiminds Lucy.


    Related