CRM

Lead capture and conversion tracking with the HubSpot API

A practical Python guide to capturing leads reliably and tracking their conversions in HubSpot, covering form submissions, the Forms API, contact upserts, custom behavioural events and closed-loop attribution.

Ahmed Khalil Ben Smida7 min read

Capturing a lead is easy. Capturing it reliably, attributing it to the right source, and then proving which of those leads actually converted is where most setups fall apart. A form fills in silently, a duplicate contact is created, a conversion happens on a channel nobody is tracking, and by the end of the quarter the marketing report and the revenue number tell two different stories.

This guide walks the full loop in Python and the HubSpot API: capturing a lead so it always lands as a clean contact, recording the behavioural events that mark a conversion, and closing the loop so every lead can be traced from first touch to outcome. The goal is a pipeline you can trust well enough to make budget decisions on.

Decide where capture happens before you write code

There are two honest ways to get a lead into HubSpot, and choosing the wrong one is the root of most data problems. The first is HubSpot’s own forms, which capture the submission, set the original source automatically, and drop a tracking cookie that links the contact to their prior browsing. The second is a custom form on your own stack that posts to your server, which then calls the API. The first is simpler and preserves attribution; the second gives you full control of validation and user experience but makes you responsible for attribution yourself.

The pragmatic rule is to use HubSpot forms wherever attribution matters most, such as demo requests and content downloads, and to reserve the custom path for flows where you genuinely need bespoke logic. Whichever you choose, the contact should arrive with a consistent shape, so the rest of your automation can rely on it.

Submit to the Forms API and keep attribution intact

When you post to the HubSpot Forms submission endpoint you can pass the visitor’s tracking context, which is what preserves the original source and the page history. The critical piece is the hutk cookie value, read from the browser and forwarded with the submission. Without it, every lead looks like it arrived from nowhere.

import requests

def submit_form(portal_id, form_guid, fields, hutk=None, page_url=""):
    url = (f"https://api.hsforms.com/submissions/v3/integration/"
           f"submit/{portal_id}/{form_guid}")
    payload = {
        "fields": [{"name": k, "value": v} for k, v in fields.items()],
        "context": {"pageUri": page_url, "pageName": "Lead form"},
    }
    if hutk:
        payload["context"]["hutk"] = hutk          # preserves attribution
    r = requests.post(url, json=payload, timeout=10)
    r.raise_for_status()
    return r.json()

Forwarding the hutk is the single most important line for anyone who later wants to answer “where did this lead come from”. Treat it as mandatory, not optional.

Upsert the contact so you never create a duplicate

If you capture through the CRM API directly rather than a form, always upsert on email rather than blindly creating. HubSpot lets you look up a contact by a unique property and update it if it exists, which prevents the slow accumulation of duplicates that every unmaintained CRM suffers from. The pattern is to try an update keyed on email, and fall back to a create only when the contact genuinely does not exist yet.

def upsert_contact(hs, email, props):
    props = {**props, "email": email}
    try:
        return hs._req(
            "PATCH", f"/crm/v3/objects/contacts/{email}"
                     "?idProperty=email",
            json={"properties": props})
    except requests.HTTPError as e:
        if e.response.status_code == 404:            # not found, create it
            return hs._req("POST", "/crm/v3/objects/contacts",
                           json={"properties": props})
        raise

Using email as the idProperty turns “create or update” into a single, idempotent call. Run the same capture twice and you get one clean contact, not two.

Set the fields that make a lead usable

A lead is only useful if it carries the context needed to route and score it. Beyond name and email, capture the source, the campaign, and a first-touch timestamp, and set the lifecycle stage explicitly rather than letting it default. A small, deliberate set of properties beats a sprawling form that nobody completes.

  • hs_lead_status to drive follow-up queues.
  • A campaign or UTM property so paid and organic can be separated later.
  • A lifecyclestage set to lead or marketing-qualified as appropriate.
  • A first-touch source, captured once and never overwritten.

The discipline of capturing few fields well, and capturing them the same way every time, is what makes downstream reporting possible at all.

Record conversions as custom behavioural events

A contact existing is not a conversion. A conversion is an action: a demo booked, a trial started, a quote requested. HubSpot’s custom behavioural events let you record exactly these moments and attach them to the contact’s timeline, which is what turns a static record into a measurable journey. Define the event once, then send it whenever the action happens.

def track_event(hs, email, event_name, properties=None):
    body = {
        "eventName": event_name,          # e.g. pe1234567_demo_booked
        "email": email,
        "properties": properties or {},
    }
    return hs._req("POST", "/events/v3/send", json=body)

# when a demo is booked on your app:
track_event(hs, "lead@example.com", "pe1234567_demo_booked",
            {"plan": "growth", "value": 4900})

Because the event carries properties, you can record not just that a conversion happened but its shape and its value, which is what lets you later weight conversions by revenue rather than counting them equally.

Close the loop from source to outcome

The whole point of clean capture and event tracking is to answer one question: which sources produce leads that actually convert. With the source captured at first touch and the conversion recorded as an event, you can pull both and join them into a simple conversion-by-source view. Even a rough version of this closed loop changes how budget gets allocated.

