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

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_name changes 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
  }'
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_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"}), 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_ACCESS_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_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)
        pass

Troubleshooting

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.