Send a weekly Slack report on HubSpot sequence performance using code and cron
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-...) withchat:writescope - A scheduling environment: cron, GitHub Actions, or a cloud function
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.
Why code?
Code is the best option for sequence reporting when you want zero ongoing cost and full control over the API calls. The script handles pagination, rate limiting, and per-sequence metric calculation in a single file. Setup takes about 45 minutes. The trade-off is that changes require editing code, and you need Sales Hub Professional or Enterprise for Sequences API access regardless of approach.
How it works
- Fetch all sequences from HubSpot via the CRM v3 objects API with pagination
- Loop through each sequence and search
sequence_enrollmentsfor the last 7 days - Count enrollment outcomes — opened, replied, meeting booked — per sequence
- Calculate rates (open rate, reply rate, meeting booked rate) and rank by enrollment count
- Post a Block Kit message to Slack with per-sequence breakdown and aggregate totals
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_ACCESS_TOKEN" | head -c 300Step 2: Fetch all sequences
import os
import time
import requests
from datetime import datetime, timezone, timedelta
HUBSPOT_ACCESS_TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_ACCESS_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 sequencesStep 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_resultsThe 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 reportStep 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.")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>&1GitHub 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_ACCESS_TOKEN: ${{ secrets.HUBSPOT_ACCESS_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}Troubleshooting
Rate limits
| API | Limit | Impact |
|---|---|---|
| HubSpot Search | 5 req/sec | One search per sequence — add 200ms delay between requests |
| HubSpot general | 150 req/10 sec | Unlikely to hit with ~10-20 sequences |
| Slack chat.postMessage | ~20 req/min | No concern for 1 message |
Common questions
What if I have more than 100 sequences?
The get_sequences() function handles pagination automatically using the after cursor. Most teams have 10-30 active sequences, so this rarely matters.
How do I handle rate limits with many sequences?
The 200ms delay between enrollment search requests keeps you at 5 req/sec (HubSpot's Search API limit). If you have sequences with 100+ enrollments requiring pagination, increase the delay to 300ms.
Can I add bounce rate and unsubscribe rate?
Yes. Add hs_was_email_bounced and hs_was_unsubscribed to the enrollment search properties, count them the same way as opens/replies/meetings, and add columns to the report.
Why does the report show different numbers than HubSpot's sequence dashboard?
The script counts enrollments started in the last 7 days (hs_enrollment_start_date). HubSpot's dashboard may show lifetime metrics or use a different date range. The numbers should match if you set the same time period in both.
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.
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.