Track lead-to-MQL conversion rate by source and report to Slack using code and cron

medium complexityCost: $0Recommended

Prerequisites

Prerequisites
  • Node.js 18+ or Python 3.9+
  • HubSpot private app token (scopes: crm.objects.contacts.read)
  • Slack Bot Token (xoxb-...) with chat:write scope, or a Slack Incoming Webhook URL
  • A scheduling environment: cron, GitHub Actions, or a cloud function

Why code?

Code is the best option when you want zero ongoing cost and full control over the query logic. You own the script, can version-control it, and run it on GitHub Actions for free. Setup takes about 30 minutes. The trade-off is that changes require editing code — there's no visual editor.

How it works

  1. Compute the 7-day lookback window in milliseconds for HubSpot's timestamp filters
  2. Search for all leads created in the period using the HubSpot CRM contacts search API
  3. Search for MQLs using the same date range plus a lifecyclestage = marketingqualifiedlead filter
  4. Group both sets by source (hs_analytics_source), calculate per-source and overall conversion rates
  5. Post a Block Kit message to Slack with totals and per-source breakdown

Step 1: Set up the project

# Verify your HubSpot token works
curl -s "https://api.hubapi.com/crm/v3/objects/contacts?limit=1" \
  -H "Authorization: Bearer $HUBSPOT_ACCESS_TOKEN" | head -c 200

Step 2: Search for leads created in the last 7 days

Query all contacts created in the reporting period, grouped by their original source.

import os
import requests
from datetime import datetime, timezone, timedelta
 
HUBSPOT_ACCESS_TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_ACCESS_TOKEN}", "Content-Type": "application/json"}
 
def get_date_range(days_back=7):
    now = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
    start = now - timedelta(days=days_back)
    return str(int(start.timestamp() * 1000)), str(int(now.timestamp() * 1000))
 
def search_contacts(start_ms, end_ms, extra_filters=None):
    """Search contacts created in a date range with optional extra filters."""
    filters = [
        {"propertyName": "createdate", "operator": "GTE", "value": start_ms},
        {"propertyName": "createdate", "operator": "LT", "value": end_ms},
    ]
    if extra_filters:
        filters.extend(extra_filters)
 
    all_results = []
    after = "0"
    while True:
        resp = requests.post(
            "https://api.hubapi.com/crm/v3/objects/contacts/search",
            headers=HEADERS,
            json={
                "filterGroups": [{"filters": filters}],
                "properties": ["hs_analytics_source", "lifecyclestage", "createdate"],
                "limit": 100,
                "after": after,
            },
        )
        resp.raise_for_status()
        data = resp.json()
        all_results.extend(data["results"])
        if data.get("paging", {}).get("next", {}).get("after"):
            after = data["paging"]["next"]["after"]
        else:
            break
    return all_results
 
start_ms, end_ms = get_date_range(7)
 
# All leads created in period
all_leads = search_contacts(start_ms, end_ms)
 
# MQLs created in period
mqls = search_contacts(start_ms, end_ms, extra_filters=[
    {"propertyName": "lifecyclestage", "operator": "EQ", "value": "marketingqualifiedlead"}
])
 
print(f"Last 7 days: {len(all_leads)} leads, {len(mqls)} MQLs")
Lifecycle stage values are lowercase

HubSpot stores lifecycle stage values in lowercase without spaces: marketingqualifiedlead, not Marketing Qualified Lead. If you filter with the wrong casing, the search returns zero results.

Step 3: Calculate conversion rates by source

from collections import defaultdict
 
def calculate_conversion(all_leads, mqls):
    leads_by_source = defaultdict(int)
    mqls_by_source = defaultdict(int)
 
    for lead in all_leads:
        source = lead["properties"].get("hs_analytics_source") or "UNKNOWN"
        leads_by_source[source] += 1
 
    for mql in mqls:
        source = mql["properties"].get("hs_analytics_source") or "UNKNOWN"
        mqls_by_source[source] += 1
 
    sources = sorted(
        set(leads_by_source) | set(mqls_by_source),
        key=lambda s: leads_by_source.get(s, 0),
        reverse=True,
    )
 
    report = []
    for source in sources:
        lead_count = leads_by_source.get(source, 0)
        mql_count = mqls_by_source.get(source, 0)
        rate = (mql_count / lead_count * 100) if lead_count > 0 else 0
        report.append({
            "source": source,
            "leads": lead_count,
            "mqls": mql_count,
            "rate": round(rate, 1),
        })
 
    total_leads = len(all_leads)
    total_mqls = len(mqls)
    overall_rate = round(total_mqls / total_leads * 100, 1) if total_leads > 0 else 0
 
    return report, total_leads, total_mqls, overall_rate

