Automate a sales-to-CS handoff when a HubSpot deal closes won using n8n

low complexityCost: $0-24/moRecommended

Prerequisites

Prerequisites
  • n8n instance — either n8n cloud or self-hosted
  • HubSpot private app token with crm.objects.deals.read, crm.objects.contacts.read, and crm.objects.tasks.write scopes
  • Slack workspace with a Slack app configured (Bot Token Scopes: chat:write, chat:write.public)
  • n8n credentials set up for both HubSpot and Slack

Why n8n?

n8n gives you the best balance of speed, flexibility, and cost for the CS handoff. The HubSpot Trigger node uses webhooks for near-instant delivery (1-5 seconds after a deal closes), and n8n's visual editor makes it easy to add steps like contact enrichment, CS rep routing, or onboarding project creation without writing code.

Self-hosted n8n is completely free with unlimited executions. n8n Cloud starts at $24/mo for 2,500 executions. Each closed-won deal triggers a single execution with 6-8 node steps — most teams close 10-50 deals/month, so this workflow barely registers on your quota.

How it works

  • HubSpot Trigger node fires on deal.propertyChange for the dealstage property via webhook
  • IF node filters for deals that moved to Closed Won only
  • HTTP Request nodes fetch full deal details, associated contacts, and the deal owner's name
  • Slack node posts a Block Kit handoff message with deal value, contacts, contract terms, and a HubSpot link
  • HTTP Request node creates an onboarding task in HubSpot assigned to the CS rep, associated with the deal

Step 1: Add a HubSpot Trigger node

Create a new workflow and add a HubSpot Trigger node:

  • Authentication: Select your HubSpot credential
  • Event: Deal Property Changed
  • Property: dealstage

This fires every time any deal's stage property changes. You'll filter for closedwon in the next step.

Webhook-based trigger

The HubSpot Trigger node uses webhooks for near-instant delivery (1-5 seconds). The trigger sends the deal ID and the new stage value.

Step 2: Filter for Closed Won only

Add an IF node to only process deals that moved to Closed Won:

  • Condition: {{ $json.properties.dealstage.value }} equals closedwon

Route the true output to the rest of the workflow. The false output goes nowhere — other stage changes are ignored.

Step 3: Fetch full deal details

The trigger only sends the deal ID and changed property. Add an HTTP Request node to get the full deal record:

  • Method: GET
  • URL: https://api.hubapi.com/crm/v3/objects/deals/{{ $json.objectId }}
  • Authentication: Predefined -> HubSpot API
  • Query params: properties=dealname,amount,dealstage,hubspot_owner_id,closedate,hs_num_associated_contacts,notes_last_updated,contract_length,description&associations=contacts

The associations=contacts parameter returns associated contact IDs so you can pull contact details.

Step 4: Fetch associated contacts

Add another HTTP Request node to get the primary contact's details:

  • Method: GET
  • URL: https://api.hubapi.com/crm/v3/objects/contacts/{{ $json.associations.contacts.results[0].id }}
  • Query params: properties=firstname,lastname,email,phone,jobtitle,company
Multiple contacts

A deal may have multiple associated contacts. The example above fetches only the first. To handle all contacts, add a Split In Batches node to iterate over $json.associations.contacts.results and fetch each one.

Step 5: Fetch the deal owner's name

Add an HTTP Request node to resolve the sales rep's owner ID to a name:

  • Method: GET
  • URL: https://api.hubapi.com/crm/v3/owners/{{ $json.properties.hubspot_owner_id }}

Step 6: Send Slack notification to CS channel

