HubSpot + Facebook Lead Ads: What the Native Integration Loses

Facebook Lead Ads lets you collect contacts directly within Meta — without redirecting to a website. A user sees an ad, clicks, the form is pre-filled with their profile data, the lead is submitted. HubSpot natively integrates with Facebook Lead Ads through Marketing -> Lead Capture -> Facebook Lead Ads. It looks like a ready-made solution. In practice — the native integration works, but it loses enough data that attribution is inaccurate and the CRM fills up with duplicates.

How the Native Integration Works

The native HubSpot ↔ Facebook Lead Ads integration uses the Meta Leads API: after the form is submitted, Meta sends a webhook -> HubSpot creates a Contact. Setup takes 5 minutes in the interface. Problems start after.

What the native integration can do:
— Create a Contact from standard Lead Ad form fields (name, email, phone)
— Add leads to a HubSpot List
— Trigger a Workflow on contact creation
— Map form fields to standard HubSpot Contact properties

What the native integration CANNOT do:

1. Custom Field Mapping — Only Partially

A Lead Ad form can contain custom questions: “Your job title”, “Team size”, “Which product are you interested in”. The native integration does not support direct mapping of these answers to HubSpot Custom Properties. The data is either lost or stored in notes — with no ability to segment by them in the CRM.

2. UTM Attribution Is Not Passed

This is the main pain point. A user clicks an ad with utm_campaign=summer_sale&utm_source=facebook. The native integration creates a Contact, but UTM parameters from the ad URL do not populate Contact properties. HubSpot sees the source as “Facebook Lead Ads” — with no breakdown by campaign, ad set, or creative.

Result: Deals contain no data on which specific campaign the lead came from. The marketer cannot compare cost per deal across campaigns.

3. Duplicate Existing Contacts

If a Contact with that email already exists in HubSpot — the native integration creates a second Contact instead of updating the existing one. The logic: the native integration uses the create endpoint, not upsert. Result: duplicates, fragmented interaction history, and a broken Lifecycle Stage.

4. Conditional Form Logic

A Facebook Lead Ad form supports conditional questions: if the answer to question 1 is “Yes” -> show question 2. The native integration does not understand this conditional logic — it receives a flat array of answers without branch context. Complex lead qualification forms behave unpredictably.

5. No Support for Instant Form Versions

Meta continuously updates the Instant Forms format. The native integration may not support new field types immediately — there is a delay until HubSpot officially updates the connector.

How This Affects the Business

A typical scenario: a marketer runs 4 Facebook Lead Ad campaigns with different creatives. All leads arrive in HubSpot with the source “Facebook Lead Ads”. A month later, the question is which campaign delivers the best cost per closed deal — there is no data. Optimizing for CPL instead of CAC: the expensive channel gets funded, the cheap one gets cut.

A similar problem is described for HubSpot + Slack: the native integration creates the appearance of working, but loses critical data.

The Right Approach: Direct Integration via the Lead Gen API

Step 1. Receive the Lead in real time via webhook:

from flask import Flask, request
import hmac, hashlib

app = Flask(__name__)

FACEBOOK_APP_SECRET = "your_app_secret"

@app.route("/webhooks/fb-leads", methods=["GET", "POST"])
def facebook_lead_webhook():
    if request.method == "GET":
        # Endpoint verification
        challenge = request.args.get("hub.challenge")
        return challenge, 200

    payload = request.json
    for entry in payload.get("entry", []):
        for change in entry.get("changes", []):
            if change.get("field") == "leadgen":
                lead_id = change["value"]["leadgen_id"]
                form_id = change["value"]["form_id"]
                ad_id = change["value"].get("ad_id")
                campaign_id = change["value"].get("campaign_id")
                adset_id = change["value"].get("adset_id")

                lead_data = fetch_lead_data(lead_id)
                process_lead(lead_data, ad_id, campaign_id, adset_id)

    return "", 200

Step 2. Retrieve lead data from the Graph API:

import requests

FACEBOOK_ACCESS_TOKEN = "your_page_access_token"

def fetch_lead_data(lead_id: str) -> dict:
    resp = requests.get(
        f"https://graph.facebook.com/v19.0/{lead_id}",
        params={
            "access_token": FACEBOOK_ACCESS_TOKEN,
            "fields": "field_data,ad_id,campaign_id,adset_id,created_time,form_id"
        }
    )
    resp.raise_for_status()
    return resp.json()

def extract_fields(lead_data: dict) -> dict:
    # Convert field_data to dict
    result = {}
    for item in lead_data.get("field_data", []):
        key = item.get("name", "")
        values = item.get("values", [])
        result[key] = values[0] if values else ""
    return result

Step 3. Upsert into HubSpot with UTM attribution:

import hubspot
from hubspot.crm.contacts import SimplePublicObjectInputForCreate
from hubspot.crm.contacts.exceptions import ApiException

hs_client = hubspot.Client.create(access_token="your_hubspot_private_app_token")

AD_NAME_CACHE = {}

def get_ad_name(ad_id: str) -> str:
    if ad_id in AD_NAME_CACHE:
        return AD_NAME_CACHE[ad_id]
    try:
        resp = requests.get(
            f"https://graph.facebook.com/v19.0/{ad_id}",
            params={"access_token": FACEBOOK_ACCESS_TOKEN, "fields": "name,effective_status"}
        )
        name = resp.json().get("name", ad_id)
        AD_NAME_CACHE[ad_id] = name
        return name
    except Exception:
        return ad_id

