Auto-archive stale HubSpot deals using n8n
Prerequisites
- n8n instance (cloud or self-hosted)
- HubSpot private app token with
crm.objects.deals.read,crm.objects.deals.write, andcrm.schemas.deals.readscopes - Slack app with Bot Token (
chat:writescope) - n8n credentials for HubSpot and Slack
- A "Closed Lost — Stale" close reason configured in HubSpot (optional but recommended)
Overview
This workflow runs daily, finds deals with no activity for 60+ days, warns the deal owner in Slack, then waits 48 hours. If the deal still hasn't been updated after the grace period, it moves the deal to Closed Lost automatically.
n8n's Wait node makes this a natural fit — the workflow literally pauses for 48 hours mid-execution without consuming resources.
Step 1: Schedule a daily trigger
Add a Schedule Trigger node:
- Trigger interval: Days
- Trigger at hour: 7 (run before the team starts)
- Timezone: Set to your team's timezone
Step 2: Search for stale deals
Add an HTTP Request node to find deals with no modification in the last 60 days:
- Method: POST
- URL:
https://api.hubapi.com/crm/v3/objects/deals/search - Authentication: HubSpot API credential
- Body:
{
"filterGroups": [
{
"filters": [
{
"propertyName": "hs_lastmodifieddate",
"operator": "LT",
"value": "{{ $now.minus({days: 60}).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
}HubSpot's LT operator for dates expects a Unix timestamp in milliseconds. n8n's $now.minus({days: 60}).toMillis() handles this correctly.
Step 3: Check if any deals were found
Add an IF node after the search:
- Condition:
{{ $json.total }}greater than0
Connect the true branch to the next step. The false branch ends the workflow — nothing to do today.
Step 4: Split into individual deals
Add a Split Out node to iterate over each stale deal:
- Field to split out:
results
This produces one item per deal, so each deal flows through the remaining nodes individually.
Step 5: Resolve the deal owner
Add an HTTP Request node to look up the owner's name and email:
- Method: GET
- URL:
https://api.hubapi.com/crm/v3/owners/{{ $json.properties.hubspot_owner_id }} - Authentication: HubSpot API credential
This returns firstName, lastName, and email for the deal owner.
If hubspot_owner_id is empty, this request will fail. Add an IF node before the owner lookup to check for an owner, and route unassigned deals to a separate Slack channel or skip them.
Step 6: Warn the deal owner in Slack
Add a Slack node to notify the owner:
- Resource: Message
- Operation: Send a Message
- Channel:
#sales-pipeline(or DM the owner if you have a HubSpot-to-Slack user mapping) - Message Type: Block Kit
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "⚠️ *Stale Deal Warning*\n\n*{{ $('Split Out').item.json.properties.dealname }}* has had no activity for {{ Math.round((Date.now() - new Date($('Split Out').item.json.properties.hs_lastmodifieddate)) / 86400000) }} days.\n\nThis deal will be moved to *Closed Lost* in 48 hours unless you update it.\n\n<https://app.hubspot.com/contacts/YOUR_PORTAL_ID/deal/{{ $('Split Out').item.json.id }}|View deal in HubSpot>"
}
}
]
}Replace YOUR_PORTAL_ID with your actual HubSpot portal ID.
Step 7: Wait 48 hours
Add a Wait node:
- Resume: After time interval
- Wait amount: 48
- Wait unit: Hours
The workflow pauses here. No executions are consumed while waiting.
n8n persists the workflow state and resumes after 48 hours. On n8n cloud, this is fully managed. Self-hosted instances need a running n8n process — if n8n restarts, queued waits resume automatically from the database.
Step 8: Re-check if the deal was updated
After the wait, the deal may have been updated by the rep. Add an HTTP Request node to fetch the deal's current state:
- Method: GET
- URL:
https://api.hubapi.com/crm/v3/objects/deals/{{ $('Split Out').item.json.id }} - Query params:
properties=hs_lastmodifieddate,dealstage
Then add an IF node to check:
- Condition:
{{ new Date($json.properties.hs_lastmodifieddate) > $now.minus({hours: 48}).toJSDate() }}
If true (deal was updated during the grace period), the workflow ends for this deal — the rep saved it.
If false (still stale), continue to close it.
Step 9: Close the deal
Add an HTTP Request node on the false branch:
- Method: PATCH
- URL:
https://api.hubapi.com/crm/v3/objects/deals/{{ $('Split Out').item.json.id }} - Body:
{
"properties": {
"dealstage": "closedlost",
"closed_lost_reason": "Stale — auto-archived after 60 days"
}
}The closed_lost_reason property name depends on your HubSpot configuration. Check your deal properties in HubSpot Settings to find the internal name. Some portals use hs_closed_lost_reason or a custom property.
Step 10: Send a confirmation to Slack
Add a final Slack node:
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "🗄️ *Deal Auto-Archived*\n*{{ $('Split Out').item.json.properties.dealname }}* was moved to Closed Lost after 60+ days of inactivity. No objection was received during the 48-hour grace period."
}
}
]
}Step 11: Add error handling and activate
- Enable Retry On Fail on all HTTP Request nodes (2 retries, 5 second wait)
- Create an Error Workflow that notifies you in Slack if the main workflow fails
- Test manually by clicking Execute Workflow (use a test deal to verify the full flow)
- Toggle the workflow to Active
Cost
- n8n Cloud: Each daily run uses 1 execution for the trigger, plus additional node executions per stale deal. The Wait node does not consume credits while paused. A typical run with 5 stale deals uses ~50 node executions. Well within the Starter plan (2,500 executions/month).
- 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.