Automate a sales-to-CS handoff when a HubSpot deal closes won using code
Prerequisites
- Node.js 18+ or Python 3.9+
- HubSpot private app token (scopes:
crm.objects.deals.read,crm.objects.contacts.read,crm.objects.tasks.write) - Slack Bot Token (
xoxb-...) withchat:writescope - A server or cloud function to receive HubSpot webhooks (or a polling alternative via cron)
Why code?
Code gives you full control over the handoff logic with zero vendor lock-in and zero ongoing costs. You can customize every aspect — the Slack message format, the task creation logic, the CS rep routing rules, and the deduplication strategy. The code runs on your own infrastructure (a server, cloud function, or cron job), so there's no execution limit or pricing tier to worry about.
The trade-off is setup and maintenance. You need to deploy and maintain the webhook server or cron job yourself, handle error recovery, and update the code when APIs change. If you want faster setup with less maintenance, use the n8n or Zapier approaches.
How it works
- Webhook server (or cron-based poller) detects deals that moved to Closed Won in HubSpot
- API calls fetch full deal details, associated contacts, and the deal owner's name
- Slack SDK posts a Block Kit handoff message with deal value, contacts, contract terms, and a HubSpot link
- HubSpot API creates an onboarding task associated with the deal, assigned to the CS rep
Overview
There are two approaches for triggering the handoff in code:
- Webhook (recommended) — set up a HubSpot workflow to send a webhook when a deal moves to Closed Won. Your server processes the event in real time.
- Polling via cron — a scheduled script checks for recently closed-won deals. Simpler to set up but introduces a delay.
This guide covers both. The core logic (fetch deal details, post to Slack, create task) is the same.
Step 1: Set up the project
# Verify your HubSpot token works
curl -s "https://api.hubapi.com/crm/v3/objects/deals?limit=1" \
-H "Authorization: Bearer $HUBSPOT_ACCESS_TOKEN" | head -c 200Step 2: Fetch deal details, contacts, and owner
import os
import requests
HUBSPOT_ACCESS_TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_ACCESS_TOKEN}", "Content-Type": "application/json"}
def get_deal_details(deal_id):
"""Fetch full deal record with associations."""
resp = requests.get(
f"https://api.hubapi.com/crm/v3/objects/deals/{deal_id}",
headers=HEADERS,
params={
"properties": "dealname,amount,closedate,hubspot_owner_id,contract_length,description",
"associations": "contacts",
},
)
resp.raise_for_status()
return resp.json()
def get_contact(contact_id):
"""Fetch contact details."""
resp = requests.get(
f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}",
headers=HEADERS,
params={"properties": "firstname,lastname,email,phone,jobtitle,company"},
)
resp.raise_for_status()
return resp.json()
def get_owner(owner_id):
"""Resolve owner ID to name."""
resp = requests.get(
f"https://api.hubapi.com/crm/v3/owners/{owner_id}",
headers=HEADERS,
)
resp.raise_for_status()
data = resp.json()
return f"{data.get('firstName', '')} {data.get('lastName', '')}".strip()Step 3: Post handoff to Slack
from slack_sdk import WebClient
def post_handoff(deal, contact, owner_name):
props = deal["properties"]
c_props = contact["properties"]
amount = float(props.get("amount") or 0)
blocks = [
{"type": "header", "text": {"type": "plain_text", "text": "🎉 New Closed-Won Deal — CS Handoff"}},
{"type": "section", "fields": [
{"type": "mrkdwn", "text": f"*Deal*\n{props['dealname']}"},
{"type": "mrkdwn", "text": f"*Value*\n${amount:,.0f}"},
{"type": "mrkdwn", "text": f"*Sales Rep*\n{owner_name}"},
{"type": "mrkdwn", "text": f"*Close Date*\n{props.get('closedate', 'N/A')[:10]}"},
]},
{"type": "divider"},
{"type": "section", "text": {"type": "mrkdwn", "text": (
f"*Primary Contact*\n"
f"{c_props.get('firstname', '')} {c_props.get('lastname', '')} "
f"({c_props.get('jobtitle', 'No title')})\n"
f"📧 {c_props.get('email', 'No email')}\n"
f"📞 {c_props.get('phone', 'No phone')}"
)}},
{"type": "section", "text": {"type": "mrkdwn", "text": (
f"*Contract Length*\n{props.get('contract_length', 'Not specified')}"
)}},
]
if props.get("description"):
blocks.append({"type": "section", "text": {
"type": "mrkdwn",
"text": f"*Sales Notes*\n{props['description'][:500]}",
}})
blocks.append({"type": "actions", "elements": [{
"type": "button",
"text": {"type": "plain_text", "text": "View Deal in HubSpot"},
"url": f"https://app.hubspot.com/contacts/{os.environ.get('HUBSPOT_PORTAL_ID', 'YOUR_PORTAL_ID')}/deal/{deal['id']}",
}]})
slack = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
result = slack.chat_postMessage(
channel=os.environ["SLACK_CHANNEL_ID"],
text=f"CS Handoff: {props['dealname']}",
blocks=blocks,
unfurl_links=False,
)
return result["ts"]Step 4: Create a HubSpot task for CS
def create_cs_task(deal, contact, owner_name, cs_owner_id):
props = deal["properties"]
c_props = contact["properties"]
amount = float(props.get("amount") or 0)
resp = requests.post(
"https://api.hubapi.com/crm/v3/objects/tasks",
headers=HEADERS,
json={
"properties": {
"hs_task_subject": f"Onboarding: {props['dealname']}",
"hs_task_body": (
f"New closed-won deal ready for CS onboarding.\n\n"
f"Deal: {props['dealname']}\n"
f"Value: ${amount:,.0f}\n"
f"Sales Rep: {owner_name}\n"
f"Contact: {c_props.get('email', 'N/A')}"
),
"hs_task_status": "NOT_STARTED",
"hs_task_priority": "HIGH",
"hubspot_owner_id": cs_owner_id,
},
"associations": [{
"to": {"id": deal["id"]},
"types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 204}],
}],
},
)
resp.raise_for_status()
return resp.json()The type ID 204 links a task to a deal. If you also want to associate the task with the contact, add a second entry with type ID 1. See HubSpot's associations API docs for the full list.
Step 5: Wire it up — webhook or polling
Option A: Webhook server
from flask import Flask, request, jsonify
app = Flask(__name__)
CS_OWNER_ID = os.environ.get("CS_OWNER_ID", "YOUR_CS_REP_OWNER_ID")
@app.route("/webhook/closed-won", methods=["POST"])
def handle_closed_won():
payload = request.json
deal_id = payload.get("objectId") or payload[0].get("objectId")
deal = get_deal_details(deal_id)
if deal["properties"].get("dealstage") != "closedwon":
return jsonify({"status": "skipped"}), 200
# Get primary contact
contacts = deal.get("associations", {}).get("contacts", {}).get("results", [])
contact = get_contact(contacts[0]["id"]) if contacts else {"properties": {}}
owner_name = get_owner(deal["properties"]["hubspot_owner_id"])
post_handoff(deal, contact, owner_name)
create_cs_task(deal, contact, owner_name, CS_OWNER_ID)
return jsonify({"status": "ok"}), 200
if __name__ == "__main__":
app.run(port=3000)Then configure a HubSpot workflow to POST to your webhook URL when a deal enters Closed Won.
Option B: Polling via cron
def check_recent_closed_won():
"""Search for deals closed in the last hour."""
one_hour_ago = str(int((datetime.now(timezone.utc) - timedelta(hours=1)).timestamp() * 1000))
resp = requests.post(
"https://api.hubapi.com/crm/v3/objects/deals/search",
headers=HEADERS,
json={
"filterGroups": [{"filters": [
{"propertyName": "dealstage", "operator": "EQ", "value": "closedwon"},
{"propertyName": "hs_lastmodifieddate", "operator": "GTE", "value": one_hour_ago},
]}],
"properties": ["dealname", "amount", "closedate", "hubspot_owner_id", "contract_length", "description"],
"limit": 100,
},
)
resp.raise_for_status()
return resp.json()["results"]Schedule with cron to run every hour:
0 * * * * cd /path/to/cs-handoff && python handoff.py >> /var/log/cs-handoff.log 2>&1With polling, you need to track which deals you've already processed to avoid duplicate handoffs. Store processed deal IDs in a file, database, or environment variable between runs.
Rate limits
| API | Limit | Impact |
|---|---|---|
| HubSpot general | 150 req/10 sec | ~4 requests per handoff. No concern. |
| HubSpot Search | 5 req/sec | Only used in polling mode |
| Slack chat.postMessage | ~20 req/min | No concern |
Troubleshooting
Common questions
Should I use webhooks or polling?
Webhooks for instant delivery if you can host a server. Polling via cron if you want simpler infrastructure — no public endpoint needed. Webhooks require a HubSpot workflow to POST to your URL; polling requires deduplication logic to avoid duplicate handoffs. Most teams start with polling (simpler) and switch to webhooks when they need faster notifications.
How do I handle deduplication with the polling approach?
Store processed deal IDs between runs — a local JSON file, a database row, or an environment variable. On each poll, check if the deal ID has already been processed before running the handoff. Clear old entries after 30 days to prevent the file from growing indefinitely.
What's the best hosting option for the webhook server?
AWS Lambda or Cloudflare Workers for zero-cost serverless hosting. Both handle HubSpot's webhook volume easily (each closed-won deal is a single POST). If you already have a server, add the /webhook/closed-won endpoint to your existing Express/Flask app.
Cost
- $0 — runs on existing infrastructure. Use a free cloud function (AWS Lambda, Cloudflare Workers) for the webhook server.
- Maintenance: update CS owner ID when team changes. Consider externalizing the routing config to a JSON file or environment variable.
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.