Skip to content

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"
  }
}