Post a daily Slack leaderboard of rep activity from HubSpot using code and cron
Prerequisites
- Node.js 18+ or Python 3.9+
- HubSpot private app token (scopes:
crm.objects.engagements.read,crm.objects.owners.read) - Slack Bot Token (
xoxb-...) withchat:writescope, or a Slack Incoming Webhook URL - A scheduling environment: cron, GitHub Actions, or a cloud function
Why code?
The code approach gives you zero ongoing cost and full customization. There are no per-execution fees, no platform subscriptions — just a Python or Node script running on cron or GitHub Actions. You control every detail: the ranking formula, activity types, output format, and error handling.
The trade-off is setup time and maintenance responsibility. You need a developer comfortable with REST APIs and script deployment. But for a daily leaderboard — which is fundamentally a fetch-aggregate-post pattern — the code is straightforward. Initial setup takes 30-45 minutes. Once running, it needs maintenance only when HubSpot changes their API or you want to adjust the leaderboard format.
How it works
- Cron or GitHub Actions triggers the script every weekday morning at your configured time
- HubSpot Owners API fetches the rep roster to map owner IDs to display names
- Three HubSpot CRM Search API calls fetch yesterday's calls, emails, and meetings filtered by
hs_timestamp— paginated to handle teams with 100+ daily activities - Ranking logic aggregates per-rep counts, sorts by total descending, assigns medal emojis to the top 3
- Slack Web API posts a Block Kit message with the ranked leaderboard, per-rep breakdowns, and team totals
Step 1: Set up the project
# Verify your HubSpot token can access engagements
curl -s "https://api.hubapi.com/crm/v3/objects/calls?limit=1" \
-H "Authorization: Bearer $HUBSPOT_ACCESS_TOKEN" | head -c 200Step 2: Fetch the owner roster
Map HubSpot owner IDs to rep names so the leaderboard shows human-readable names.
curl -s "https://api.hubapi.com/crm/v3/owners?limit=100" \
-H "Authorization: Bearer $HUBSPOT_ACCESS_TOKEN" \
| jq '.results[] | {id: .id, name: (.firstName + " " + .lastName)}'Step 3: Search for yesterday's activities
Query calls, emails, and meetings logged yesterday using the HubSpot Search API. The hs_timestamp property records when each engagement was logged.
def get_yesterday_range():
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
yesterday = today - timedelta(days=1)
return str(int(yesterday.timestamp() * 1000)), str(int(today.timestamp() * 1000))
def search_engagements(object_type, start_ms, end_ms):
"""Search calls, emails, or meetings within a time range."""
all_results = []
after = "0"
while True:
resp = requests.post(
f"https://api.hubapi.com/crm/v3/objects/{object_type}/search",
headers=HEADERS,
json={
"filterGroups": [{
"filters": [
{"propertyName": "hs_timestamp", "operator": "GTE", "value": start_ms},
{"propertyName": "hs_timestamp", "operator": "LT", "value": end_ms},
]
}],
"properties": ["hs_timestamp", "hubspot_owner_id"],
"limit": 100,
"after": after,
},
)
resp.raise_for_status()
data = resp.json()
all_results.extend(data["results"])
if data.get("paging", {}).get("next", {}).get("after"):
after = data["paging"]["next"]["after"]
else:
break
return all_results
start_ms, end_ms = get_yesterday_range()
calls = search_engagements("calls", start_ms, end_ms)
emails = search_engagements("emails", start_ms, end_ms)
meetings = search_engagements("meetings", start_ms, end_ms)
print(f"Yesterday: {len(calls)} calls, {len(emails)} emails, {len(meetings)} meetings")HubSpot's Search API expects hs_timestamp filter values as Unix milliseconds (a 13-digit number). If you pass seconds (10 digits), the API returns zero results with no error.
Step 4: Rank reps and build the leaderboard
def build_leaderboard(calls, emails, meetings, owner_map):
reps = {}
def count(items, activity_type):
for item in items:
owner_id = item["properties"].get("hubspot_owner_id")
if not owner_id:
continue
if owner_id not in reps:
reps[owner_id] = {"calls": 0, "emails": 0, "meetings": 0, "total": 0}
reps[owner_id][activity_type] += 1
reps[owner_id]["total"] += 1
count(calls, "calls")
count(emails, "emails")
count(meetings, "meetings")
ranked = sorted(reps.items(), key=lambda x: x[1]["total"], reverse=True)
medals = ["🥇", "🥈", "🥉"]
lines = []
for i, (owner_id, counts) in enumerate(ranked):
medal = medals[i] if i < 3 else f"{i + 1}."
name = owner_map.get(owner_id, f"Owner {owner_id}")
lines.append(
f"{medal} *{name}* — {counts['total']} activities "
f"({counts['calls']}C {counts['emails']}E {counts['meetings']}M)"
)
return linesStep 5: Post to Slack
from slack_sdk import WebClient
def post_leaderboard(lines):
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
date_str = yesterday.strftime("%A, %b %d")
blocks = [
{"type": "header", "text": {"type": "plain_text", "text": "🏆 Rep Activity Leaderboard"}},
{"type": "context", "elements": [
{"type": "mrkdwn", "text": f"Activity for {date_str}"}
]},
{"type": "divider"},
{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}},
{"type": "context", "elements": [
{"type": "mrkdwn", "text": "C = Calls | E = Emails | M = Meetings"}
]},
]
slack = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
result = slack.chat_postMessage(
channel=os.environ["SLACK_CHANNEL_ID"],
text="Rep Activity Leaderboard",
blocks=blocks,
unfurl_links=False,
)
print(f"Posted leaderboard: {result['ts']}")
# --- Main ---
if __name__ == "__main__":
owner_map = get_owners()
start_ms, end_ms = get_yesterday_range()
calls = search_engagements("calls", start_ms, end_ms)
emails = search_engagements("emails", start_ms, end_ms)
meetings = search_engagements("meetings", start_ms, end_ms)
lines = build_leaderboard(calls, emails, meetings, owner_map)
if lines:
post_leaderboard(lines)
else:
print("No activity logged yesterday — skipping leaderboard.")Step 6: Schedule with cron or GitHub Actions
Cron (server-based):
# crontab -e — runs at 8 AM on weekdays only
0 8 * * 1-5 cd /path/to/rep-leaderboard && python leaderboard.py >> /var/log/leaderboard.log 2>&1GitHub Actions (serverless):
# .github/workflows/rep-leaderboard.yml
name: Daily Rep Leaderboard
on:
schedule:
- cron: '0 13 * * 1-5' # 8 AM ET = 1 PM UTC, weekdays only
workflow_dispatch: {}
jobs:
leaderboard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install requests slack_sdk
- run: python leaderboard.py
env:
HUBSPOT_ACCESS_TOKEN: ${{ secrets.HUBSPOT_ACCESS_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}Never commit tokens to your repo. Use GitHub Secrets, .env files (gitignored), or your hosting platform's secrets manager.
Troubleshooting
Rate limits
| API | Limit | Impact |
|---|---|---|
| HubSpot Search | 5 req/sec | 3 searches (calls, emails, meetings) runs in under 1 second |
| HubSpot general | 150 req/10 sec | No concern |
| Slack chat.postMessage | ~20 req/min | No concern for 1 message |
Common questions
What are the HubSpot API rate limits for this script?
The Search API allows 5 requests per second. This script makes 3 search requests (calls, emails, meetings) plus 1 owners request — well within limits. If pagination kicks in (100+ activities of one type), add a 200ms delay between pages. The general API limit is 150 requests per 10 seconds for private apps.
Where should I host this script?
GitHub Actions (free tier, 2,000 minutes/month) is the simplest serverless option. For local hosting, cron on any always-on machine works. Railway ($5/mo) or a small VPS ($5-7/mo) gives you persistent hosting with easy log access. The script runs in under 10 seconds, so compute costs are negligible.
How do I handle timezone differences between reps?
The script uses UTC midnight-to-midnight as the default window. If your team spans multiple timezones, some activities logged late in the day may fall into the next day's window. You can adjust the timezone offset in the date range calculation, but a simpler approach is to run the leaderboard at 9 AM in your latest timezone — by then, all of yesterday's activities are captured.
Can I add LinkedIn messages or other activity types to the leaderboard?
Yes. HubSpot tracks LinkedIn messages as a separate engagement type. Add another search call with object_type="linkedin_messages" and include it in the ranking. Any HubSpot engagement object that has hs_timestamp and hubspot_owner_id properties can be added the same way.
Cost
- $0 — runs on existing infrastructure. GitHub Actions free tier includes 2,000 minutes/month.
- Maintenance: minimal. Owner list is fetched dynamically, so new reps appear automatically. Update scopes if HubSpot changes their API.
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.