Stale deal rescue: HubSpot → Slack → Todoist
Deals gone quiet get flagged, owners pinged in Slack, and a follow-up task lands in Todoist.
Deals go quiet and nobody notices until the pipeline review. HubSpot can re-check a date property on a schedule, but only with an added Data Hub Professional/Enterprise subscription — without it, a workflow only re-evaluates when a property changes. This runs that same daily recheck for free: it searches HubSpot for deals inactive past a stall threshold, DMs each owner in Slack with the deal, amount and last-activity date, and files a follow-up task in Todoist. It watches time sitting in a stage, not a sequence running dry.
How it flows
- 01
Scheduled check runs
Once a day, independent of anything happening inside HubSpot itself — this reproduces the native "Based on a schedule" recheck without the Data Hub Professional/Enterprise subscription that trigger requires.
- 02
Stalled open deals pulled from HubSpot
Search API returns deals with hs_lastactivitydate older than the threshold, excluding anything already closed.
- 03
Each deal's owner resolved
hubspot_owner_id on the deal maps to an owner record, and the owner record's email is the join key into Slack and Todoist.
- 04
Owner pinged by Slack DM
Deal name, amount and last-activity date land in a direct message — no channel post, no audience beyond the one person who owns the deal.
- 05
Follow-up task filed in Todoist
The same owner gets a task in their project with the deal's context and a link back to HubSpot, assigned directly to them.
- 06
Repeat nudges are skipped for a day
A deal is excluded from the next run if stale_rescue_last_nudged_at was stamped in the last 24 hours (see the HubSpot dedupe step above), so the same stall does not generate a new DM and task every single day it stays open.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
HubSpot
Source of truth for which deals have gone stale
- 01
Create a private app token
Settings (gear) → Integrations → Private Apps → Create private app (some accounts still route this through Development → Legacy apps → Create legacy app → Private — same feature, mid-rename). Scopes: crm.objects.deals.read (read deal properties), crm.objects.deals.write (stamp a last-nudged timestamp back onto the deal so it isn't re-nudged for 24 hours — see the dedupe step below), crm.objects.owners.read (resolve owner id to email). The token sits on the Auth tab behind "Show token" once the app is created.
- 02
Pick hs_lastactivitydate as the stall clock
hs_lastactivitydate is the last time a note, call, logged email, meeting or message was recorded on the deal — that's what "stalled" should mean. Don't use hs_lastmodifieddate: it bumps on any property edit, including a stage change or an amount correction that has nothing to do with the deal actually moving forward.
- 03
Search for open deals past the threshold
HubSpot workflows already have a "Send notification to existing owners" action that can notify a deal's owner — the gap isn't that HubSpot can't reach people. HubSpot workflows also have a native "Based on a schedule" enrollment trigger that re-evaluates a filter (including a date property like hs_lastactivitydate) on every scheduled run, which is exactly this kind of recheck — but setting it to recur daily, weekly or monthly needs an added Data Hub Professional or Enterprise subscription; without that, a workflow only re-evaluates a deal the moment one of its properties changes, and time passing on its own never re-triggers it. This step reproduces the schedule-based recheck for free: pick a stall threshold in days (e.g. 7), and on every run of the scheduled job compute `value` as the current time minus that many days, in Unix milliseconds — the number below is a fixed example for illustration, not a value to hard-code, since a stale timestamp would stop matching new deals within days.
POST crm/v3/objects/deals/searchcurl -X POST "https://api.hubapi.com/crm/v3/objects/deals/search" \ -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "filterGroups": [{ "filters": [ { "propertyName": "hs_lastactivitydate", "operator": "LT", "value": "1723161600000" }, { "propertyName": "dealstage", "operator": "NOT_IN", "values": ["closedwon", "closedlost"] } ] }], "properties": ["dealname", "amount", "hs_lastactivitydate", "hubspot_owner_id", "dealstage"], "limit": 100 }' - 04
Resolve each deal to its owner's email
hubspot_owner_id on the deal is the join key. Owners API's id field is the one that matches it — not userId, which looks similar but errors if used for assignment elsewhere. Cache the owner list rather than calling this per deal if the batch is large.
GET crm/v3/owners/{ownerId}curl -s "https://api.hubapi.com/crm/v3/owners/51409223" \ -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN" - 05
Stamp a last-nudged timestamp to dedupe
Add a custom deal property (e.g. stale_rescue_last_nudged_at, date/time type) under Settings → Properties → Deal properties. After sending the Slack DM and filing the Todoist task for a deal, PATCH that property to the current timestamp using the crm.objects.deals.write scope above. Then split the Search API call in the step above into two filterGroups, OR'd together — HubSpot's Search API ORs across filterGroups and ANDs within one — so a deal matches if it has never been nudged (stale_rescue_last_nudged_at has no value) OR was last nudged more than 24 hours ago, in addition to the existing hs_lastactivitydate and dealstage filters in both groups.
Slack
Direct nudge to the deal's owner
- 01
Create the app and add bot scopes
api.slack.com/apps → Create New App → From scratch → OAuth & Permissions → Bot Token Scopes: chat:write (send the nudge), users:read.email (map owner email to a Slack user id), im:write (open the one-to-one DM channel). Install/reinstall to the workspace. Because this is a DM, the bot never needs to be invited into any channel.
- 02
Resolve the owner's email to a DM channel
Look up the Slack user id by the same email HubSpot has on file for the owner, then open a one-to-one channel for it. This assumes the owner's HubSpot email and Slack email match — the same assumption HubSpot's own owner-notification action relies on, so it's worth a one-time check across the team rather than something this workflow can detect at runtime.
users.lookupByEmail + conversations.opencurl -s -G "https://slack.com/api/users.lookupByEmail" \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -d email="jane@example.com" curl -s -X POST "https://slack.com/api/conversations.open" \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -H "Content-Type: application/json" \ -d '{"users": "U012ABCDEF"}' - 03
Post the nudge
chat.postMessage into the DM channel id from the previous step, with the deal name, amount and last-activity date in the text so the owner can act without opening HubSpot first. If a single run is nudging a long list of owners, space the sends to roughly one per second — comfortably under the per-channel rate limit without needing to think about it further at this volume. Slack's method reference now lives at docs.slack.dev; api.slack.com/methods redirects there.
chat.postMessagecurl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -H "Content-Type: application/json; charset=utf-8" \ -d '{"channel":"D012ABCDEF","text":"Northwind · $42,000 · no activity in 12 days — quick nudge?"}'
Todoist
A trackable follow-up task, not just a message that scrolls away
- 01
Get an API token
Todoist Settings → Integrations → Developer → copy the personal API token, or register an OAuth app requesting task:add if this should run under a service account. HubSpot itself used to have a native "Create a task in Todoist" workflow action for exactly this — but as of a July 31, 2026 HubSpot update, that integration is sunset and no longer accepts new installations, so a direct Todoist API call is the only path open to a workflow set up today.
- 02
Create a dedicated project and note its ID
Make a project (e.g. "Stale Deal Follow-ups") in Todoist and copy its project_id from the project's URL (todoist.com/app/project/<project_id>) — every owner's follow-up task in the flow below lands in this one project, which is what both the collaborators check in the next step and the project_id field in the task-creation snippet refer to.
- 03
Confirm the owner is a collaborator on the target project
Assigning a task to a specific person (assignee_id) only works if that person is a collaborator on the project the task lands in. Pull the roster once and keep the owner-email-to-user_id mapping — the same collaborators call already used to build owner rosters elsewhere.
GET /api/v1/projects/{project_id}/collaboratorscurl -s "https://api.todoist.com/api/v1/projects/6X6WMMqgq2PWxjCX/collaborators" \ -H "Authorization: Bearer $TODOIST_TOKEN" - 04
Create the follow-up task
POST to /api/v1/tasks (REST v2 and Sync v9 both return 410 Gone now — v1 is the only live path). Put the stall context in description so the owner does not have to reopen HubSpot to remember why the task exists, and assign it to the owner directly.
POST /api/v1/taskscurl -X POST "https://api.todoist.com/api/v1/tasks" \ -H "Authorization: Bearer $TODOIST_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "content": "Follow up: Northwind deal stalled 12 days", "description": "Deal: Northwind · $42,000 · stage: Proposal Sent · last activity 2026-07-28.\nHubSpot: https://app.hubspot.com/contacts/DEAL_ID", "project_id": "6X6WMMqgq2PWxjCX", "due_string": "today", "priority": 3, "assignee_id": "158111168" }'