Trial follow-up escalation: HubSpot → Slack → Todoist
Sequence ends with zero opens: DM the owner in Slack, force a Todoist task, not a message.
A high-value trial finishes its HubSpot sequence with zero opens or replies - Dynamic Sequences never reacts - it only fires on engaged contacts. This reads standard CRM contact properties (Sales Hub Starter, not the pricier Sequences API) for a just-ended sequence on a target-tier trial with no engagement, DMs the owner in Slack, then forces a due-today Todoist task - the real differentiator, since Pro/Enterprise Workflows can already send that same DM natively. Unlike a stalled-deal check, this watches sequence completion, not deal age.
How it flows
- 01
Scheduled search runs against HubSpot
On a timer of 15 minutes or more — the Search API is capped at 5 requests/second per account regardless of the account's general rate limit, tighter than it looks, and 15 minutes is still fast enough that a sequence ending does not sit unnoticed for long.
- 02
Contacts search returns qualifying, tagged trials
One CRM contacts search with three filters — is_trial_contact true, hs_last_sequence_ended_date recent, plan_tier at a qualifying tier — pulls back candidates along with their engagement properties.
- 03
Zero-engagement is confirmed in code, not in the query
hs_sales_email_last_opened being empty only means "never opened any sales email," not "ignored this sequence" — some contacts have simply never received one before. Compare it (and hs_sales_email_last_replied) against hs_last_sequence_enrolled_date: empty, or timestamped before enrollment, is what actually means zero engagement this run.
- 04
Each contact's owner resolved to an email
hubspot_owner_id on the contact is the join key into the Owners API, which returns the email used to find that owner in Slack.
- 05
Owner pinged by Slack DM
Contact name, company, plan tier and a link back to the HubSpot record, sent as a direct message — the same condition-check-then-DM a Pro/Enterprise HubSpot Workflow could also fire.
- 06
Forced follow-up task filed in Todoist
Due today, high priority, same context in the task content — this is the step a native HubSpot alert or a Slack DM cannot replace on its own.
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 who finished a sequence with zero engagement
- 01
Create a private app with contact-read scope
HubSpot → Development → Legacy apps → Create legacy app → Private (some accounts still show this under Settings → Integrations → Private Apps — same feature, different label depending on the account). Scopes: crm.objects.contacts.read (required), crm.objects.contacts.write (optional — only needed if you add a step of your own to stamp a de-dupe timestamp back onto the contact; the flow below does not include that step). The token sits on the Auth tab behind "Show token".
- 02
Create the plan_tier and is_trial_contact properties
This intentionally skips hubspotscore: HubSpot froze its legacy lead-scoring tool on 2025-08-31 (existing scores stopped updating and stay pinned at whatever value they had that day), so a numeric-score filter is a dead signal for any contact who entered your pipeline afterward. Settings → Properties → Create property, on the Contact object: plan_tier (dropdown, values matching your own plan names, e.g. starter / growth / enterprise — HubSpot has no built-in "customer value" field, this is entirely this workflow's own choice, kept in sync with whatever your billing or CRM source of truth already tracks) and is_trial_contact (checkbox, set true when a contact starts a trial). Both are custom properties on the base Contact object, not a Sales Hub feature, so creating and filtering on them does not require any paid tier.
- 03
Confirm the sequence is on a paying tier
Sequences lives under its own nav item and requires Sales Hub Starter or above. If hs_sequences_is_enrolled and hs_last_sequence_ended_date read empty for every contact, check the account tier before debugging the API call — a free HubSpot account never populates these properties at all, no matter how the request is built.
- 04
Search for contacts to escalate
One CRM contacts search covers all three conditions — is_trial_contact true, hs_last_sequence_ended_date recent, plan_tier at a qualifying tier. Pull hs_sales_email_last_opened, hs_sales_email_last_replied and hs_last_sequence_enrolled_date in the same response; the engagement check on those three properties happens in code after the fetch, not as a fourth filter — HubSpot's Search API only compares a property to a literal value, so "opened before this enrollment" has no filter syntax regardless of how many filters a group allows (see the flow steps below for why "empty" alone is not enough).
POST crm/v3/objects/contacts/searchcurl -X POST "https://api.hubapi.com/crm/v3/objects/contacts/search" \ -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "filterGroups": [{ "filters": [ { "propertyName": "is_trial_contact", "operator": "EQ", "value": "true" }, { "propertyName": "hs_last_sequence_ended_date", "operator": "GTE", "value": "1723075200000" }, { "propertyName": "plan_tier", "operator": "IN", "values": ["growth", "enterprise"] } ] }], "properties": [ "email", "firstname", "lastname", "company", "hubspot_owner_id", "plan_tier", "hs_last_sequence_ended_date", "hs_last_sequence_enrolled_date", "hs_sales_email_last_opened", "hs_sales_email_last_replied" ] }' - 05
Know what HubSpot itself already does here
On Sales Hub Professional/Enterprise, HubSpot Workflows can already branch on these same contact properties and send a native Slack notification to the record owner — the condition-check-and-alert half of this workflow is not exclusive to it. What Workflows cannot do today is hand off to Todoist: HubSpot used to ship a native "Create a task in Todoist" workflow action, but as of a 2026-07-31 update that integration is sunset and no longer accepts new installations, so a workflow set up today has no native bridge to Todoist (only third-party Zapier/Make integrations remain available), and the native path tops out at another internal HubSpot task sitting in the same CRM queue the owner already skims past. The actual value of this workflow is the last two flow steps below — a due-today Todoist task the owner cannot read-and-ignore the way they can a CRM task or a Slack DM.
Slack
Direct nudge to the sequence 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 (post the DM), users:read.email (resolve the HubSpot owner's email to a Slack user id). Install to the workspace to get the bot token (xoxb-..., Bearer auth).
- 02
DM without inviting the bot anywhere
Slack lets chat.postMessage address a user id directly in the channel field — that opens a one-to-one DM with no channel to create and no invite step. This only works because the target is a Slack user id, not a channel name.
- 03
Look up the user, post, then check the body — not just the status code
Slack's Web API wraps failures in an HTTP 200: a bad token or missing scope still comes back {"ok":false,"error":"..."} at status 200, so a status-code-only check treats that failure as success. Read ok from the response body on every call.
users.lookupByEmail + chat.postMessagecurl -s -G "https://slack.com/api/users.lookupByEmail" \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ --data-urlencode "email=owner@company.com" curl -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -H "Content-Type: application/json; charset=utf-8" \ -d '{"channel":"U0REP123","text":"Northwind (growth plan) finished the trial sequence with zero opens — worth a personal call."}'
Todoist
The forced action a Slack message or CRM task can be skimmed past
- 01
Grab a personal API token
Todoist web app → avatar (top right) → Integrations → Developer → copy the API token (the page may still label it "API token"). Bearer auth, same token, no OAuth flow to run.
- 02
Create a dedicated project
Make a "Trial Escalation" project and copy its project_id from the URL, so these tasks land somewhere separate from anyone's personal to-do list. This is built as one rep's own forced-action list, not a team dispatcher — Todoist Free does not support assigning a task to a teammate (Todoist's own pricing page lists "Assigned tasks" only under Pro/Business, not Free/Beginner; the field is assignee_id on POST /api/v1/tasks, not the Sync API's older responsible_uid). If several reps share the project, put the owner's name in the task content instead of relying on assignment.
- 03
File the forced task
POST to /api/v1/tasks, not the old /rest/v2/tasks path — that one now returns 410 Gone. due_string takes natural language ("today", "in 4 hours"); priority runs 1 (normal) to 4 (urgent).
POST api.todoist.com/api/v1/taskscurl -X POST "https://api.todoist.com/api/v1/tasks" \ -H "Authorization: Bearer $TODOIST_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "content": "Call Northwind (Lee) — trial sequence ended, zero opens, growth plan", "project_id": "2203306141", "due_string": "today", "priority": 4 }'