WorkflowsMCP

Trial follow-up escalation: HubSpot → Slack → Todoist

Sequence ends with zero opens: DM the owner in Slack, force a Todoist task, not a message.

@workflowsmcpVerified saleslead-routingcrmescalation

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

  1. 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".

  2. 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.

  3. 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.

  4. 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/search
    curl -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"
        ]
      }'
  5. 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

  1. 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).

  2. 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.

  3. 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.postMessage
    curl -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

  1. 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.

  2. 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.

  3. 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/tasks
    curl -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
      }'