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)

Why code?

The code approach gives you two things no other option does: real-time delivery and zero ongoing cost. HubSpot's webhook API pushes events to your server within seconds of a stage change — no polling delay, no per-task fees. You pay only for hosting, which can be free on Vercel's serverless tier or about $5/mo on Railway.

The trade-off is setup complexity. You need to register a webhook subscription, build and deploy an endpoint, and handle edge cases like retry logic and signature validation. If your team has a developer comfortable with Express or Flask, this takes 30-45 minutes. If not, Zapier or n8n will get you to the same result faster.

How it works

  • HubSpot webhook subscription registers your endpoint to receive deal.propertyChange events filtered to the dealstage property — HubSpot pushes events in near real-time
  • Express/Flask handler receives batched webhook events, extracts the deal ID and new stage value from each event
  • HubSpot CRM API call fetches the full deal record (name, amount, owner) since the webhook payload only includes the changed property
  • Pipeline stages cache maps internal stage IDs to display labels — loaded once on server startup to avoid repeated API calls
  • Slack Web API posts a Block Kit message with the deal details and a direct link to the HubSpot record

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_ACCESS_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_ACCESS_TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_ACCESS_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_ACCESS_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_ACCESS_TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_ACCESS_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

Troubleshooting

Common questions

What are the trade-offs between webhooks and polling?

Webhooks deliver events in 1-5 seconds but require a publicly accessible server. Polling (the alternative at the end of this guide) runs on a schedule — every 5 minutes via cron, for example — and can run anywhere, including GitHub Actions. Webhooks are better for time-sensitive alerts; polling is better if you can't host an endpoint or want simpler infrastructure.

Where should I host the webhook handler?

For low-volume alerting, Vercel's serverless functions work well and are free. Railway ($5/mo) gives you a persistent server with easier debugging. AWS Lambda with API Gateway is another free-tier option. For production use with high reliability, a small VPS (DigitalOcean, Hetzner) at $5-7/mo gives you full control and persistent process for the stage map cache.

How do I handle webhook retries and deduplication?

HubSpot retries failed webhook deliveries (non-2xx responses) for up to 24 hours with exponential backoff. To prevent duplicate alerts, track processed event IDs — store the eventId from each webhook payload in a database or in-memory set (if you're okay losing state on restart). Check the set before processing each event.

How do I test webhooks locally?

Use ngrok to create a public tunnel to your local server: ngrok http 3000. Copy the HTTPS URL ngrok gives you and register it as your webhook URL with HubSpot. Events will flow through ngrok to your local Express/Flask server. Remember to update the webhook URL to your production server before going live.

Cost

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

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.