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-...) withchat:writescope, 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 200Step 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_rateStep 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>&1GitHub 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
| API | Limit | Impact |
|---|---|---|
| HubSpot Search | 5 req/sec | 2 searches + pagination. Add 200ms delay between pages if paginating. |
| HubSpot general | 150 req/10 sec | No concern for a weekly report |
| Slack chat.postMessage | ~20 req/min | No concern for 1 message |
Cost
- $0 — runs on existing infrastructure. GitHub Actions free tier includes 2,000 minutes/month.
- Maintenance: update the
lifecyclestagefilter 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.