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

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

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

Need help implementing this?

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