Send a Slack alert when a HubSpot deal changes stage using code

medium complexityCost: $0

Prerequisites

Prerequisites
  • Node.js 18+ or Python 3.9+
  • HubSpot private app token with crm.objects.deals.read and crm.schemas.deals.read scopes
  • Slack Bot Token (xoxb-...) with chat:write scope
  • A server or serverless function to receive webhooks (Express, Vercel, AWS Lambda)

Step 1: Subscribe to deal property changes

HubSpot's webhook subscriptions API lets you receive events when deal properties change. Set up a subscription for the dealstage property:

curl -X POST "https://api.hubapi.com/webhooks/v3/YOUR_APP_ID/subscriptions" \
  -H "Authorization: Bearer $HUBSPOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "eventType": "deal.propertyChange",
    "propertyName": "dealstage",
    "active": true
  }'
Webhook vs. polling

Webhooks are instant (1-5 seconds). If you can't host a webhook endpoint, use polling instead — search for deals modified in the last N minutes on a cron schedule. See the polling alternative at the end.

Step 2: Build the webhook handler

Create an endpoint that receives HubSpot webhook events, fetches the full deal, resolves the stage name, and posts to Slack:

from flask import Flask, request, jsonify
from slack_sdk import WebClient
import requests
import os
 
app = Flask(__name__)
slack = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
HUBSPOT_TOKEN = os.environ["HUBSPOT_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_TOKEN}"}
 
# Cache pipeline stages on startup
stages_resp = requests.get("https://api.hubapi.com/crm/v3/pipelines/deals", headers=HEADERS)
STAGE_MAP = {}
for pipeline in stages_resp.json()["results"]:
    for stage in pipeline["stages"]:
        STAGE_MAP[stage["id"]] = stage["label"]
 
@app.route("/webhook", methods=["POST"])
def handle_webhook():
    events = request.json
    for event in events:
        if event.get("propertyName") != "dealstage":
            continue
 
        deal_id = event["objectId"]
        new_stage_id = event["propertyValue"]
        stage_name = STAGE_MAP.get(new_stage_id, new_stage_id)
 
        # Fetch deal details
        deal_resp = requests.get(
            f"https://api.hubapi.com/crm/v3/objects/deals/{deal_id}",
            headers=HEADERS,
            params={"properties": "dealname,amount,hubspot_owner_id"}
        )
        deal = deal_resp.json()["properties"]
 
        amount = float(deal.get("amount") or 0)
        slack.chat_postMessage(
            channel=os.environ["SLACK_CHANNEL_ID"],
            text=f"Deal stage changed: {deal['dealname']}",
            blocks=[
                {"type": "section", "text": {"type": "mrkdwn",
                    "text": f"🔄 *Deal Stage Changed*\n*{deal['dealname']}* moved to *{stage_name}*\nAmount: ${amount:,.0f}"}},
                {"type": "context", "elements": [{"type": "mrkdwn",
                    "text": f"<https://app.hubspot.com/contacts/YOUR_PORTAL_ID/deal/{deal_id}|View in HubSpot>"}]}
            ]
        )
    return jsonify({"status": "ok"}), 200

Step 3: Deploy and register the webhook URL

Deploy your handler to a publicly accessible URL (Vercel, Railway, AWS Lambda, etc.), then register it with HubSpot:

curl -X PUT "https://api.hubapi.com/webhooks/v3/YOUR_APP_ID/settings" \
  -H "Authorization: Bearer $HUBSPOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"targetUrl": "https://your-server.com/webhook"}'

Polling alternative

If you can't host a webhook, poll on a schedule instead:

# Run every 5 minutes via cron
# 0/5 * * * * python poll_stage_changes.py
 
import requests, os, json
from datetime import datetime, timedelta, timezone
from slack_sdk import WebClient
 
HUBSPOT_TOKEN = os.environ["HUBSPOT_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_TOKEN}"}
slack = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
 
five_min_ago = int((datetime.now(timezone.utc) - timedelta(minutes=5)).timestamp() * 1000)
 
resp = requests.post(
    "https://api.hubapi.com/crm/v3/objects/deals/search",
    headers={**HEADERS, "Content-Type": "application/json"},
    json={
        "filterGroups": [{"filters": [{
            "propertyName": "hs_lastmodifieddate",
            "operator": "GTE",
            "value": str(five_min_ago)
        }]}],
        "properties": ["dealname", "amount", "dealstage"],
        "limit": 100
    }
)
 
for deal in resp.json().get("results", []):
    # Post to Slack (same Block Kit as above)
    pass

Cost

  • Hosting: Free on Vercel (serverless), ~$5/mo on Railway, or use GitHub Actions for polling
  • No per-execution cost beyond hosting

Need help implementing this?

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