WorkflowsMCP

Win/loss debrief: HubSpot → Slack → Notion

Closed deal opens a Slack thread; the team's debrief becomes a searchable Notion case.

@workflowsmcpVerified salescrmautomationnotifications

HubSpot's own Slack notification for a closed deal is one line to a channel, person, or record owner — no debrief, no follow-up. This workflow opens a Slack thread instead, posts the deal's numbers and recorded Closed Won/Lost reason, and asks the team guiding questions in-thread. After a configurable window (default 72h) it pulls the discussion, summarizes what the team concluded, and files it as a case — outcome, reason, amount, owner, summary — in a searchable Notion win/loss library, linked back into the thread.

How it flows

  1. 01

    Deal closes in HubSpot

    Someone drags the deal to Closed Won or Closed Lost, or an integration does it via API — the dealstage propertyChange webhook fires.

  2. 02

    Receiver confirms the stage and pulls the deal

    propertyValue is checked against your account's real Closed Won/Lost stage IDs, not the closedwon/closedlost defaults, then the deal's amount, recorded reason, and owner email come back from the Deals and Owners APIs.

  3. 03

    A debrief thread opens in Slack

    Outcome, amount, the recorded reason, a link back to the HubSpot record, and two or three guiding questions — the question set branches on Won vs Lost.

  4. 04

    The team discusses in-thread

    Asynchronous and unmoderated by the workflow — this is the actual debrief, and nothing here shortens it or scores it.

  5. 05

    After the window closes, the thread gets summarized

    On the configured schedule (72h by default) the job reads every reply and produces a short summary of what the team actually concluded — the reasoning behind the outcome, not just the reason code.

  6. 06

    The case lands in Notion, and the thread gets the link back

    Outcome, reason, amount, owner, the Slack thread URL, and the summary become one page in the win/loss database; the new page's link is posted back into the Slack thread so the discussion and the record point at each other.

Set up each app

Work through these in order — later apps usually need a token or an id from an earlier one.

HubSpot

Event source — deal closes, reason and owner come from here

  1. 01

    Create a private app with the minimum scopes

    Settings (gear, top right) → Integrations → Private Apps → Create a private app → Scopes tab. Add crm.objects.deals.read to read deal fields and crm.objects.owners.read to resolve the owner's email; add crm.objects.deals.write only if you also want the workflow to stamp a debrief_completed property back onto the deal. Create app → Auth tab → Show token, and treat it as a secret.

  2. 02

    Confirm the Closed Reason property names in your account

    closed_lost_reason is a real internal name confirmed from a live HubSpot API call; closed_won_reason is a naming-symmetry guess that has not been confirmed the same way — some accounts rename or delete these default properties entirely. Before wiring either into the receiver, fetch a real deal with both properties named and check what comes back non-null.

    GET deals/{dealId} — confirm property names
    curl -s "https://api.hubapi.com/crm/v3/objects/deals/{dealId}?properties=dealname,amount,dealstage,closed_lost_reason,closed_won_reason,hubspot_owner_id,closedate" \
      -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN"
  3. 03

    Look up your pipeline's real Closed Won/Lost stage IDs

    closedwon and closedlost are only HubSpot's documentation examples — every pipeline can rename its stage IDs, and an account with more than one pipeline can have a different pair for each. Call this once per pipeline and build your own stageId → Closed Won/Lost lookup from what your account actually returns; do not compare a webhook's propertyValue against hardcoded strings. Each stage in the response carries metadata.probability — "1.0" for Closed Won, "0.0" for Closed Lost — and metadata.isClosed marking any terminal stage; build the lookup off those fields rather than matching on the stage's display label, which can be renamed or localized.

    GET pipelines/deals
    curl -s "https://api.hubapi.com/crm/v3/pipelines/deals" \
      -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN"
  4. 04

    Subscribe the webhook to dealstage changes

    Same private app → Webhooks tab (a project-built private app configures the identical subscription in webhooks.json instead of this tab) → add a subscription on the deal object for propertyChange on dealstage → paste your endpoint URL → Test to fire one real event. The subscription fires on every dealstage change, not only Closed Won/Lost — your endpoint does that filtering using the stage-ID lookup from the previous step. If the Webhooks tab does not appear in your app's settings, that is a plan gate on this account; there is no single official page stating which tier unlocks it, so go by what your own account shows rather than a specific number.

    webhooks.json subscription
    {
      "settings": {
        "targetUrl": "https://YOUR_ENDPOINT/hubspot/deal-closed",
        "maxConcurrentRequests": 5
      },
      "subscriptions": {
        "crmObjects": [
          {
            "subscriptionType": "object.propertyChange",
            "objectName": "deal",
            "propertyName": "dealstage",
            "active": true
          }
        ]
      }
    }
  5. 05

    Optional: make Closed Lost/Won Reason a dropdown

    Both properties ship as free text. Settings → Properties → search "Closed lost reason" / "Closed won reason" → Edit → change the field type to Dropdown select and add your own option list, so debriefs land on a consistent set of reasons instead of one-off phrasing.

