{
  "name": "Chase unanswered quotes on a 2, 5 and 10 day schedule for HVAC, plumbing and home service businesses",
  "tags": [],
  "nodes": [
    {
      "parameters": {
        "content": "## Chase unanswered quotes on a 2, 5 and 10 day schedule\n\n### How it works\n\n1. Runs once every morning and reads the whole quote sheet.\n2. Counts the days since each quote was sent and works out which reminder is now due.\n3. Skips anything already chased at that stage, so the same wave never goes out twice.\n4. Sends the matching email and stamps the wave against the quote.\n5. A second webhook takes a quote out of the sequence as soon as the customer replies.\n\n### Setup steps\n\n- Create a sheet named Quotes with these headers in row 1: quote_id, sent_at, customer_name, email, job_summary, amount, status, reminders_sent, last_reminder_at.\n- Add one row per quote you send, with status set to awaiting and reminders_sent set to 0.\n- Connect Google Sheets credentials and select the same document and sheet in all three Sheets nodes.\n- In both update nodes, select quote_id under Column to Match On.\n- Connect Gmail credentials, then edit the company name, sender and message templates at the top of Decide Who Is Due.\n\n### Customization\n\nChange the STAGES array to chase on different days, or rewrite the three message templates in your own voice. Swap Gmail for Outlook or an SMS node. Anything that can make an HTTP request can post a quote_id to the second webhook to stop the sequence: a CRM, a form, or an inbox parser.",
        "width": 480,
        "height": 896
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -800,
        -112
      ],
      "id": "sticky-0",
      "name": "Sticky Note"
    },
    {
      "parameters": {
        "content": "## Read the quote sheet\n\nRuns every morning and pulls every quote currently on the sheet.",
        "width": 400,
        "height": 288,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -240,
        -64
      ],
      "id": "sticky-1",
      "name": "Sticky Note1"
    },
    {
      "parameters": {
        "content": "## Work out who is due\n\nCounts the days since each quote was sent and picks the reminder wave that is now due.",
        "width": 240,
        "height": 336,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        240,
        -112
      ],
      "id": "sticky-2",
      "name": "Sticky Note2"
    },
    {
      "parameters": {
        "content": "## Send and record the reminder\n\nSends the matching email, then stamps the wave against the quote so it is never repeated.",
        "width": 720,
        "height": 288,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        512,
        -64
      ],
      "id": "sticky-3",
      "name": "Sticky Note3"
    },
    {
      "parameters": {
        "content": "## Stop chasing on reply\n\nAny tool can post a quote_id here to take that quote out of the sequence.",
        "width": 944,
        "height": 272,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -240,
        448
      ],
      "id": "sticky-4",
      "name": "Sticky Note4"
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 9
            }
          ]
        }
      },
      "id": "schedule-daily",
      "name": "Every Morning",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -192,
        64
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "REPLACE_WITH_YOUR_SPREADSHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Quotes",
          "mode": "name"
        },
        "options": {}
      },
      "id": "sheets-read",
      "name": "Get Open Quotes",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        16,
        64
      ]
    },
    {
      "parameters": {
        "jsCode": "// Works out which quotes need a nudge today. All state lives in the sheet, so a\n// restart or a missed day never breaks the sequence.\n\n// ---------- DAYS AFTER THE QUOTE WAS SENT ----------\nconst STAGES = [2, 5, 10];\n// ---------------------------------------------------\n\nconst COMPANY = 'Your Company';\nconst SENDER = 'Alex';\n\nconst MESSAGES = [\n  {\n    subject: (r) => `Quick check on your quote, ${r.customer_name || 'there'}`,\n    body: (r) =>\n      `Hi ${r.customer_name || 'there'},\\n\\n` +\n      `Just making sure the quote for ${r.job_summary} reached you. ` +\n      `Any questions about what is included, reply here and I will talk you through it.\\n\\n` +\n      `${SENDER}\\n${COMPANY}`,\n  },\n  {\n    subject: (r) => `Anything you would like changed on the quote?`,\n    body: (r) =>\n      `Hi ${r.customer_name || 'there'},\\n\\n` +\n      `Still happy to adjust the scope on ${r.job_summary} if the number is not where you need it. ` +\n      `Plenty of jobs get trimmed or staged to fit a budget, so tell me what you had in mind.\\n\\n` +\n      `${SENDER}\\n${COMPANY}`,\n  },\n  {\n    subject: (r) => `Shall I close this one out?`,\n    body: (r) =>\n      `Hi ${r.customer_name || 'there'},\\n\\n` +\n      `I have not heard back on ${r.job_summary}, so I will assume the timing is not right and stop chasing. ` +\n      `If you would rather keep it open, just reply and I will hold it.\\n\\n` +\n      `${SENDER}\\n${COMPANY}`,\n  },\n];\n\nconst now = Date.now();\nconst DAY = 24 * 60 * 60 * 1000;\nconst due = [];\n\nfor (const item of $input.all()) {\n  // Spreadsheet headers often carry a stray trailing space, which would otherwise\n  // make every lookup below silently return undefined.\n  const row = {};\n  for (const [key, value] of Object.entries(item.json)) row[key.trim()] = value;\n\n  if (String(row.status || '').trim().toLowerCase() !== 'awaiting') continue;\n  if (!row.sent_at || !row.email) continue;\n\n  const sentAt = new Date(row.sent_at).getTime();\n  if (Number.isNaN(sentAt)) continue;\n\n  const daysElapsed = Math.floor((now - sentAt) / DAY);\n  const alreadySent = Number(row.reminders_sent || 0);\n\n  // The highest stage the calendar has reached so far.\n  let stage = 0;\n  for (let i = 0; i < STAGES.length; i++) {\n    if (daysElapsed >= STAGES[i]) stage = i + 1;\n  }\n\n  // Nothing new is due, or the whole sequence is finished.\n  if (stage <= alreadySent || stage > MESSAGES.length) continue;\n\n  const template = MESSAGES[stage - 1];\n\n  due.push({\n    json: {\n      quote_id: row.quote_id,\n      customer_name: row.customer_name,\n      email: row.email,\n      job_summary: row.job_summary,\n      amount: row.amount,\n      stage,\n      days_elapsed: daysElapsed,\n      subject: template.subject(row),\n      body: template.body(row),\n    },\n  });\n}\n\nreturn due;"
      },
      "id": "code-due",
      "name": "Decide Who Is Due",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        288,
        64
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $json.email }}",
        "subject": "={{ $json.subject }}",
        "emailType": "text",
        "message": "={{ $json.body }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "gmail-send",
      "name": "Send Follow Up",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        560,
        64
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Records which wave went out, so the same reminder is never sent twice.\nconst src = $('Decide Who Is Due').item.json;\n\nreturn {\n  json: {\n    quote_id: src.quote_id,\n    reminders_sent: src.stage,\n    last_reminder_at: new Date().toISOString(),\n  },\n};"
      },
      "id": "code-log",
      "name": "Build Reminder Log",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        816,
        64
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "REPLACE_WITH_YOUR_SPREADSHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Quotes",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [
            "quote_id"
          ],
          "schema": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "sheets-log",
      "name": "Mark Reminder Sent",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1088,
        64
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "quote-replied",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "webhook-replied",
      "name": "When Quote Replied",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -192,
        560
      ],
      "webhookId": "f4a19d72-3b58-4c60-8d2e-91ca7f6b0d33"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={\"status\":\"ok\"}",
        "options": {}
      },
      "id": "respond-replied",
      "name": "Confirm Reply Logged",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        16,
        560
      ]
    },
    {
      "parameters": {
        "jsCode": "// Any tool can call this webhook with a quote_id to take the quote out of the\n// sequence: a CRM, a form, an inbox parser, or a person clicking a link.\nreturn $input.all().map((item) => {\n  const body = item.json.body || item.json || {};\n  return {\n    json: {\n      quote_id: String(body.quote_id || '').trim(),\n      status: 'replied',\n    },\n  };\n});"
      },
      "id": "code-replied",
      "name": "Build Replied Row",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        288,
        560
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "REPLACE_WITH_YOUR_SPREADSHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Quotes",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [
            "quote_id"
          ],
          "schema": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "sheets-replied",
      "name": "Mark Quote Replied",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        560,
        560
      ]
    }
  ],
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Every Morning": {
      "main": [
        [
          {
            "node": "Get Open Quotes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Open Quotes": {
      "main": [
        [
          {
            "node": "Decide Who Is Due",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Decide Who Is Due": {
      "main": [
        [
          {
            "node": "Send Follow Up",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Follow Up": {
      "main": [
        [
          {
            "node": "Build Reminder Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Reminder Log": {
      "main": [
        [
          {
            "node": "Mark Reminder Sent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Quote Replied": {
      "main": [
        [
          {
            "node": "Confirm Reply Logged",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Confirm Reply Logged": {
      "main": [
        [
          {
            "node": "Build Replied Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Replied Row": {
      "main": [
        [
          {
            "node": "Mark Quote Replied",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
