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

medium complexityCost: $0-24/mo

Prerequisites

Prerequisites
  • n8n instance — either n8n cloud or self-hosted
  • HubSpot private app token with crm.objects.contacts.read scope
  • 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)

Why n8n?

n8n is a strong choice for conversion reporting because the visual workflow makes it easy to adjust date ranges, add new lifecycle stages, or split reports by team — all without writing code. Self-hosted n8n runs unlimited workflows for free, and the Code node handles the percentage calculations that would be tedious with visual aggregators alone.

How it works

  • Schedule Trigger fires weekly on Monday morning
  • Set node computes 7-day lookback timestamps in milliseconds for HubSpot's filter
  • Two HTTP Request nodes search HubSpot contacts — one for all leads, one filtered to MQLs
  • Code node groups both sets by hs_analytics_source, calculates per-source and overall conversion rates
  • Slack node posts a Block Kit message with totals and per-source breakdown

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
}
Pagination

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
}
Why two searches?

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' }) }}"
        }
      ]
    }
  ]
}
Block Kit JSON format

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

  1. Click Execute Workflow to test with your current data
  2. Check that sources resolve correctly — common values are ORGANIC_SEARCH, PAID_SEARCH, DIRECT_TRAFFIC, REFERRALS, SOCIAL_MEDIA
  3. Toggle the workflow to Active

Troubleshooting

Common questions

How many n8n executions does this use per month?

One execution per week = ~4 executions/month. On n8n Cloud Starter ($24/mo, 2,500 executions), this workflow uses less than 0.2% of your quota. Self-hosted n8n has no execution limits.

What if we generate more than 100 leads per week?

The HubSpot Search API returns max 100 results per page. Add a loop using an IF node that checks for paging.next.after and feeds back into the HTTP Request node until all pages are fetched. At typical B2B volumes (50-200 leads/week), you'll need 1-2 pages.

Can I add month-to-date totals alongside the weekly numbers?

Yes. Duplicate the two search nodes with a 30-day lookback instead of 7 days. Pass both weekly and MTD data into the Code node and add a second section to the Slack message.

Why does the report show contacts as MQL even if they converted after the reporting period?

This report checks current lifecycle stage, not when the stage changed. A contact created 5 days ago that becomes an MQL tomorrow won't appear in this week's MQL count. For exact funnel timing, use HubSpot's lifecycle stage change history instead.

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 using hs_analytics_source_data_1

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.