Post a daily Slack leaderboard of rep activity from HubSpot using n8n

medium complexityCost: $0-24/moRecommended

Prerequisites

Prerequisites
  • n8n instance — either n8n cloud or self-hosted
  • HubSpot private app token with crm.objects.engagements.read and crm.objects.owners.read scopes
  • Slack workspace with a Slack app configured (Bot Token Scopes: chat:write, chat:write.public)
  • n8n credentials set up for both HubSpot and Slack

Why n8n?

n8n is the best option for a daily leaderboard if you want visual workflow management with no per-execution cost. Self-hosted n8n runs unlimited workflows for free — you only pay for your own infrastructure ($5-10/mo for a VPS). The visual editor makes it easy to adjust ranking logic, add new activity types, or change the schedule without touching code.

The leaderboard requires a Code node for ranking logic (sorting reps, assigning medals), which n8n handles natively. Parallel execution of the three search requests (calls, emails, meetings) cuts execution time compared to sequential approaches. If you're already running n8n for other automations, this workflow adds minimal overhead.

How it works

  • Schedule Trigger fires every weekday morning at your configured time
  • Set node computes yesterday's midnight-to-midnight timestamps in milliseconds for HubSpot's filter
  • Three parallel HTTP Request nodes search calls, emails, and meetings filtered by hs_timestamp within yesterday's range
  • Merge node combines all three result sets into a single stream
  • Code node builds an owner lookup, counts per-rep activities, ranks by total, and assigns medal emojis
  • Slack node posts a Block Kit message with the ranked leaderboard and team totals

Step 1: Create the workflow and schedule trigger

Open n8n and create a new workflow. Add a Schedule Trigger node:

  • Trigger interval: Days
  • Days between triggers: 1
  • Hour: 8
  • Minute: 0
  • Timezone: Set to your team's timezone

This fires every weekday morning (you'll add a weekday filter in the next step).

Step 2: Filter to weekdays only

Add an IF node to skip weekends:

// Expression in the IF node condition
{{ new Date().getDay() >= 1 && new Date().getDay() <= 5 }}

Route the true output to the rest of the workflow. The false output goes nowhere — the workflow ends silently on weekends.

Timezone matters

n8n cloud defaults to UTC. Set the timezone in the Schedule Trigger node — not the workflow settings — so the day-of-week check uses your local time.

Step 3: Fetch owner list from HubSpot

Add an HTTP Request node to get your rep roster:

  • Method: GET
  • URL: https://api.hubapi.com/crm/v3/owners
  • Authentication: Predefined Credential Type -> HubSpot API
  • Query params: limit=100

This returns each owner's id, firstName, lastName, and email. You'll use the id to match against engagement owners.

Step 4: Fetch yesterday's calls

Add an HTTP Request node:

  • Method: POST
  • URL: https://api.hubapi.com/crm/v3/objects/calls/search
  • Body content type: JSON
  • Body:
{
  "filterGroups": [
    {
      "filters": [
        {
          "propertyName": "hs_timestamp",
          "operator": "GTE",
          "value": "{{yesterday_start_ms}}"
        },
        {
          "propertyName": "hs_timestamp",
          "operator": "LT",
          "value": "{{today_start_ms}}"
        }
      ]
    }
  ],
  "properties": ["hs_timestamp", "hubspot_owner_id"],
  "limit": 100
}

Replace the timestamp expressions with n8n expressions. In a Set node before this step, compute:

// yesterday start (midnight) in milliseconds
{{ new Date(new Date().setHours(0,0,0,0) - 86400000).getTime() }}
// today start (midnight) in milliseconds
{{ new Date(new Date().setHours(0,0,0,0)).getTime() }}
Timestamp format

HubSpot's hs_timestamp filter expects Unix milliseconds as a string, not an ISO date. The Search API will silently return zero results if you pass seconds instead of milliseconds.

Step 5: Fetch yesterday's emails and meetings

Duplicate the calls HTTP Request node twice — once for emails and once for meetings:

Emails:

  • URL: https://api.hubapi.com/crm/v3/objects/emails/search
  • Same filter body, same properties

Meetings:

  • URL: https://api.hubapi.com/crm/v3/objects/meetings/search
  • Same filter body, same properties
Parallel execution

In n8n, you can run these three HTTP Request nodes in parallel by connecting them all to the same Set node output. n8n executes sibling branches concurrently, cutting execution time.

Step 6: Merge and rank reps with a Code node

Add a Merge node (mode: Append) to combine all three activity streams into one. Then add a Code node (Run Once for All Items):

const owners = $('Fetch Owners').first().json.results;
const calls = $('Fetch Calls').first().json.results || [];
const emails = $('Fetch Emails').first().json.results || [];
const meetings = $('Fetch Meetings').first().json.results || [];
 
// Build owner name lookup
const ownerMap = {};
for (const o of owners) {
  ownerMap[o.id] = `${o.firstName} ${o.lastName}`.trim() || o.email;
}
 
// Count activities per owner
const reps = {};
function count(items, type) {
  for (const item of items) {
    const ownerId = item.properties.hubspot_owner_id;
    if (!ownerId) continue;
    if (!reps[ownerId]) reps[ownerId] = { calls: 0, emails: 0, meetings: 0, total: 0 };
    reps[ownerId][type]++;
    reps[ownerId].total++;
  }
}
 
count(calls, 'calls');
count(emails, 'emails');
count(meetings, 'meetings');
 
// Rank by total activity
const ranked = Object.entries(reps)
  .map(([id, counts]) => ({
    name: ownerMap[id] || `Owner ${id}`,
    ...counts
  }))
  .sort((a, b) => b.total - a.total);
 
// Assign medals
const medals = ['🥇', '🥈', '🥉'];
const leaderboard = ranked.map((rep, i) => ({
  rank: i + 1,
  medal: medals[i] || `${i + 1}.`,
  ...rep
}));
 
return [{ json: { leaderboard, date: new Date(Date.now() - 86400000).toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }) } }];

