Win/loss debrief: HubSpot → Slack → Notion
Closed deal opens a Slack thread; the team's debrief becomes a searchable Notion case.
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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
- 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.
- 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 namescurl -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" - 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/dealscurl -s "https://api.hubapi.com/crm/v3/pipelines/deals" \ -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN" - 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 } ] } } - 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
- 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.
- 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.
- 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 threadcurl -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?"}} ] }' - 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 threadcurl -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
- 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.
- 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.
- 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 casecurl -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." } }] } } }'