Stale Deal Nudger
Module 15 · business · intermediate
learner.json
In n8n, copy this file and paste it onto the canvas, or use Import from File.
{
"name": "15 - Stale Deal Nudger",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 8 * * 1-5"
}
]
}
},
"id": "m15-schedule",
"name": "Weekdays 8 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
0
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "deals/stale-check",
"responseMode": "responseNode",
"options": {}
},
"id": "m15-webhook",
"name": "Stale Check API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"webhookId": "course-15-stale-check"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "s1",
"name": "stale_days",
"value": "={{ { appointmentscheduled: 3, qualifiedtobuy: 7, presentationscheduled: 5, decisionmakerboughtin: 4, contractsent: 3, default: 7 } }}",
"type": "object"
},
{
"id": "s2",
"name": "closed_stages",
"value": "={{ ['closedwon', 'closedlost'] }}",
"type": "array"
},
{
"id": "s3",
"name": "owners",
"value": "={{ { '12345678': { name: 'Alex Rivera' }, '23456789': { name: 'Priya Shah' } } }}",
"type": "object"
},
{
"id": "s4",
"name": "crm_deal_url",
"value": "https://app.hubspot.com/contacts/YOUR_PORTAL_ID/deal/{id}",
"type": "string"
},
{
"id": "s5",
"name": "use_ai",
"value": false,
"type": "boolean"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "m15-settings",
"name": "Settings (edit me)",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
220,
150
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "a1",
"leftValue": "={{ $('Stale Check API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m15-source",
"name": "Deals from API?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
440,
150
]
},
{
"parameters": {
"authentication": "appToken",
"resource": "deal",
"operation": "getAll",
"returnAll": true,
"filters": {
"properties": [
"dealname",
"dealstage",
"amount",
"hubspot_owner_id",
"notes_last_updated",
"hs_lastmodifieddate"
]
}
},
"id": "m15-hubspot",
"name": "Get HubSpot Deals",
"type": "n8n-nodes-base.hubspot",
"typeVersion": 2.1,
"position": [
660,
40
]
},
{
"parameters": {
"jsCode": "// HubSpot deals → the common deal shape used by the rest of the workflow.\n// The HubSpot node may return properties as { value: … } (legacy API) or plain values; handle both.\nconst s = $('Settings (edit me)').first().json;\nconst val = (p, k) => (p?.[k] && typeof p[k] === 'object' ? p[k].value : p?.[k]) ?? null;\n\nreturn $input.all()\n .filter((i) => i.json.dealId || i.json.id)\n .map(({ json: d }) => {\n const p = d.properties ?? {};\n const lastActivity = val(p, 'notes_last_updated') ?? val(p, 'hs_lastmodifieddate');\n const ownerId = String(val(p, 'hubspot_owner_id') ?? '');\n return {\n json: {\n id: String(d.dealId ?? d.id),\n name: val(p, 'dealname') ?? '(unnamed deal)',\n stage: String(val(p, 'dealstage') ?? 'unknown').toLowerCase(),\n amount: Number(val(p, 'amount') ?? 0),\n owner: s.owners[ownerId]?.name ?? (ownerId ? `Owner ${ownerId}` : 'Unassigned'),\n contact: '',\n last_activity: lastActivity ? new Date(Number(lastActivity) || lastActivity).toISOString() : null,\n url: s.crm_deal_url ? s.crm_deal_url.replace('{id}', String(d.dealId ?? d.id)) : '',\n },\n };\n });\n"
},
"id": "m15-map",
"name": "Map HubSpot Deals",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
40
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "v1",
"leftValue": "={{ Array.isArray($json.body?.deals) }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m15-valid",
"name": "Has deals[]?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
660,
260
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { error: 'POST {\"deals\": [{\"id\",\"name\",\"stage\",\"amount\",\"owner\",\"last_activity\"}], \"now\"?: ISO date, \"ai\"?: true}' } }}",
"options": {
"responseCode": 400
}
},
"id": "m15-400",
"name": "Respond Bad Input",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
880,
400
]
},
{
"parameters": {
"jsCode": "// API path: deals posted in the body (any CRM, or a test). Same common shape as the HubSpot mapper.\nconst body = $input.first().json.body ?? {};\nreturn (body.deals ?? []).map((d, i) => ({\n json: {\n id: String(d.id ?? `deal-${i + 1}`),\n name: String(d.name ?? '(unnamed deal)'),\n stage: String(d.stage ?? 'unknown').toLowerCase(),\n amount: Number(d.amount ?? 0),\n owner: String(d.owner ?? 'Unassigned'),\n contact: String(d.contact ?? ''),\n last_activity: d.last_activity ?? null,\n url: String(d.url ?? ''),\n },\n}));\n"
},
"id": "m15-from-req",
"name": "Deals from Request",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
220
],
"alwaysOutputData": false
},
{
"parameters": {
"jsCode": "// Keep open deals with no activity for longer than their stage allows, most urgent first.\n// urgency = overdue days × deal size factor (no activity date counts as one stage-limit overdue).\nconst s = $('Settings (edit me)').first().json;\nconst req = $('Stale Check API').isExecuted ? $('Stale Check API').first().json : null;\nconst now = req?.body?.now ? DateTime.fromISO(req.body.now) : DateTime.now();\n\nconst stale = [];\nfor (const { json: d } of $input.all()) {\n if (s.closed_stages.includes(d.stage)) continue;\n const limit = s.stale_days[d.stage] ?? s.stale_days.default;\n const last = d.last_activity ? DateTime.fromISO(d.last_activity) : null;\n const idle = last && last.isValid ? Math.floor(now.diff(last, 'days').days) : 999;\n if (idle <= limit) continue;\n const overdue = idle - limit;\n const sizeFactor = 1 + Math.log10(Math.max(d.amount, 1000) / 1000);\n stale.push({\n ...d,\n idle_days: idle === 999 ? null : idle,\n limit_days: limit,\n overdue_days: idle === 999 ? null : overdue,\n urgency: Math.round((idle === 999 ? limit : overdue) * sizeFactor * 10) / 10,\n });\n}\nstale.sort((a, b) => b.urgency - a.urgency);\nreturn stale.map((json) => ({ json }));\n"
},
"id": "m15-find",
"name": "Find Stale Deals",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1100,
150
],
"alwaysOutputData": true
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "n1",
"leftValue": "={{ $json.id }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "exists",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m15-any",
"name": "Any Stale?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1320,
150
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "u1",
"leftValue": "={{ $('Stale Check API').isExecuted ? $('Stale Check API').first().json.body.ai === true : $('Settings (edit me)').first().json.use_ai }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m15-useai",
"name": "Use AI?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1540,
60
]
},
{
"parameters": {
"promptType": "define",
"text": "=Write a short follow-up email from a salesperson to revive a deal that went quiet.\n\nDeal: {{ $json.name }}\nStage: {{ $json.stage }}\nValue: {{ $json.amount ? '$' + $json.amount : 'unknown' }}\nDays since last activity: {{ $json.idle_days ?? 'unknown' }}\nContact: {{ $json.contact || 'unknown' }}\nSalesperson: {{ $json.owner }}\n\nRules: max 80 words; friendly and specific to the stage; one clear question or call to action; no guilt-tripping (\"just checking in\", \"I haven't heard back\"); don't invent facts, prices or discounts; sign with the salesperson's first name.",
"hasOutputParser": true
},
"id": "m15-ai",
"name": "Draft Follow-up (AI)",
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"typeVersion": 1.9,
"position": [
1820,
-60
]
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "gpt-5-mini",
"cachedResultName": "gpt-5-mini"
},
"options": {}
},
"id": "m15-model",
"name": "OpenAI Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.3,
"position": [
1740,
140
]
},
{
"parameters": {
"schemaType": "fromJson",
"jsonSchemaExample": "{\n \"subject\": \"Next steps for the Acme website project\",\n \"body\": \"Hi Sam, ...\"\n}"
},
"id": "m15-parser",
"name": "Email Format",
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"typeVersion": 1.3,
"position": [
1900,
140
]
},
{
"parameters": {
"jsCode": "// Pair each AI draft with its deal (same order as Find Stale Deals → Use AI?).\nconst deals = $('Find Stale Deals').all();\nreturn $input.all().map((item, i) => ({\n json: {\n ...deals[i].json,\n draft_subject: item.json.output?.subject ?? `Following up: ${deals[i].json.name}`,\n draft_body: item.json.output?.body ?? '',\n draft_source: 'ai',\n },\n}));\n"
},
"id": "m15-attach",
"name": "Attach AI Drafts",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2200,
-60
]
},
{
"parameters": {
"jsCode": "// No-AI fallback: a short, stage-aware follow-up draft per deal.\nconst OPENERS = {\n 'appointmentscheduled': 'Looking forward to our call. Is the time we booked still good for you?',\n 'qualifiedtobuy': 'I put together a few ideas based on what you shared. Would a 15-minute call this week work to walk through them?',\n 'presentationscheduled': 'Following up on our presentation. What questions came up on your side?',\n 'decisionmakerboughtin': 'Great to have everyone aligned. Shall I send over the next steps and timeline?',\n 'contractsent': 'Just checking you received the contract. Happy to clarify anything before you sign.',\n};\nreturn $input.all().map(({ json: d }) => {\n const first = d.contact ? d.contact.split(' ')[0] : 'there';\n const line = OPENERS[d.stage] ?? 'Wanted to follow up on our last conversation. Is this still a priority for you?';\n return {\n json: {\n ...d,\n draft_subject: `Following up: ${d.name}`,\n draft_body: `Hi ${first},\\n\\n${line}\\n\\nBest,\\n${d.owner.split(' ')[0]}`,\n draft_source: 'template',\n },\n };\n});\n"
},
"id": "m15-template",
"name": "Template Drafts",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1980,
300
]
},
{
"parameters": {
"jsCode": "// One digest per owner: their stale deals, most urgent first, with a ready-to-send message.\nconst money = (n) => (n ? '$' + Math.round(n).toLocaleString('en-US') : 'no amount');\nconst byOwner = {};\nfor (const { json: d } of $input.all()) (byOwner[d.owner] ??= []).push(d);\n\nconst owners = Object.entries(byOwner)\n .map(([owner, deals]) => {\n const total = deals.reduce((sum, d) => sum + d.amount, 0);\n const lines = deals.map((d, i) =>\n `${i + 1}. ${d.name} (${money(d.amount)}, ${d.stage}): ${d.idle_days === null ? 'no activity recorded' : `${d.idle_days} days quiet (limit ${d.limit_days})`}` +\n (d.url ? `\\n ${d.url}` : '') +\n `\\n Draft: \"${d.draft_subject}\"`);\n return {\n owner,\n deal_count: deals.length,\n pipeline_at_risk: total,\n message: `👋 ${owner.split(' ')[0]}, ${deals.length} deal${deals.length > 1 ? 's' : ''} (${money(total)}) went quiet:\\n\\n${lines.join('\\n')}`,\n deals,\n };\n })\n .sort((a, b) => b.pipeline_at_risk - a.pipeline_at_risk);\n\nreturn owners.map((json) => ({ json }));\n"
},
"id": "m15-group",
"name": "Group by Owner",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2420,
60
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r1",
"leftValue": "={{ $('Stale Check API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m15-api",
"name": "Called via API?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2640,
60
]
},
{
"parameters": {
"aggregate": "aggregateAllItemData",
"destinationFieldName": "owners",
"options": {}
},
"id": "m15-agg",
"name": "Collect Owners",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
2860,
-40
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { stale_count: $json.owners.reduce((n, o) => n + o.deal_count, 0), pipeline_at_risk: $json.owners.reduce((n, o) => n + o.pipeline_at_risk, 0), owners: $json.owners } }}",
"options": {}
},
"id": "m15-respond",
"name": "Respond",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
3080,
-40
]
},
{
"parameters": {},
"id": "m15-send",
"name": "Send Nudges (Exercise 1)",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
2860,
160
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "e1",
"leftValue": "={{ $('Stale Check API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m15-api2",
"name": "Called via API? (none stale)",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1540,
420
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { stale_count: 0, pipeline_at_risk: 0, owners: [] } }}",
"options": {}
},
"id": "m15-respond-none",
"name": "Respond Nothing Stale",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1760,
420
]
}
],
"connections": {
"Weekdays 8 AM": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Stale Check API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Settings (edit me)": {
"main": [
[
{
"node": "Deals from API?",
"type": "main",
"index": 0
}
]
]
},
"Deals from API?": {
"main": [
[
{
"node": "Has deals[]?",
"type": "main",
"index": 0
}
],
[
{
"node": "Get HubSpot Deals",
"type": "main",
"index": 0
}
]
]
},
"Get HubSpot Deals": {
"main": [
[
{
"node": "Map HubSpot Deals",
"type": "main",
"index": 0
}
]
]
},
"Map HubSpot Deals": {
"main": [
[
{
"node": "Find Stale Deals",
"type": "main",
"index": 0
}
]
]
},
"Has deals[]?": {
"main": [
[
{
"node": "Deals from Request",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond Bad Input",
"type": "main",
"index": 0
}
]
]
},
"Deals from Request": {
"main": [
[
{
"node": "Find Stale Deals",
"type": "main",
"index": 0
}
]
]
},
"Find Stale Deals": {
"main": [
[
{
"node": "Any Stale?",
"type": "main",
"index": 0
}
]
]
},
"Any Stale?": {
"main": [
[
{
"node": "Use AI?",
"type": "main",
"index": 0
}
],
[
{
"node": "Called via API? (none stale)",
"type": "main",
"index": 0
}
]
]
},
"Use AI?": {
"main": [
[
{
"node": "Draft Follow-up (AI)",
"type": "main",
"index": 0
}
],
[
{
"node": "Template Drafts",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "Draft Follow-up (AI)",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Email Format": {
"ai_outputParser": [
[
{
"node": "Draft Follow-up (AI)",
"type": "ai_outputParser",
"index": 0
}
]
]
},
"Draft Follow-up (AI)": {
"main": [
[
{
"node": "Attach AI Drafts",
"type": "main",
"index": 0
}
]
]
},
"Attach AI Drafts": {
"main": [
[
{
"node": "Group by Owner",
"type": "main",
"index": 0
}
]
]
},
"Template Drafts": {
"main": [
[
{
"node": "Group by Owner",
"type": "main",
"index": 0
}
]
]
},
"Group by Owner": {
"main": [
[
{
"node": "Called via API?",
"type": "main",
"index": 0
}
]
]
},
"Called via API?": {
"main": [
[
{
"node": "Collect Owners",
"type": "main",
"index": 0
}
],
[
{
"node": "Send Nudges (Exercise 1)",
"type": "main",
"index": 0
}
]
]
},
"Collect Owners": {
"main": [
[
{
"node": "Respond",
"type": "main",
"index": 0
}
]
]
},
"Called via API? (none stale)": {
"main": [
[
{
"node": "Respond Nothing Stale",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
}
}