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)

Why code?

A custom webhook handler gives you real-time round-robin routing with full control over counter persistence. You can use Redis for atomic increments (preventing race conditions under concurrent load), implement weighted routing, or add complex skip logic for out-of-office reps — none of which are easy in no-code tools.

The trade-off is hosting and maintenance. You need a server or serverless function, and you're responsible for the counter storage layer. For teams with a developer on staff that need guaranteed fairness under high concurrency, this is the most reliable option.

How it works

  • HubSpot webhook fires on contact creation events
  • Webhook handler fetches the full contact record and checks if an owner is already assigned
  • Round-robin logic reads the counter from a persistent store (file, Redis, or database), selects the next rep, and increments atomically
  • HubSpot PATCH sets the contact's hubspot_owner_id
  • Slack Web API sends a DM to the assigned rep with context and a HubSpot link

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_ACCESS_TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_ACCESS_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_ACCESS_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_ACCESS_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_ACCESS_TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_ACCESS_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

Troubleshooting

Common questions

What's the best storage for the round-robin counter?

Redis with INCR is the gold standard — atomic, fast, and handles concurrent requests without race conditions. Upstash offers a free tier (10K requests/day). A database row with UPDATE ... RETURNING also works. The /tmp JSON file approach is fine for low-volume single-server setups but breaks under serverless cold starts or concurrent requests.

How do I handle reps going on vacation?

Maintain an activeReps array that's a subset of REPS. When someone goes on PTO, remove them from activeReps and reset the counter. When they return, add them back. Store the active list in the same Redis/database as the counter for easy updates.

Should I use webhooks or polling?

Webhooks give you sub-second assignment — the lead is routed before the rep even sees it in HubSpot. Polling adds up to 5 minutes of delay. Use webhooks if you can host a server; use polling for simpler deployment with a small delay trade-off.

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

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.