Instantly notify a rep in Slack when a high-intent lead books a demo using code
Prerequisites
- Node.js 18+ or Python 3.9+
- HubSpot private app token with
crm.objects.contacts.readandformsscopes - Slack Bot Token (
xoxb-...) withchat:writescope - A server or serverless function to receive webhooks (Express, Vercel, AWS Lambda)
- A mapping of HubSpot owner IDs to Slack user IDs
Why code?
A custom webhook handler gives you real-time notifications with zero polling delay and no per-execution costs. You control the filtering logic, the owner mapping, and the Slack message format entirely. The webhook fires the instant HubSpot processes the form submission.
The trade-off is hosting and maintenance. You need a server or serverless function to receive webhooks, and you're responsible for error handling, retries, and uptime. For teams with a developer on staff, this is the most responsive and cost-effective option.
How it works
- HubSpot webhook subscription fires when
recent_conversion_event_namechanges on a contact - Webhook handler filters for demo-specific form names and fetches the full contact record
- Owner mapping resolves the HubSpot owner ID to a Slack user ID
- Slack Web API sends a Block Kit DM to the assigned rep with contact context and a HubSpot link
Step 1: Subscribe to form submissions
Register a webhook subscription for form submissions in HubSpot:
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": "contact.propertyChange",
"propertyName": "recent_conversion_event_name",
"active": true
}'HubSpot doesn't have a direct "form submitted" webhook event type. Instead, subscribe to the recent_conversion_event_name property change, which updates whenever a form is submitted. Filter for your demo form's name in the handler.
Step 2: Build the webhook handler
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}"}
PORTAL_ID = os.environ.get("HUBSPOT_PORTAL_ID", "YOUR_PORTAL_ID")
DEMO_FORM_NAME = "Book a Demo" # Match your form's name
OWNER_TO_SLACK = {
"12345678": "U01AAAA",
"23456789": "U02BBBB",
"34567890": "U03CCCC",
"45678901": "U04DDDD",
}
FALLBACK_CHANNEL = os.environ.get("SLACK_FALLBACK_CHANNEL", "#demo-alerts")
@app.route("/webhook", methods=["POST"])
def handle_webhook():
events = request.json
for event in events:
# Filter for demo form submissions
if event.get("propertyName") != "recent_conversion_event_name":
continue
if DEMO_FORM_NAME.lower() not in (event.get("propertyValue") or "").lower():
continue
contact_id = event["objectId"]
# Fetch full contact
resp = requests.get(
f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}",
headers=HEADERS,
params={"properties": "firstname,lastname,email,jobtitle,company,numberofemployees,industry,hubspot_owner_id,hs_analytics_source"}
)
resp.raise_for_status()
props = resp.json()["properties"]
# Resolve Slack target
owner_id = props.get("hubspot_owner_id")
slack_target = OWNER_TO_SLACK.get(owner_id, FALLBACK_CHANNEL)
name = f"{props.get('firstname', '')} {props.get('lastname', '')}".strip()
slack.chat_postMessage(
channel=slack_target,
text=f"Demo booked: {name} at {props.get('company', 'Unknown')}",
blocks=[
{"type": "header", "text": {"type": "plain_text", "text": "🔥 Demo Booked!"}},
{"type": "section", "fields": [
{"type": "mrkdwn", "text": f"*Name:*\n{name}"},
{"type": "mrkdwn", "text": f"*Title:*\n{props.get('jobtitle', 'N/A')}"},
{"type": "mrkdwn", "text": f"*Company:*\n{props.get('company', 'Unknown')} ({props.get('numberofemployees', '?')} employees)"},
{"type": "mrkdwn", "text": f"*Industry:*\n{props.get('industry', 'Unknown')}"},
{"type": "mrkdwn", "text": f"*Source:*\n{props.get('hs_analytics_source', 'Unknown')}"},
{"type": "mrkdwn", "text": f"*Email:*\n{props.get('email', 'N/A')}"},
]},
{"type": "actions", "elements": [
{"type": "button", "text": {"type": "plain_text", "text": "View Contact"},
"url": f"https://app.hubspot.com/contacts/{PORTAL_ID}/contact/{contact_id}",
"style": "primary"}
]}
]
)
return jsonify({"status": "ok"}), 200Step 3: Deploy and register the webhook URL
Deploy your handler, 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"}'Slack's section block supports a maximum of 10 fields. The example uses 6. If you add more context (lead score, lifecycle stage, etc.), stay under the limit or split into multiple section blocks.
Polling alternative
If you can't host a webhook, poll for recent form submissions:
# Run every 5 minutes via cron
# */5 * * * * python poll_demo_bookings.py
import requests, os
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"])
five_min_ago = int((datetime.now(timezone.utc) - timedelta(minutes=5)).timestamp() * 1000)
resp = requests.post(
"https://api.hubapi.com/crm/v3/objects/contacts/search",
headers=HEADERS,
json={
"filterGroups": [{"filters": [
{"propertyName": "recent_conversion_date", "operator": "GTE", "value": str(five_min_ago)}
]}],
"properties": ["firstname", "lastname", "email", "jobtitle", "company",
"numberofemployees", "industry", "hubspot_owner_id",
"recent_conversion_event_name"],
"limit": 100
}
)
for contact in resp.json().get("results", []):
if "demo" in (contact["properties"].get("recent_conversion_event_name") or "").lower():
# Send Slack alert (same Block Kit as above)
passTroubleshooting
Common questions
Should I use webhooks or polling?
Webhooks give you sub-second latency — the Slack alert arrives within 1-2 seconds of the form submission. Polling (the cron alternative) adds up to 5 minutes of delay. Use webhooks if you can host a server; use polling if you want a simpler deployment (cron + script, no inbound traffic).
How do I handle HubSpot's webhook retries?
HubSpot retries webhook deliveries if your handler responds slowly (>5 seconds) or returns a non-200 status. Return 200 immediately and process the event asynchronously, or add deduplication by tracking processed eventId values in memory or Redis.
What's the best hosting option for the webhook handler?
Vercel serverless functions are free and handle the bursty traffic pattern well (demo bookings are low-volume, high-importance events). Railway (~$5/mo) or a small VPS works too. Avoid cold-start-heavy platforms (AWS Lambda with default settings) if sub-second latency matters.
Cost
- Hosting: Free on Vercel (serverless), ~$5/mo on Railway
- 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.