Send a weekly Slack report on HubSpot sequence performance using code and cron

medium complexityCost: $0Recommended

Prerequisites

Prerequisites
  • Node.js 18+ or Python 3.9+
  • HubSpot Sales Hub Professional or Enterprise (required for Sequences API access)
  • HubSpot private app token (scopes: crm.objects.contacts.read, sales-email-read)
  • Slack Bot Token (xoxb-...) with chat:write scope
  • A scheduling environment: cron, GitHub Actions, or a cloud function
Sequences API access

The HubSpot Sequences API is only available with Sales Hub Professional or Enterprise. Starter and free plans don't have API access to sequence data, even if sequences are available in the UI.

Step 1: Set up the project

# Verify your token can access sequences
curl -s "https://api.hubapi.com/crm/v3/objects/sequences?limit=1" \
  -H "Authorization: Bearer $HUBSPOT_TOKEN" | head -c 300

Step 2: Fetch all sequences

import os
import time
import requests
from datetime import datetime, timezone, timedelta
 
HUBSPOT_TOKEN = os.environ["HUBSPOT_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"}
 
def get_sequences():
    """Fetch all sequences from HubSpot."""
    sequences = []
    after = "0"
    while True:
        resp = requests.get(
            "https://api.hubapi.com/crm/v3/objects/sequences",
            headers=HEADERS,
            params={"limit": 100, "after": after, "properties": "hs_sequence_name"},
        )
        resp.raise_for_status()
        data = resp.json()
        sequences.extend(data["results"])
        if data.get("paging", {}).get("next", {}).get("after"):
            after = data["paging"]["next"]["after"]
        else:
            break
    return sequences

Step 3: Fetch enrollment data per sequence

Query sequence enrollments for the last 7 days. Each enrollment record includes flags for whether the contact opened, replied, or booked a meeting.

def get_enrollments(sequence_id, start_ms):
    """Get enrollments for a specific sequence in the given time range."""
    all_results = []
    after = "0"
    while True:
        resp = requests.post(
            "https://api.hubapi.com/crm/v3/objects/sequence_enrollments/search",
            headers=HEADERS,
            json={
                "filterGroups": [{
                    "filters": [
                        {"propertyName": "hs_sequence_id", "operator": "EQ", "value": str(sequence_id)},
                        {"propertyName": "hs_enrollment_start_date", "operator": "GTE", "value": start_ms},
                    ]
                }],
                "properties": [
                    "hs_sequence_id", "hs_enrollment_state",
                    "hs_was_email_opened", "hs_was_email_replied",
                    "hs_was_meeting_booked",
                ],
                "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
Search API rate limit

The HubSpot Search endpoint is limited to 5 requests per second. When fetching enrollments for multiple sequences, add a 200ms delay between requests to stay within limits.

Step 4: Calculate performance metrics

def calculate_metrics(sequences, start_ms):
    report = []
    for seq in sequences:
        seq_name = seq["properties"].get("hs_sequence_name", f"Sequence {seq['id']}")
        enrollments = get_enrollments(seq["id"], start_ms)
        time.sleep(0.2)  # Rate limit: 5 req/sec
 
        if not enrollments:
            continue
 
        enrolled = len(enrollments)
        opened = sum(1 for e in enrollments if e["properties"].get("hs_was_email_opened") == "true")
        replied = sum(1 for e in enrollments if e["properties"].get("hs_was_email_replied") == "true")
        meetings = sum(1 for e in enrollments if e["properties"].get("hs_was_meeting_booked") == "true")
 
        report.append({
            "name": seq_name,
            "enrolled": enrolled,
            "opened": opened,
            "replied": replied,
            "meetings": meetings,
            "open_rate": round(opened / enrolled * 100, 1),
            "reply_rate": round(replied / enrolled * 100, 1),
            "meeting_rate": round(meetings / enrolled * 100, 1),
        })
 
    report.sort(key=lambda r: r["enrolled"], reverse=True)
    return report

Step 5: Post to Slack

from slack_sdk import WebClient
 
def post_report(report):
    total_enrolled = sum(r["enrolled"] for r in report)
    total_replied = sum(r["replied"] for r in report)
    total_meetings = sum(r["meetings"] for r in report)
 
    seq_lines = []
    for r in report:
        seq_lines.append(
            f"*{r['name']}* ({r['enrolled']} enrolled)\n"
            f"    Open: {r['open_rate']}% | Reply: {r['reply_rate']}% | Meeting: {r['meeting_rate']}%"
        )
 
    blocks = [
        {"type": "header", "text": {"type": "plain_text", "text": "📧 Weekly Sequence Performance Report"}},
        {"type": "section", "fields": [
            {"type": "mrkdwn", "text": f"*Total Enrolled*\n{total_enrolled}"},
            {"type": "mrkdwn", "text": f"*Total Replies*\n{total_replied}"},
            {"type": "mrkdwn", "text": f"*Meetings Booked*\n{total_meetings}"},
        ]},
        {"type": "divider"},
        {"type": "section", "text": {
            "type": "mrkdwn",
            "text": "*Per-Sequence Breakdown*\n\n" + "\n\n".join(seq_lines),
        }},
        {"type": "context", "elements": [{
            "type": "mrkdwn",
            "text": f"Last 7 days | Generated {datetime.now().strftime('%A, %B %d, %Y')}",
        }]},
    ]
 
    slack = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
    result = slack.chat_postMessage(
        channel=os.environ["SLACK_CHANNEL_ID"],
        text="Weekly Sequence Performance Report",
        blocks=blocks,
        unfurl_links=False,
    )
    print(f"Posted report: {result['ts']}")
 
# --- Main ---
if __name__ == "__main__":
    now = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
    start = now - timedelta(days=7)
    start_ms = str(int(start.timestamp() * 1000))
 
    sequences = get_sequences()
    print(f"Found {len(sequences)} sequences")
 
    report = calculate_metrics(sequences, start_ms)
    if report:
        post_report(report)
    else:
        print("No enrollment data for the last 7 days — skipping report.")
Slack Block Kit limits

Section text has a 3,000-character max. If you have many sequences, truncate to the top 10 and note the remainder, or post overflow in a threaded reply using thread_ts.

Step 6: Schedule with cron or GitHub Actions

Cron (server-based):

# crontab -e
0 9 * * 1 cd /path/to/sequence-report && python report.py >> /var/log/sequence-report.log 2>&1

GitHub Actions (serverless):

# .github/workflows/sequence-report.yml
name: Weekly Sequence Report
on:
  schedule:
    - cron: '0 14 * * 1'  # 9 AM ET = 2 PM UTC
  workflow_dispatch: {}
jobs:
  report:
    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 report.py
        env:
          HUBSPOT_TOKEN: ${{ secrets.HUBSPOT_TOKEN }}
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
          SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}

Rate limits

APILimitImpact
HubSpot Search5 req/secOne search per sequence — add 200ms delay between requests
HubSpot general150 req/10 secUnlikely to hit with ~10-20 sequences
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.
  • HubSpot: requires Sales Hub Professional ($100/user/mo) or Enterprise for Sequences API access. No additional API cost.

Need help implementing this?

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