Alert Slack when a HubSpot deal is stuck in a stage for over 14 days using n8n
Install this workflow
Download the n8n workflow JSON and import it into your n8n instance.
stale-deal-alert.n8n.jsonPrerequisites
- 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
Why n8n?
n8n gives you a visual workflow editor with a Code node for custom logic — ideal for stale deal detection, which requires date calculations, stage lookups, and grouping deals by owner. Self-hosted n8n is completely free with unlimited executions, and the Schedule Trigger node runs your workflow at a set time each day without any external scheduler.
The Code node is what sets n8n apart for this use case. You can calculate days stale, group deals by owner, and format the output — all in a few lines of JavaScript within the visual editor. No separate script, no deployment pipeline. The trade-off is that you need basic JavaScript comfort for the Code node.
How it works
- Schedule Trigger fires at a set time each day (e.g., 8 AM before standups)
- HTTP Request node fetches pipeline stage labels from the HubSpot Pipelines API
- HTTP Request node searches for deals where
hs_lastmodifieddateis older than 14 days, excluding closed stages - Code node calculates days stale, resolves stage names, and groups deals by owner
- Slack node posts a formatted alert per owner listing their stale deals
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
}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
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')).
Troubleshooting
Common questions
Does each stale deal count as a separate n8n execution?
No. The entire workflow — trigger, API calls, code processing, Slack messages — counts as 1 execution. Even if you have 50 stale deals grouped into 10 owner messages, it's still 1 execution.
Can I DM individual reps instead of posting to a shared channel?
Yes. You need a mapping of HubSpot owner IDs to Slack user IDs. Store this in a Google Sheet or as static workflow data. Then change the Slack node to DM the owner using their Slack user ID instead of posting to a channel.
What if I have more than 100 stale deals?
The HubSpot search API caps at 100 results per page. Add a loop in the Code node using the after cursor from the response's paging object to fetch subsequent pages. For most teams, 100 is enough — if you have more than 100 stale deals, you may want to tighten the staleness threshold.
Cost
- n8n Cloud: 1 execution/day = ~30/month. Well within the Starter plan's 2,500.
- Self-hosted: Free.
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.