Instantly notify a rep in Slack when a high-intent lead books a demo using code

medium complexityCost: $0

Prerequisites

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

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_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "eventType": "contact.propertyChange",
    "propertyName": "recent_conversion_event_name",
    "active": true
  }'
Form submission detection

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_TOKEN = os.environ["HUBSPOT_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_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"}), 200

Step 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_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"targetUrl": "https://your-server.com/webhook"}'
Block Kit field limit

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_TOKEN = os.environ["HUBSPOT_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_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)
        pass

Cost

  • Hosting: Free on Vercel (serverless), ~$5/mo on Railway
  • 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.