def process_lead(lead_data: dict, ad_id: str, campaign_id: str, adset_id: str):
    fields = extract_fields(lead_data)
    email = fields.get("email", "")
    if not email:
        return

    ad_name = get_ad_name(ad_id) if ad_id else ""

    contact_props = {
        "email": email,
        "firstname": fields.get("first_name", ""),
        "lastname": fields.get("last_name", ""),
        "phone": fields.get("phone_number", ""),
        # UTM attribution via ad API data
        "hs_analytics_source": "PAID_SOCIAL",
        "hs_analytics_source_data_1": "Facebook",
        "hs_analytics_source_data_2": campaign_id or "",
        # Custom properties (create in HubSpot in advance)
        "fb_campaign_id": campaign_id or "",
        "fb_adset_id": adset_id or "",
        "fb_ad_id": ad_id or "",
        "fb_ad_name": ad_name,
        "fb_form_id": lead_data.get("form_id", ""),
        # Custom fields from the form (explicit mapping)
        "job_title": fields.get("job_title", ""),
        "company_size": fields.get("company_size", ""),
        "interested_product": fields.get("interested_in", ""),
        "lead_source": "Facebook Lead Ads",
    }

    # Upsert - update an existing contact, do not create a duplicate
    try:
        hs_client.crm.contacts.basic_api.create(
            simple_public_object_input_for_create=SimplePublicObjectInputForCreate(
                properties=contact_props
            )
        )
    except ApiException as e:
        if e.status == 409:  # Contact already exists
            # Find the existing contact and update
            existing = hs_client.crm.contacts.search_api.do_search(
                public_object_search_request={
                    "filterGroups": [{"filters": [
                        {"propertyName": "email", "operator": "EQ", "value": email}
                    ]}]
                }
            )
            if existing.results:
                contact_id = existing.results[0].id
                hs_client.crm.contacts.basic_api.update(
                    contact_id=contact_id,
                    simple_public_object_input={"properties": contact_props}
                )
        else:
            raise

Attribution: Linking the Lead to the Closed Deal

Passing campaign_id, adset_id, ad_id into Contact properties allows HubSpot Reports to break down deals by Facebook campaign. But this provides only “last click” attribution — and only for leads from Facebook Lead Ads.

For full attribution from the ad click to the closed deal across all channels — Meta, Google, LinkedIn — a system is needed that combines ad data with the CRM lifecycle. Prooflytics + Meta Ads solves exactly this: first click -> attribution via fbclid -> deal in HubSpot -> real CAC per campaign.

Real-World Case

B2B SaaS (EU, 30–50 leads from Facebook Lead Ads per month, HubSpot):

  • Before: native integration. 100% of leads with the source “Facebook Lead Ads” and no breakdown. 18% duplicates in the CRM. Custom form fields (job title, company size) lost.
  • After: direct integration via Graph API webhook. UTM attribution at the ad level. Upsert instead of create — duplicates eliminated. Custom form fields -> HubSpot Custom Properties -> segmentation in Deals.
  • Additionally: breakdown of deals by Facebook campaigns showed: campaign with CPL $45 produced cost per deal of $890, campaign with CPL $110 — $420. Budget reallocation -> -23% customer acquisition cost.

Who This Is Relevant For

  • Teams with 30+ leads per month from Facebook Lead Ads — at lower volumes the native integration is tolerable
  • Marketing with multiple active campaigns — campaign_id breakdown is required
  • Companies with custom qualification forms — conditional questions, custom fields
  • HubSpot + CRM-driven attribution: the native integration breaks the reporting funnel

Frequently Asked Questions

Is the HubSpot Facebook Lead Ads integration paid?

The native integration is available on both free and paid HubSpot plans — there is no separate fee. The Facebook Graph API for retrieving leads is free with an approved Meta Business account.

How do you verify a webhook from Facebook?

Meta signs the payload via X-Hub-Signature-256 (HMAC-SHA256 with App Secret). Verification: hmac.new(app_secret, request.data, sha256).hexdigest() == sig_header. On a GET request to the endpoint, hub.challenge must be returned — this is the verification step when registering the webhook.

Does the native integration lose all UTMs or just some?

The Facebook Lead Ads form does not pass UTM parameters through the native integration — it has no knowledge of the ad URL. UTM data from ad parameters is available via the Graph API (campaign_id, adset_id, ad_id). The fbclid (click ID) itself is not generated for Lead Ads — only for clicks to a website.

How does Upsert work in the HubSpot API?

HubSpot v3 API does not have a direct upsert endpoint for Contacts. The solution: first try create -> on 409 (duplicate) -> search by email -> update the existing contact. Alternative: use the batch/upsert endpoint (available with Operations Hub).

Summary

  • Native integration: works for simple forms, creates duplicates, loses UTM and custom fields
  • Direct integration: Graph API webhook -> fetch lead -> upsert into HubSpot with campaign_id/adset_id/ad_id
  • Upsert: search by email -> update, to avoid duplicating existing contacts
  • Custom fields: explicit mapping from field_data into HubSpot Custom Properties
  • Attribution to the closed deal: an additional system is needed — for example Prooflytics for multi-channel

If you use HubSpot and Facebook Lead Ads and want to set up UTM and custom field transfer — describe your form structure. Exceltic.dev will configure direct integration via the Graph API.

More articles

All →