Track lead-to-MQL conversion rate by source and report to Slack using n8n
Prerequisites
- n8n instance — either n8n cloud or self-hosted
- HubSpot private app token with
crm.objects.contacts.readscope - Slack workspace with a Slack app configured (Bot Token Scopes:
chat:write,chat:write.public) - n8n credentials set up for both HubSpot and Slack
- Lifecycle stages configured in HubSpot (at minimum: Lead, Marketing Qualified Lead)
Step 1: Create the workflow and schedule trigger
Open n8n and create a new workflow. Add a Schedule Trigger node:
- Trigger interval: Weeks
- Day of week: Monday
- Hour: 9
- Minute: 0
- Timezone: Set to your team's timezone
Step 2: Set date range variables
Add a Set node to calculate the date range for the last 7 days:
// Expression fields in the Set node
// seven_days_ago (ISO string)
{{ new Date(Date.now() - 7 * 86400000).toISOString().split('T')[0] + 'T00:00:00.000Z' }}
// seven_days_ago_ms (Unix milliseconds for HubSpot filter)
{{ new Date(Date.now() - 7 * 86400000).setHours(0,0,0,0).toString() }}
// today_ms
{{ new Date().setHours(0,0,0,0).toString() }}Step 3: Search for leads created in the last 7 days
Add an HTTP Request node to find all contacts created in the period:
- Method: POST
- URL:
https://api.hubapi.com/crm/v3/objects/contacts/search - Authentication: Predefined Credential Type -> HubSpot API
- Body content type: JSON
- Body:
{
"filterGroups": [
{
"filters": [
{
"propertyName": "createdate",
"operator": "GTE",
"value": "{{$json.seven_days_ago_ms}}"
},
{
"propertyName": "createdate",
"operator": "LT",
"value": "{{$json.today_ms}}"
}
]
}
],
"properties": [
"hs_analytics_source", "lifecyclestage", "createdate"
],
"limit": 100
}The HubSpot Search API returns a max of 100 results per request. If you generate more than 100 leads per week, add a loop: use an IF node to check for $json.paging.next.after, then loop back to the HTTP Request with the after parameter.
Step 4: Search for MQLs from the same period
Add a second HTTP Request node for contacts that became MQLs. Use a filter on lifecyclestage:
{
"filterGroups": [
{
"filters": [
{
"propertyName": "createdate",
"operator": "GTE",
"value": "{{$json.seven_days_ago_ms}}"
},
{
"propertyName": "createdate",
"operator": "LT",
"value": "{{$json.today_ms}}"
},
{
"propertyName": "lifecyclestage",
"operator": "EQ",
"value": "marketingqualifiedlead"
}
]
}
],
"properties": [
"hs_analytics_source", "lifecyclestage", "createdate"
],
"limit": 100
}HubSpot's Search API doesn't support OR filters across filter groups for aggregation. It's simplest to run two queries — one for all leads, one filtered to MQLs — and compare counts per source in a Code node.
Step 5: Calculate conversion rates with a Code node
Add a Code node (Run Once for All Items):
const allLeads = $('Fetch Leads').first().json.results || [];
const mqls = $('Fetch MQLs').first().json.results || [];
// Group leads by source
const leadsBySource = {};
for (const lead of allLeads) {
const source = lead.properties.hs_analytics_source || 'UNKNOWN';
leadsBySource[source] = (leadsBySource[source] || 0) + 1;
}
// Group MQLs by source
const mqlsBySource = {};
for (const mql of mqls) {
const source = mql.properties.hs_analytics_source || 'UNKNOWN';
mqlsBySource[source] = (mqlsBySource[source] || 0) + 1;
}
// Calculate conversion rate per source
const sources = [...new Set([...Object.keys(leadsBySource), ...Object.keys(mqlsBySource)])];
const report = sources
.map(source => ({
source,
leads: leadsBySource[source] || 0,
mqls: mqlsBySource[source] || 0,
rate: leadsBySource[source]
? ((mqlsBySource[source] || 0) / leadsBySource[source] * 100).toFixed(1)
: '0.0',
}))
.sort((a, b) => b.leads - a.leads);
const totalLeads = allLeads.length;
const totalMQLs = mqls.length;
const overallRate = totalLeads > 0 ? (totalMQLs / totalLeads * 100).toFixed(1) : '0.0';
return [{
json: {
report,
totalLeads,
totalMQLs,
overallRate,
}
}];Step 6: Format and send to Slack
Add a Slack node:
- Resource: Message
- Operation: Send a Message
- Channel:
#marketing-reports - Message Type: Block Kit
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "📈 Weekly Lead-to-MQL Conversion Report"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Total Leads*\n{{ $json.totalLeads }}"
},
{
"type": "mrkdwn",
"text": "*Total MQLs*\n{{ $json.totalMQLs }}"
},
{
"type": "mrkdwn",
"text": "*Overall Conversion*\n{{ $json.overallRate }}%"
}
]
},
{
"type": "divider"
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Conversion by Source*\n{{ $json.report.map(r => `• *${r.source}*: ${r.leads} leads → ${r.mqls} MQLs (${r.rate}%)`).join('\\n') }}"
}
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "Last 7 days | Generated {{ new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) }}"
}
]
}
]
}n8n's Slack node expects the full {"blocks": [...]} wrapper. If you pass just the array, n8n silently falls back to the notification text field.
Step 7: Test and activate
- Click Execute Workflow to test with your current data
- Check that sources resolve correctly — common values are
ORGANIC_SEARCH,PAID_SEARCH,DIRECT_TRAFFIC,REFERRALS,SOCIAL_MEDIA - Toggle the workflow to Active
Cost
- n8n Cloud Starter: $24/mo for 2,500 executions. A weekly report uses ~4 executions/month. Self-hosted n8n is free.
- Maintenance: update the lifecycle stage value if you rename your MQL stage. Monitor for HubSpot API changes.
Next steps
- Add month-to-date totals — duplicate the search with a wider date range and add a second section to the Slack message
- Week-over-week trend — store last week's rates in n8n's static data (
$getWorkflowStaticData('global')) and show deltas with arrows - Source drill-down — for high-volume sources like
PAID_SEARCH, add UTM campaign-level breakdown usinghs_analytics_source_data_1
Need help implementing this?
We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.