Add a Slack node:

  • Resource: Message
  • Operation: Send a Message
  • Channel: #cs-handoffs
  • Message Type: Block Kit
{
  "blocks": [
    {
      "type": "header",
      "text": {
        "type": "plain_text",
        "text": "🎉 New Closed-Won Deal — CS Handoff"
      }
    },
    {
      "type": "section",
      "fields": [
        {
          "type": "mrkdwn",
          "text": "*Deal*\n{{ $('Fetch Deal').item.json.properties.dealname }}"
        },
        {
          "type": "mrkdwn",
          "text": "*Value*\n${{ parseFloat($('Fetch Deal').item.json.properties.amount || '0').toLocaleString() }}"
        },
        {
          "type": "mrkdwn",
          "text": "*Sales Rep*\n{{ $('Fetch Owner').item.json.firstName }} {{ $('Fetch Owner').item.json.lastName }}"
        },
        {
          "type": "mrkdwn",
          "text": "*Close Date*\n{{ $('Fetch Deal').item.json.properties.closedate.split('T')[0] }}"
        }
      ]
    },
    {
      "type": "divider"
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*Primary Contact*\n{{ $('Fetch Contact').item.json.properties.firstname }} {{ $('Fetch Contact').item.json.properties.lastname }} ({{ $('Fetch Contact').item.json.properties.jobtitle || 'No title' }})\n📧 {{ $('Fetch Contact').item.json.properties.email }}\n📞 {{ $('Fetch Contact').item.json.properties.phone || 'No phone' }}"
      }
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*Contract Length*\n{{ $('Fetch Deal').item.json.properties.contract_length || 'Not specified' }}"
      }
    },
    {
      "type": "actions",
      "elements": [
        {
          "type": "button",
          "text": {
            "type": "plain_text",
            "text": "View Deal in HubSpot"
          },
          "url": "https://app.hubspot.com/contacts/YOUR_PORTAL_ID/deal/{{ $('Fetch Deal').item.json.id }}"
        }
      ]
    }
  ]
}

Step 7: Create a HubSpot task for the CS rep

Add an HTTP Request node to create an onboarding task:

  • Method: POST
  • URL: https://api.hubapi.com/crm/v3/objects/tasks
  • Body:
{
  "properties": {
    "hs_task_subject": "Onboarding: {{ $('Fetch Deal').item.json.properties.dealname }}",
    "hs_task_body": "New closed-won deal ready for CS onboarding.\n\nDeal: {{ $('Fetch Deal').item.json.properties.dealname }}\nValue: ${{ parseFloat($('Fetch Deal').item.json.properties.amount || '0').toLocaleString() }}\nSales Rep: {{ $('Fetch Owner').item.json.firstName }} {{ $('Fetch Owner').item.json.lastName }}\nPrimary Contact: {{ $('Fetch Contact').item.json.properties.email }}",
    "hs_task_status": "NOT_STARTED",
    "hs_task_priority": "HIGH",
    "hubspot_owner_id": "CS_REP_OWNER_ID",
    "hs_timestamp": "{{ new Date().toISOString() }}"
  },
  "associations": [
    {
      "to": { "id": "{{ $('Fetch Deal').item.json.id }}" },
      "types": [{ "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 204 }]
    }
  ]
}
Task association type ID

The association type ID 204 links a task to a deal. Use 1 for contact-to-task associations. These IDs are HubSpot-defined — find the full list in the HubSpot associations API docs.

Replace CS_REP_OWNER_ID with the actual owner ID of your CS rep, or add logic to route based on deal size, industry, or region.

Step 8: Add error handling and activate

  1. Enable Settings -> Retry On Fail on each HTTP Request node (2 retries, 5-second wait)
  2. Create an Error Workflow that sends a Slack DM when the handoff fails
  3. Click Execute Workflow to test (manually move a test deal to Closed Won in HubSpot)
  4. Toggle the workflow to Active

Troubleshooting

Common questions

How do I route to different CS reps based on deal size or region?

Add a Code node between the deal fetch and task creation that maps deal properties to CS rep owner IDs. For example: deals over $50K go to your enterprise CSM, deals under $50K go to your SMB team. Store the mapping as a lookup table in the Code node for easy updates.

What if the deal has no associated contacts?

Some deals may not have contacts linked. Add an IF node before the Fetch Contact step that checks if associations.contacts.results exists and has length > 0. Route contactless deals to a simplified Slack message that omits contact details and flags the gap.

Can I create multiple onboarding tasks instead of one?

Yes. Add parallel HTTP Request nodes after the Slack step — one for each task (kickoff call, intro email, account setup). Each uses the same task creation payload with a different subject and body. Use the same deal association (type ID 204) for all tasks.

Cost

  • n8n Cloud Starter: $24/mo for 2,500 executions. Each closed-won deal triggers 1 execution. Self-hosted n8n is free.
  • Maintenance: update the CS rep owner ID if team assignments change. Consider building an owner routing table in a Code node for automatic assignment.

Next steps

  • CS rep routing — add a Code node that maps deal properties (size, industry, region) to the appropriate CS rep owner ID
  • Onboarding checklist — create multiple tasks (kickoff call, intro email, account setup) instead of a single task
  • Timeline note — add an HTTP Request to post a note on the deal timeline (POST /crm/v3/objects/notes) documenting that the handoff occurred
  • Welcome email — add a step to trigger a HubSpot workflow or send an email to the customer's primary contact

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.