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.readandcrm.schemas.deals.readscopes - Slack app with Bot Token (
chat:writescope) - 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
- Test manually by clicking Execute Workflow
- Verify Slack messages contain the right stale deals
- 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.