Send a Slack alert when a HubSpot deal changes stage using code
Prerequisites
- Node.js 18+ or Python 3.9+
- HubSpot private app token with
crm.objects.deals.readandcrm.schemas.deals.readscopes - Slack Bot Token (
xoxb-...) withchat:writescope - 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.propertyChangeevents filtered to thedealstageproperty — 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
}'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"}), 200Step 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)
passTroubleshooting
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.