Post a daily Slack leaderboard of rep activity from HubSpot using n8n
Install this workflow
Download the n8n workflow JSON and import it into your n8n instance.
rep-leaderboard.n8n.jsonPrerequisites
- n8n instance — either n8n cloud or self-hosted
- HubSpot private app token with
crm.objects.engagements.readandcrm.objects.owners.readscopes - 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_timestampwithin 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.
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() }}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
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-activitychannel - 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"
}
]
}
]
}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
- Enable Settings -> Retry On Fail on each HTTP Request node (2 retries, 5-second wait)
- Create an Error Workflow with an Error Trigger that sends you a Slack DM on failure
- Click Execute Workflow to test with yesterday's data
- 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-teamand AE leaderboard to#ae-teamusing 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.