Slack

Opens the debrief thread and holds the discussion

  1. 01

    Create the Slack app and bot scopes

    api.slack.com/apps → Create New App → From scratch → pick your workspace → OAuth & Permissions → Bot Token Scopes. Add chat:write to open the thread and post the Notion link back, channels:history (or groups:history for a private channel) to read the discussion back afterward, and users:read.email if you want the deal owner @-mentioned instead of named in plain text. Install to Workspace, then copy the Bot User OAuth Token.

  2. 02

    Invite the bot and record the channel ID

    Invite the bot into your win/loss channel with /invite @your-bot-name — chat.postMessage returns not_in_channel otherwise. Copy the channel ID (starts with C) from the channel details panel; the receiver is configured with that ID, not the channel name.

  3. 03

    Post the debrief thread

    One call opens the thread: pass channel and a blocks message with the deal's numbers, the recorded reason, a link back to the HubSpot record, and two or three guiding questions matched to Won vs Lost. Save the response's ts field — it is the thread root that every reply, and eventually the Notion link, hangs off of.

    chat.postMessage — open the thread
    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": "C0WINLOSS",
        "text": "Closed Lost · Acme Co · $42,000 — let'\''s debrief",
        "blocks": [
          {"type":"section","text":{"type":"mrkdwn","text":"*Acme Co* closed *Lost* — $42,000\nRecorded reason: _Went with competitor_\n<https://app.hubspot.com/contacts/PORTAL/deal/DEALID|Open in HubSpot>"}},
          {"type":"section","text":{"type":"mrkdwn","text":"Debrief in this thread:\n1. What signal did we miss earliest?\n2. Where did the deal actually stall?\n3. What would we do differently next time?"}}
        ]
      }'
  4. 04

    Decide when to pull the discussion

    Slack has no "thread closed" event, so there is no native signal that the team is done talking. Default to a scheduled job on a configurable window — 72h after the thread opens is a reasonable starting point — rather than trying to detect when replies have stopped. On conversations.replies itself: an internal app built for your own workspace gets Tier 3 (roughly 50 requests/minute, up to 200 messages per call), which comfortably covers one debrief thread; distribute the app outside the Slack Marketplace instead of listing it there, and this same method drops to 1 request/minute and 15 messages per call, at which point pulling a long thread needs pagination.

    conversations.replies — pull the thread
    curl -s -G https://slack.com/api/conversations.replies \
      -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
      --data-urlencode "channel=C0WINLOSS" \
      --data-urlencode "ts=1706300000.123456"

Notion

Searchable win/loss case library

  1. 01

    Create an internal integration

    notion.so/my-integrations → New integration → name it, pick your workspace, Capabilities → check Insert content (required to create case pages); add Read content too if you want to check for an existing case before creating a duplicate. Submit, then copy the Internal Integration Secret.

  2. 02

    Build the case database

    Create a database with exactly these properties — names matter, the API writes to them by name: Deal name (Title), Outcome (Select: Won/Lost), Reason (Text), Amount (Number), Owner (Text), Slack thread (URL), Closed date (Date), Summary (Text). Owner is plain text on purpose, not Notion's People property: HubSpot hands you an email and Slack hands you a user ID, and neither lines up with a Notion workspace member without an extra lookup call — storing "name (email)" as text avoids a three-way identity match for a field that is display-only.

  3. 03

    Share the database with the integration

    Open the database → ••• menu (top right) → Connections → add the integration you just created. Skipping this step reads as a 404 on a database you can plainly see. Copy the database ID from the page URL, then call `GET /v1/databases/{database_id}` once and keep the returned `data_sources[0].id` — that data source id, not the database id, is what the receiver config and the page-creation call need. Notion-Version 2026-03-11 requires a data source id, not a raw database id, as the page parent — retrieve the database once, read its `data_sources[0].id`, and use that as `data_source_id` in the receiver config instead of the database id from the URL.

    POST v1/pages — file the case
    curl -X POST https://api.notion.com/v1/pages \
      -H "Authorization: Bearer $NOTION_TOKEN" \
      -H "Notion-Version: 2026-03-11" \
      -H "Content-Type: application/json" \
      -d '{
        "parent": { "type": "data_source_id", "data_source_id": "YOUR_DATA_SOURCE_ID" },
        "properties": {
          "Deal name": { "title": [{ "text": { "content": "Acme Co" } }] },
          "Outcome": { "select": { "name": "Lost" } },
          "Reason": { "rich_text": [{ "text": { "content": "Went with competitor" } }] },
          "Amount": { "number": 42000 },
          "Owner": { "rich_text": [{ "text": { "content": "Jordan Lee (jordan@company.com)" } }] },
          "Slack thread": { "url": "https://company.slack.com/archives/C0WINLOSS/p1706300000123456" },
          "Closed date": { "date": { "start": "2026-08-08" } },
          "Summary": { "rich_text": [{ "text": { "content": "Team agreed pricing came in late relative to the competitor's initial quote." } }] }
        }
      }'