Alert Slack when a HubSpot deal is stuck in a stage for over 14 days using n8n

medium complexityCost: $0-24/moRecommended

Prerequisites

Prerequisites
  • n8n instance (cloud or self-hosted)
  • HubSpot private app token with crm.objects.deals.read and crm.schemas.deals.read scopes
  • Slack app with Bot Token (chat:write scope)
  • n8n credentials for HubSpot and Slack

Step 1: Schedule a daily check

Add a Schedule Trigger node:

  • Trigger interval: Days
  • Trigger at hour: 8 (run each morning before standups)

Step 2: Fetch pipeline stages

Add an HTTP Request node to get stage labels:

  • Method: GET
  • URL: https://api.hubapi.com/crm/v3/pipelines/deals
  • Authentication: HubSpot API credential

Step 3: Search for stale deals

Add another HTTP Request node to find deals not modified in the last 14 days:

  • Method: POST
  • URL: https://api.hubapi.com/crm/v3/objects/deals/search
  • Body:
{
  "filterGroups": [
    {
      "filters": [
        {
          "propertyName": "hs_lastmodifieddate",
          "operator": "LT",
          "value": "{{ $now.minus({days: 14}).toMillis() }}"
        },
        {
          "propertyName": "dealstage",
          "operator": "NOT_IN",
          "values": ["closedwon", "closedlost"]
        }
      ]
    }
  ],
  "properties": ["dealname", "amount", "dealstage", "hubspot_owner_id", "hs_lastmodifieddate"],
  "sorts": [{"propertyName": "hs_lastmodifieddate", "direction": "ASCENDING"}],
  "limit": 100
}
Excluding closed deals

The NOT_IN filter for closedwon and closedlost ensures you only flag active deals. Closed deals are expected to be inactive.

Step 4: Process and group by owner

Add a Code node to calculate days stale and group by owner:

const deals = $('Search Stale Deals').first().json.results || [];
const pipelines = $('Fetch Stages').first().json.results;
 
const stageMap = {};
for (const p of pipelines) {
  for (const s of p.stages) stageMap[s.id] = s.label;
}
 
const byOwner = {};
for (const deal of deals) {
  const props = deal.properties;
  const ownerId = props.hubspot_owner_id || 'unassigned';
  const daysStale = Math.round((Date.now() - new Date(props.hs_lastmodifieddate)) / 86400000);
 
  if (!byOwner[ownerId]) byOwner[ownerId] = [];
  byOwner[ownerId].push({
    name: props.dealname,
    amount: parseFloat(props.amount || '0'),
    stage: stageMap[props.dealstage] || props.dealstage,
    daysStale,
    dealId: deal.id,
  });
}
 
return Object.entries(byOwner).map(([ownerId, deals]) => ({
  json: { ownerId, deals, dealCount: deals.length }
}));

Step 5: Send Slack alerts per owner

Add a Slack node (it will execute once per owner from the Code node output):

  • Channel: You can DM the owner directly if you have a Slack user ID mapping, or post to a shared channel
  • Message Type: Block Kit
{
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "⚠️ *Stale Deals Alert*\nYou have *{{ $json.dealCount }}* deals with no activity for 14+ days:\n{{ $json.deals.map(d => `• *${d.name}* — ${d.stage} — ${d.daysStale}d stale — $${d.amount.toLocaleString()}`).join('\\n') }}"
      }
    }
  ]
}

Step 6: Activate

  1. Test manually by clicking Execute Workflow
  2. Verify Slack messages contain the right stale deals
  3. Toggle to Active for daily checks
Owner to Slack user mapping

To DM individual reps, you need a mapping of HubSpot owner IDs to Slack user IDs. Store this in a Google Sheet or as static data in the workflow ($getWorkflowStaticData('global')).

Cost

  • n8n Cloud: 1 execution/day = ~30/month. Well within the Starter plan's 2,500.
  • Self-hosted: Free.

Need help implementing this?

We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.