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

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_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_TOKEN = os.environ["HUBSPOT_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_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_TOKEN: ${{ secrets.HUBSPOT_TOKEN }}
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
          SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}

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

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.

Need help implementing this?

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