Step 7: Format and send to Slack

Add a Slack node:

  • Resource: Message
  • Operation: Send a Message
  • Channel: Select your #sales-activity channel
  • Message Type: Block Kit
{
  "blocks": [
    {
      "type": "header",
      "text": {
        "type": "plain_text",
        "text": "🏆 Rep Activity Leaderboard"
      }
    },
    {
      "type": "context",
      "elements": [
        {
          "type": "mrkdwn",
          "text": "Activity for {{ $json.date }}"
        }
      ]
    },
    {
      "type": "divider"
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "{{ $json.leaderboard.map(r => `${r.medal} *${r.name}* — ${r.total} activities (${r.calls}C ${r.emails}E ${r.meetings}M)`).join('\\n') }}"
      }
    },
    {
      "type": "context",
      "elements": [
        {
          "type": "mrkdwn",
          "text": "C = Calls | E = Emails | M = Meetings"
        }
      ]
    }
  ]
}
Block Kit JSON in n8n

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 8: Add error handling and activate

  1. Enable Settings -> Retry On Fail on each HTTP Request node (2 retries, 5-second wait)
  2. Create an Error Workflow with an Error Trigger that sends you a Slack DM on failure
  3. Click Execute Workflow to test with yesterday's data
  4. Toggle the workflow to Active

Troubleshooting

Common questions

How many n8n executions does this workflow use per month?

One execution per weekday = roughly 20-22 executions per month. On n8n Cloud Starter ($24/mo, 2,500 executions), this workflow uses less than 1% of your quota. Self-hosted n8n has no execution limits.

What happens if a rep logs no activity yesterday?

They won't appear on the leaderboard at all. The ranking only includes reps with at least one call, email, or meeting. If you want to show inactive reps, modify the Code node to include all owners from the Fetch Owners response, even those with zero activity.

Can I weight activities differently (e.g., meetings worth more than emails)?

Yes. In the Code node, replace the simple total count with a weighted formula: reps[ownerId].total = reps[ownerId].calls * 2 + reps[ownerId].emails * 1 + reps[ownerId].meetings * 3. Adjust weights to match your team's priorities.

Does this work with multiple sales teams?

Yes. Add a Router node after the Code node to split leaderboards by team. You can filter owners by team membership or use HubSpot's team property, then post separate leaderboards to different Slack channels.

Cost

  • n8n Cloud Starter: $24/mo for 2,500 executions. A daily report uses ~20 executions/month (weekdays only). Self-hosted n8n is free.
  • Maintenance: Update owner IDs if reps join or leave. The owner list is fetched dynamically, so new reps appear automatically.

Next steps

  • Weighted scoring — assign points (1 for email, 2 for call, 3 for meeting) instead of raw counts to reward high-effort activities
  • Channel-specific boards — post SDR leaderboard to #sdr-team and AE leaderboard to #ae-team using a Router node
  • Week-over-week streak — use n8n static data ($getWorkflowStaticData('global')) to track who held the top spot multiple days in a row

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.