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)
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_TOKEN" | head -c 200Step 2: Fetch deal details, contacts, and owner
import os
import requests
HUBSPOT_TOKEN = os.environ["HUBSPOT_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_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 |
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.
Need help implementing this?
We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.