Round-robin route HubSpot leads and notify reps in Slack using code

high complexityCost: $0

Prerequisites

Prerequisites
  • Node.js 18+ or Python 3.9+
  • HubSpot private app token with crm.objects.contacts.read, crm.objects.contacts.write, and settings.users.read scopes
  • Slack Bot Token (xoxb-...) with chat:write scope
  • A server or serverless function to receive webhooks (Express, Vercel, AWS Lambda)
  • A persistence layer for the round-robin counter (Redis, a JSON file, or an environment variable)

Step 1: Define the rep roster

Create a config that maps each rep's HubSpot owner ID to their Slack user ID:

REPS = [
    {"name": "Alice", "hubspot_owner_id": "12345678", "slack_user_id": "U01AAAA"},
    {"name": "Bob",   "hubspot_owner_id": "23456789", "slack_user_id": "U02BBBB"},
    {"name": "Carol", "hubspot_owner_id": "34567890", "slack_user_id": "U03CCCC"},
    {"name": "Dave",  "hubspot_owner_id": "45678901", "slack_user_id": "U04DDDD"},
]

Step 2: Build the webhook handler with round-robin logic

from flask import Flask, request, jsonify
from slack_sdk import WebClient
import requests, os, json
 
app = Flask(__name__)
slack = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
HUBSPOT_TOKEN = os.environ["HUBSPOT_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"}
PORTAL_ID = os.environ.get("HUBSPOT_PORTAL_ID", "YOUR_PORTAL_ID")
 
COUNTER_FILE = "/tmp/round_robin_counter.json"
 
def get_next_rep():
    """Read counter, pick the next rep, increment and save."""
    try:
        with open(COUNTER_FILE) as f:
            data = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        data = {"index": 0}
 
    rep = REPS[data["index"] % len(REPS)]
    data["index"] = (data["index"] + 1) % len(REPS)
 
    with open(COUNTER_FILE, "w") as f:
        json.dump(data, f)
 
    return rep
 
@app.route("/webhook", methods=["POST"])
def handle_webhook():
    events = request.json
    for event in events:
        if event.get("subscriptionType") != "contact.creation":
            continue
 
        contact_id = event["objectId"]
 
        # Fetch contact details
        resp = requests.get(
            f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}",
            headers=HEADERS,
            params={"properties": "firstname,lastname,email,company,jobtitle,hubspot_owner_id"}
        )
        resp.raise_for_status()
        props = resp.json()["properties"]
 
        # Skip if already assigned
        if props.get("hubspot_owner_id"):
            continue
 
        # Assign via round-robin
        rep = get_next_rep()
 
        requests.patch(
            f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}",
            headers=HEADERS,
            json={"properties": {"hubspot_owner_id": rep["hubspot_owner_id"]}}
        ).raise_for_status()
 
        # Slack DM
        name = f"{props.get('firstname', '')} {props.get('lastname', '')}".trim() if hasattr(str, 'trim') else f"{props.get('firstname', '')} {props.get('lastname', '')}".strip()
        slack.chat_postMessage(
            channel=rep["slack_user_id"],
            text=f"New lead assigned: {name}",
            blocks=[
                {"type": "section", "text": {"type": "mrkdwn",
                    "text": f"🆕 *New Lead Assigned to You*\n*{name}* — {props.get('jobtitle', 'No title')} at {props.get('company', 'Unknown')}\nEmail: {props.get('email', 'N/A')}"}},
                {"type": "actions", "elements": [
                    {"type": "button", "text": {"type": "plain_text", "text": "View in HubSpot"},
                     "url": f"https://app.hubspot.com/contacts/{PORTAL_ID}/contact/{contact_id}"}
                ]}
            ]
        )
 
    return jsonify({"status": "ok"}), 200
Counter persistence in serverless

A JSON file on /tmp works for a single server but resets on serverless cold starts. For production, use Redis, DynamoDB, or a database row. The key requirement is atomic read-and-increment to avoid race conditions.

Step 3: Register the webhook with HubSpot

Subscribe to contact creation events:

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.creation",
    "active": true
  }'

Then set your webhook target URL:

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"}'

Polling alternative

If you can't host a webhook, poll for recently created unassigned contacts:

# Run every 5 minutes via cron
# */5 * * * * python poll_new_leads.py
 
import requests, os, json
from datetime import datetime, timedelta, timezone
 
HUBSPOT_TOKEN = os.environ["HUBSPOT_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"}
 
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": "createdate", "operator": "GTE", "value": str(five_min_ago)},
            {"propertyName": "hubspot_owner_id", "operator": "NOT_HAS_PROPERTY"}
        ]}],
        "properties": ["firstname", "lastname", "email", "company", "jobtitle"],
        "limit": 100
    }
)
 
for contact in resp.json().get("results", []):
    rep = get_next_rep()  # Same function from above
    # Assign and notify (same logic as webhook handler)
    pass

Cost

  • Hosting: Free on Vercel (serverless), ~$5/mo on Railway
  • Redis (for counter persistence): Free tier on Upstash or 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.