Step 4: Post to Slack

from slack_sdk import WebClient
 
def post_report(report, total_leads, total_mqls, overall_rate):
    source_lines = "\n".join(
        f"• *{r['source']}*: {r['leads']} leads → {r['mqls']} MQLs ({r['rate']}%)"
        for r in report
    )
 
    blocks = [
        {"type": "header", "text": {"type": "plain_text", "text": "📈 Weekly Lead-to-MQL Conversion Report"}},
        {"type": "section", "fields": [
            {"type": "mrkdwn", "text": f"*Total Leads*\n{total_leads}"},
            {"type": "mrkdwn", "text": f"*Total MQLs*\n{total_mqls}"},
            {"type": "mrkdwn", "text": f"*Overall Conversion*\n{overall_rate}%"},
        ]},
        {"type": "divider"},
        {"type": "section", "text": {
            "type": "mrkdwn",
            "text": f"*Conversion by Source*\n{source_lines}",
        }},
        {"type": "context", "elements": [{
            "type": "mrkdwn",
            "text": f"Last 7 days | Generated {datetime.now().strftime('%A, %B %d, %Y')}",
        }]},
    ]
 
    slack = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
    result = slack.chat_postMessage(
        channel=os.environ["SLACK_CHANNEL_ID"],
        text="Weekly Lead-to-MQL Conversion Report",
        blocks=blocks,
        unfurl_links=False,
    )
    print(f"Posted report: {result['ts']}")
 
# --- Main ---
if __name__ == "__main__":
    start_ms, end_ms = get_date_range(7)
    all_leads = search_contacts(start_ms, end_ms)
    mqls = search_contacts(start_ms, end_ms, extra_filters=[
        {"propertyName": "lifecyclestage", "operator": "EQ", "value": "marketingqualifiedlead"}
    ])
    report, total_leads, total_mqls, overall_rate = calculate_conversion(all_leads, mqls)
    post_report(report, total_leads, total_mqls, overall_rate)
Slack Block Kit limits

Section text has a 3,000-character max. If you have many sources, truncate the list or split across multiple blocks. Max 50 blocks per message.

Step 5: Schedule with cron or GitHub Actions

Cron (server-based):

# crontab -e
0 9 * * 1 cd /path/to/conversion-report && python report.py >> /var/log/conversion-report.log 2>&1

GitHub Actions (serverless):

# .github/workflows/conversion-report.yml
name: Weekly Conversion Report
on:
  schedule:
    - cron: '0 14 * * 1'  # 9 AM ET = 2 PM UTC
  workflow_dispatch: {}
jobs:
  report:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install requests slack_sdk
      - run: python report.py
        env:
          HUBSPOT_ACCESS_TOKEN: ${{ secrets.HUBSPOT_ACCESS_TOKEN }}
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
          SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}

Troubleshooting

Rate limits

APILimitImpact
HubSpot Search5 req/sec2 searches + pagination. Add 200ms delay between pages if paginating.
HubSpot general150 req/10 secNo concern for a weekly report
Slack chat.postMessage~20 req/minNo concern for 1 message

Common questions

What if my MQL definition uses a custom lifecycle stage?

HubSpot stores lifecycle stage values in lowercase without spaces. Custom stages have custom internal names — check the exact value in HubSpot Settings > Objects > Contacts > Lifecycle Stages. Replace marketingqualifiedlead in the filter with your custom value.

How do I handle more than 100 leads per week?

The pagination loop in the code handles this automatically — it follows the paging.next.after cursor until all results are fetched. Add a 200ms delay between pages to stay within HubSpot's 5 req/sec Search API limit.

Can I track conversion by UTM campaign within a source?

Yes. Add hs_analytics_source_data_1 to the search properties. This contains the UTM campaign parameter for paid sources. Add a second grouping level in calculate_conversion() to break down high-volume sources by campaign.

Why do my conversion rates differ from HubSpot's built-in analytics?

This report measures contacts that are currently at MQL stage and were created in the reporting period. Contacts that passed through MQL to a later stage (SQL, Customer) may not appear as MQLs. For exact funnel analysis, query lifecycle stage change history instead.

Cost

  • $0 — runs on existing infrastructure. GitHub Actions free tier includes 2,000 minutes/month.
  • Maintenance: update the lifecyclestage filter value if you rename your MQL stage. Monitor for API deprecations.

Looking to scale your AI operations?

We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.