Post a daily Slack leaderboard of rep activity from HubSpot using code and cron

medium complexityCost: $0

Prerequisites

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-...) with chat:write scope, or a Slack Incoming Webhook URL
  • A scheduling environment: cron, GitHub Actions, or a cloud function

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_TOKEN" | head -c 200

Step 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_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")
hs_timestamp is in milliseconds

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 lines

Step 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>&1

GitHub 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_TOKEN: ${{ secrets.HUBSPOT_TOKEN }}
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
          SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}
Environment variables

Never commit tokens to your repo. Use GitHub Secrets, .env files (gitignored), or your hosting platform's secrets manager.

Rate limits

APILimitImpact
HubSpot Search5 req/sec3 searches (calls, emails, meetings) runs in under 1 second
HubSpot general150 req/10 secNo concern
Slack chat.postMessage~20 req/minNo concern for 1 message

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.

Need help implementing this?

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