def conversions_by_source(hs, event_name, since):
    # 1. contacts with the conversion event since a date
    events = hs._req("GET", "/events/v3/events",
                     params={"eventName": event_name, "occurredAfter": since})
    tally = {}
    for ev in events.get("results", []):
        email = ev["properties"].get("email")
        contact = hs._req("GET", f"/crm/v3/objects/contacts/{email}"
                          "?idProperty=email&properties=hs_analytics_source")
        src = contact["properties"].get("hs_analytics_source", "UNKNOWN")
        tally[src] = tally.get(src, 0) + 1
    return dict(sorted(tally.items(), key=lambda kv: -kv[1]))

The output is the beginning of real attribution: a ranked list of the sources that produced converting leads in a period. It is deliberately simple, because a simple closed loop that everyone trusts is worth more than an elaborate model that nobody can reproduce.

The cleanest place to enforce data quality and consent is the moment of capture, before bad data ever enters the CRM. Validate the email format and reject obvious disposable domains. Normalise the fields your reporting groups by, such as country, at capture rather than in a nightly clean-up. Record the consent basis alongside the contact, because a lead captured without a clear basis is a liability rather than an asset. Doing this work at the door is far cheaper than cleaning it up later, and it means the maintenance job you run on the whole CRM has less to fix.

Make it observable

A capture pipeline that fails silently is worse than no pipeline, because you keep spending on channels while leads quietly leak. Instrument every stage: count submissions received, contacts upserted, events sent, and errors caught, and surface those counts somewhere your team looks. When submissions hold steady but upserts drop, you have a validation problem. When events fall to zero, an integration has broken. Visibility is what turns a fragile script into infrastructure you can depend on for budget decisions.

The failure modes worth designing against

Most lead-capture systems fail in a small number of predictable ways, and knowing them in advance is the cheapest form of insurance. The first is silent validation rejection, where a form or an API call quietly drops a submission because a required property is missing or a value is malformed. The visitor believes they submitted; the CRM never sees them. The defence is to validate on the client, confirm success explicitly to the user, and log every rejected submission on the server so a pattern of failures is visible rather than invisible.

The second failure is attribution collapse, where leads pour in but every one is labelled as direct or unknown. This is almost always the missing tracking cookie or a redirect that strips query parameters before capture. Test the full journey from ad click to captured contact at least once per campaign, because attribution that is wrong is more dangerous than attribution that is absent: it sends budget confidently in the wrong direction.

The third is the slow duplicate drift covered earlier, where integrations create rather than update. It never announces itself; it just gradually inflates your contact count and double counts your pipeline until a report looks obviously wrong. Upserting on email at every entry point is the structural cure, and a scheduled hygiene job is the safety net beneath it.

The fourth, and the most expensive, is measuring activity instead of outcomes. A dashboard that celebrates form fills while ignoring whether those leads ever convert will happily reward the channels that produce the most junk. This is exactly why the conversion event matters more than the contact: it forces the system to distinguish a name in a database from a person who took a meaningful action, and it lets you weight that action by the revenue it eventually produced.

Iterate on the capture itself

A capture pipeline is not a build-once artefact; it is something you tune against evidence. Once conversions are recorded as events with a value, you can start asking sharper questions of the data. Which sources produce leads that convert quickly rather than merely in volume? Which form length maximises completed, high-quality submissions rather than raw starts? Does asking for one more qualifying field at capture improve routing enough to justify the drop in completion rate it causes?

None of these questions can be answered by opinion, and all of them can be answered once the closed loop exists. Change one variable at a time, let it run long enough to gather a meaningful sample, and compare conversion rate rather than submission count. Over a few cycles this turns lead capture from a fixed piece of plumbing into a system that gets measurably better, and it gives marketing and sales a shared, trustworthy definition of what a good lead actually is.

Key takeaways

  1. Choose HubSpot forms where attribution matters, and always forward the hutk cookie.
  2. Upsert on email so capture is idempotent and duplicates never accumulate.
  3. Capture a small, deliberate set of fields the same way every time.
  4. Record conversions as custom behavioural events with a value, not just a contact.
  5. Join source to conversion for a simple, trustworthy closed loop.
  6. Enforce validation and consent at capture, and instrument every stage.

Reliable lead capture is not glamorous, but it is the foundation everything else stands on. Get the contact clean, record the conversion as an event, and keep the source attached, and you can finally answer the only question that matters: which marketing actually produces customers.

References

Apply this to your business

05Technology and infrastructure

CRM implementation

A CRM the team actually uses, with clean data, clear lifecycle stages and reporting leadership believes.

HubSpot and CRM implementations designed around your sales process, adopted by your team, and connected to marketing and reporting from day one.

  • HubSpot
  • Salesforce
  • Brevo
  • Zoho
View capability
04Technology and infrastructure

Automation and AI

Documented, monitored automation that gives the team hours back and makes processes reliable.

n8n workflows and AI-assisted systems that remove repetitive marketing work, connect your tools and keep humans in control of what ships.

  • n8n
  • Zapier
  • Make
  • OpenAI API
View capability
04Measurement and optimisation

Tracking and analytics

Trustworthy measurement from first click to revenue, visible in dashboards the team actually uses.

Conversion tracking, GA4, Tag Manager and dashboards implemented properly, so every marketing decision is made on data you can trust.

  • Google Tag Manager
  • GA4
  • Looker Studio
  • Microsoft Clarity
View capability