Alert Slack when a HubSpot deal is stuck in a stage for over 14 days using code

medium complexityCost: $0

Prerequisites

Prerequisites
  • Node.js 18+ or Python 3.9+
  • HubSpot private app token
  • Slack Bot Token with chat:write scope
  • 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_TOKEN = os.environ["HUBSPOT_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_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_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.py

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

Cost

  • Free — GitHub Actions provides 2,000 minutes/month on the free tier.

Need help implementing this?

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