Alert Slack when a HubSpot deal is stuck in a stage for over 14 days using code
Why code?
The code approach gives you full control over stale deal detection with zero ongoing cost. You can handle pagination for large pipelines (100+ deals), group deals by owner for targeted notifications, calculate per-stage staleness thresholds, and format the output exactly how your team wants it. GitHub Actions provides free scheduling, so there's no infrastructure to maintain.
The trade-off is setup time. You need to write the script, handle API pagination, and deploy it. If your team has a developer who can spend 30-45 minutes on this, the result is more powerful and flexible than any visual builder. If not, n8n or Make will get you 90% of the way with a visual interface.
How it works
- Cron schedule (local cron or GitHub Actions) triggers the script daily
- HubSpot Pipelines API call fetches stage labels to resolve IDs like
closedwonto "Closed Won" - HubSpot CRM Search API finds deals where
hs_lastmodifieddateis older than 14 days, excluding closed stages - Date calculation computes days stale for each deal
- Slack Web API posts a summary message with deal names, stages, amounts, and days-stale counts
Prerequisites
- Node.js 18+ or Python 3.9+
- HubSpot private app token
- Slack Bot Token with
chat:writescope - Cron or GitHub Actions for scheduling
Step 1: Build the stale deal finder
import os, requests
from datetime import datetime, timedelta, timezone
from slack_sdk import WebClient
HUBSPOT_ACCESS_TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_ACCESS_TOKEN}", "Content-Type": "application/json"}
slack = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
# Fetch stage labels
stages_resp = requests.get("https://api.hubapi.com/crm/v3/pipelines/deals",
headers={"Authorization": f"Bearer {HUBSPOT_ACCESS_TOKEN}"})
stage_map = {}
for p in stages_resp.json()["results"]:
for s in p["stages"]:
stage_map[s["id"]] = s["label"]
# Search for stale deals
fourteen_days_ago = int((datetime.now(timezone.utc) - timedelta(days=14)).timestamp() * 1000)
resp = requests.post(
"https://api.hubapi.com/crm/v3/objects/deals/search",
headers=HEADERS,
json={
"filterGroups": [{"filters": [
{"propertyName": "hs_lastmodifieddate", "operator": "LT", "value": str(fourteen_days_ago)},
{"propertyName": "dealstage", "operator": "NOT_IN", "values": ["closedwon", "closedlost"]}
]}],
"properties": ["dealname", "amount", "dealstage", "hs_lastmodifieddate"],
"sorts": [{"propertyName": "hs_lastmodifieddate", "direction": "ASCENDING"}],
"limit": 100
}
)
deals = resp.json().get("results", [])
if not deals:
print("No stale deals found")
exit(0)
# Format and send
lines = []
for deal in deals:
props = deal["properties"]
days = (datetime.now(timezone.utc) - datetime.fromisoformat(
props["hs_lastmodifieddate"].replace("Z", "+00:00"))).days
amount = float(props.get("amount") or 0)
stage = stage_map.get(props.get("dealstage", ""), props.get("dealstage", ""))
lines.append(f"• *{props['dealname']}* — {stage} — ${amount:,.0f} — {days}d stale")
slack.chat_postMessage(
channel=os.environ["SLACK_CHANNEL_ID"],
text=f"Stale deals alert: {len(deals)} deals",
blocks=[
{"type": "header", "text": {"type": "plain_text", "text": f"⚠️ {len(deals)} Stale Deals (14+ days)"}},
{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}},
{"type": "context", "elements": [{"type": "mrkdwn",
"text": f"Checked {datetime.now().strftime('%A, %B %d, %Y')}"}]}
]
)
print(f"Alerted on {len(deals)} stale deals")Step 2: Schedule
# Daily at 8 AM
0 8 * * * cd /path/to/project && python stale_deals.pyOr use GitHub Actions:
name: Stale Deal Alert
on:
schedule:
- cron: '0 13 * * *' # 8 AM ET
jobs:
alert:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- run: pip install requests slack_sdk && python stale_deals.py
env:
HUBSPOT_ACCESS_TOKEN: ${{ secrets.HUBSPOT_ACCESS_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}Troubleshooting
Common questions
How do I handle more than 100 stale deals?
Add a pagination loop using the after cursor from the response's paging.next.after field. Keep fetching pages until the response no longer includes a paging object. This is straightforward in Python or Node.js but difficult in Zapier or Make.
Can I group alerts by owner so each rep gets their own message?
Yes. After fetching all stale deals, group them by hubspot_owner_id in a dictionary. Then iterate over the groups and post one Slack message per owner. This is the approach shown in the n8n Code node — the same logic works in Python or Node.js.
Does hs_lastmodifieddate update when someone adds a note?
Yes. Any change to the deal record — including notes, task completions, or email logging — updates hs_lastmodifieddate. This means a deal with recent activity notes may not appear in the stale list even if it hasn't changed stages. For stage-specific staleness, track stage changes separately with a custom property.
Cost
- Free — GitHub Actions provides 2,000 minutes/month on the free tier.
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.