Skip to content

← Back to the guide

Build pack: Overdue invoice reminders build pack

Everything from the video: the prompt that writes each reminder, the importable n8n workflow with both paths, and the setup checklist.

Watch the build: full video · 60-second version

  1. prompt

    The reminder prompt

    Goes in 'Prompt for Claude'. Swap [BUSINESS] and [OWNER]; keep the JSON line, the branch and the safety check read it.

    [BUSINESS] is owed money. Invoice {{ $json.invoice }}, {{ $json.customer }}: {{ $json.job }}, {{ $json.amount }}, due {{ $json.due }}, {{ $json.days }} days late. Tone: {{ $json.tone }}. friendly = assume they forgot. firm = clear and direct, still polite. call = 30+ days late, so route = owner: write nothing to the customer, give [OWNER] a one-line note for the call. Otherwise route = remind: write {{ $json.customer }} a short reminder. Say they can reply or call to pay. Never change the amount or the dates. Answer as JSON: {"route": "remind or owner", "subject": "under 60 characters", "message": "under 70 words, signed [BUSINESS]", "call_note": "one line for [OWNER], or none"}

  2. json

    n8n workflow (import this)

    Credentials, sheet IDs and keys removed; reconnect your Gmail and Google Sheets and set your own alert address.

    {
      "name": "Overdue invoice reminders (AI writes friendly or firm, 30+ days goes to the owner)",
      "nodes": [
        {
          "name": "Invoice check",
          "type": "n8n-nodes-base.formTrigger",
          "typeVersion": 2.2,
          "position": [
            0,
            0
          ],
          "parameters": {
            "formTitle": "[BUSINESS]",
            "formDescription": "Invoice check (runs every morning at 9).",
            "formFields": {
              "values": [
                {
                  "fieldLabel": "Invoice number",
                  "requiredField": true
                }
              ]
            },
            "options": {
              "appendAttribution": false,
              "path": "invoice-check",
              "respondWithOptions": {
                "values": {
                  "formSubmittedText": "Checking it now."
                }
              }
            }
          }
        },
        {
          "name": "Read the invoices",
          "type": "n8n-nodes-base.httpRequest",
          "typeVersion": 4.2,
          "position": [
            0,
            224
          ],
          "parameters": {
            "method": "GET",
            "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Invoices!A2:G200",
            "authentication": "predefinedCredentialType",
            "nodeCredentialType": "googleDriveOAuth2Api",
            "options": {}
          }
        },
        {
          "name": "How late is it?",
          "type": "n8n-nodes-base.code",
          "typeVersion": 2,
          "position": [
            0,
            448
          ],
          "parameters": {
            "mode": "runOnceForAllItems",
            "language": "javaScript",
            "jsCode": "const want = String($('Invoice check').first().json['Invoice number'] || '').replace(/\\D/g, '');\nconst rows = $('Read the invoices').first().json.values || [];\nconst r = rows.find(x => String(x[0] || '').replace(/\\D/g, '') === want);\nif (!want || !r) return [];\nconst [invoice, customer, email, job, amount, due, status] = [0, 1, 2, 3, 4, 5, 6].map(i => String(r[i] ?? '').trim());\nif (status.toLowerCase() === 'paid') return [];\nconst d = DateTime.fromFormat(due, 'MM/dd/yyyy', { zone: 'America/New_York' });\nif (!d.isValid) return [];\nconst days = Math.floor($now.setZone('America/New_York').startOf('day').diff(d.startOf('day'), 'days').days);\nif (days < 1) return [];\nconst tone = days >= 30 ? 'call' : days >= 14 ? 'firm' : 'friendly';\nreturn [{ json: { invoice, customer, email, job, amount, due, days, tone } }];"
          }
        },
        {
          "name": "Prompt for Claude",
          "type": "n8n-nodes-base.set",
          "typeVersion": 3.4,
          "position": [
            0,
            672
          ],
          "parameters": {
            "assignments": {
              "assignments": [
                {
                  "id": "p1",
                  "name": "prompt",
                  "type": "string",
                  "value": "=[BUSINESS] is owed money. Invoice {{ $json.invoice }}, {{ $json.customer }}: {{ $json.job }}, {{ $json.amount }}, due {{ $json.due }}, {{ $json.days }} days late. Tone: {{ $json.tone }}. friendly = assume they forgot. firm = clear and direct, still polite. call = 30+ days late, so route = owner: write nothing to the customer, give [OWNER] a one-line note for the call. Otherwise route = remind: write {{ $json.customer }} a short reminder. Say they can reply or call to pay. Never change the amount or the dates. Answer as JSON: {\"route\": \"remind or owner\", \"subject\": \"under 60 characters\", \"message\": \"under 70 words, signed [BUSINESS]\", \"call_note\": \"one line for [OWNER], or none\"}"
                },
                {
                  "id": "p2",
                  "name": "name",
                  "type": "string",
                  "value": "={{ $json.customer }}"
                },
                {
                  "id": "p3",
                  "name": "email",
                  "type": "string",
                  "value": "={{ $json.email }}"
                },
                {
                  "id": "p4",
                  "name": "invoice",
                  "type": "string",
                  "value": "={{ $json.invoice }}"
                },
                {
                  "id": "p5",
                  "name": "amount",
                  "type": "string",
                  "value": "={{ $json.amount }}"
                },
                {
                  "id": "p6",
                  "name": "due",
                  "type": "string",
                  "value": "={{ $json.due }}"
                },
                {
                  "id": "p7",
                  "name": "days",
                  "type": "string",
                  "value": "={{ $json.days }}"
                },
                {
                  "id": "p8",
                  "name": "tone",
                  "type": "string",
                  "value": "={{ $json.tone }}"
                },
                {
                  "id": "p9",
                  "name": "gotAt",
                  "type": "string",
                  "value": "={{ $now.setZone('America/New_York').toFormat('h:mm:ss a') }}"
                }
              ]
            },
            "options": {}
          }
        },
        {
          "name": "Queue for Claude",
          "type": "n8n-nodes-base.httpRequest",
          "typeVersion": 4.2,
          "position": [
            0,
            896
          ],
          "parameters": {
            "method": "POST",
            "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Queue!A:F:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS",
            "authentication": "predefinedCredentialType",
            "nodeCredentialType": "googleDriveOAuth2Api",
            "sendBody": true,
            "specifyBody": "json",
            "jsonBody": "={{ JSON.stringify({ values: [[ $json.gotAt, $json.name, $json.prompt, $execution.resumeUrl, '' ]] }) }}",
            "options": {}
          }
        },
        {
          "name": "Claude writes it",
          "type": "n8n-nodes-base.wait",
          "typeVersion": 1.1,
          "position": [
            0,
            1120
          ],
          "parameters": {
            "resume": "webhook",
            "httpMethod": "POST",
            "limitWaitTime": true,
            "resumeAmount": 5,
            "resumeUnit": "minutes",
            "options": {}
          }
        },
        {
          "name": "Check the reply",
          "type": "n8n-nodes-base.code",
          "typeVersion": 2,
          "position": [
            0,
            1344
          ],
          "parameters": {
            "mode": "runOnceForAllItems",
            "language": "javaScript",
            "jsCode": "const KEY = \"<<DEMO_RESUME_KEY>>\";\nconst it = $('Claude writes it').first().json;\nconst h = it.headers || {};\nif ((h['x-demo-key'] || '') !== KEY) throw new Error('resume rejected: missing or wrong key');\nconst to = String($('Prompt for Claude').first().json.email || '').trim().toLowerCase();\nconst TEST_MODE = true; // set false only after the form is protected (setup checklist)\nconst TEST_ADDRESSES = ['YOUR_TEST_EMAIL'];\nif (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(to)) throw new Error('send blocked: bad address');\nif (TEST_MODE && !TEST_ADDRESSES.map(a => a.toLowerCase()).includes(to)) throw new Error('send blocked: TEST_MODE is on and this is not one of your test addresses');\nconst b = it.body || {};\nconst out = {};\nout[\"subject\"] = String(b[\"subject\"] ?? '').slice(0, 90);\nout[\"message\"] = String(b[\"message\"] ?? '').slice(0, 700);\nout[\"call_note\"] = String(b[\"call_note\"] ?? '').slice(0, 200);\nconst MOODS = [\"remind\", \"owner\"];\nconst m = String(b.route ?? '').trim().toLowerCase();\nout.route = MOODS.includes(m) ? m : \"owner\";\nconst late = $('How late is it?').first().json;\nconst cents = s => Math.round(parseFloat(String(s).replace(/[$,\\s]/g, '')) * 100);\nif (Number(late.days) >= 30) out.route = 'owner';\nout.subject = out.subject.replace(/[\\r\\n]+/g, ' ').trim();\nif (out.route === 'remind' && (!out.subject || out.message.trim().length < 20)) out.route = 'owner';\nconst quoted = (out.message.match(/\\$\\s?[\\d,]+(\\.\\d{2})?/g) || []).map(cents);\nif (out.route === 'remind' && quoted.some(c => c !== cents(late.amount))) { out.route = 'owner'; out.call_note = 'the reminder quoted the wrong amount, check it'; }\nif (!out.call_note || out.call_note.toLowerCase() === 'none') out.call_note = 'no answer to the reminders so far';\nreturn [{ json: { body: out } }];"
          }
        },
        {
          "name": "Who handles it?",
          "type": "n8n-nodes-base.if",
          "typeVersion": 2.2,
          "position": [
            0,
            1568
          ],
          "parameters": {
            "conditions": {
              "options": {
                "caseSensitive": false,
                "leftValue": "",
                "typeValidation": "loose",
                "version": 2
              },
              "conditions": [
                {
                  "id": "c1",
                  "leftValue": "={{ $json.body.route }}",
                  "rightValue": "remind",
                  "operator": {
                    "type": "string",
                    "operation": "equals"
                  }
                }
              ],
              "combinator": "and"
            },
            "options": {}
          }
        },
        {
          "name": "Send the reminder",
          "type": "n8n-nodes-base.gmail",
          "typeVersion": 2.1,
          "position": [
            -260,
            1792
          ],
          "parameters": {
            "sendTo": "={{ $('Prompt for Claude').item.json.email }}",
            "emailType": "text",
            "subject": "={{ $('Check the reply').item.json.body.subject }}",
            "message": "={{ $('Check the reply').item.json.body.message + '\\n\\nInvoice ' + $('Prompt for Claude').item.json.invoice + ' · ' + $('Prompt for Claude').item.json.amount + ' · due ' + $('Prompt for Claude').item.json.due }}",
            "options": {
              "appendAttribution": false,
              "senderName": "[BUSINESS]"
            }
          }
        },
        {
          "name": "Log it",
          "type": "n8n-nodes-base.httpRequest",
          "typeVersion": 4.2,
          "position": [
            -260,
            2016
          ],
          "parameters": {
            "method": "POST",
            "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Reminders!A:F:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS",
            "authentication": "predefinedCredentialType",
            "nodeCredentialType": "googleDriveOAuth2Api",
            "sendBody": true,
            "specifyBody": "json",
            "jsonBody": "={{ JSON.stringify({ values: [[ $now.setZone('America/New_York').toFormat('h:mm:ss a'), $('Prompt for Claude').item.json.invoice, $('Prompt for Claude').item.json.name, $('Prompt for Claude').item.json.days, $('Prompt for Claude').item.json.tone, $('Prompt for Claude').item.json.amount ]] }) }}",
            "options": {}
          }
        },
        {
          "name": "Flag [OWNER]",
          "type": "n8n-nodes-base.gmail",
          "typeVersion": 2.1,
          "position": [
            260,
            1792
          ],
          "parameters": {
            "sendTo": "YOUR_ALERT_EMAIL",
            "emailType": "text",
            "subject": "={{ '📞 Call ' + $('Prompt for Claude').item.json.name + ': ' + $('Prompt for Claude').item.json.amount + ', ' + $('Prompt for Claude').item.json.days + ' days late' }}",
            "message": "={{ 'Invoice ' + $('Prompt for Claude').item.json.invoice + ' for ' + $('Prompt for Claude').item.json.name + ' is ' + $('Prompt for Claude').item.json.days + ' days late: ' + $('Prompt for Claude').item.json.amount + ', due ' + $('Prompt for Claude').item.json.due + '.\\n\\nFor the call: ' + $('Check the reply').item.json.body.call_note + '\\n\\nNo reminder was sent. It is on your Call list.' }}",
            "options": {
              "appendAttribution": false,
              "senderName": "Invoice Watch"
            }
          }
        },
        {
          "name": "Add to call list",
          "type": "n8n-nodes-base.httpRequest",
          "typeVersion": 4.2,
          "position": [
            260,
            2016
          ],
          "parameters": {
            "method": "POST",
            "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Call%20list!A:F:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS",
            "authentication": "predefinedCredentialType",
            "nodeCredentialType": "googleDriveOAuth2Api",
            "sendBody": true,
            "specifyBody": "json",
            "jsonBody": "={{ JSON.stringify({ values: [[ $now.setZone('America/New_York').toFormat('h:mm:ss a'), $('Prompt for Claude').item.json.invoice, $('Prompt for Claude').item.json.name, $('Prompt for Claude').item.json.amount, $('Prompt for Claude').item.json.days, $('Check the reply').item.json.body.call_note ]] }) }}",
            "options": {}
          }
        }
      ],
      "connections": {
        "Invoice check": {
          "main": [
            [
              {
                "node": "Read the invoices",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Read the invoices": {
          "main": [
            [
              {
                "node": "How late is it?",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "How late is it?": {
          "main": [
            [
              {
                "node": "Prompt for Claude",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Prompt for Claude": {
          "main": [
            [
              {
                "node": "Queue for Claude",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Queue for Claude": {
          "main": [
            [
              {
                "node": "Claude writes it",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Claude writes it": {
          "main": [
            [
              {
                "node": "Check the reply",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Check the reply": {
          "main": [
            [
              {
                "node": "Who handles it?",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Who handles it?": {
          "main": [
            [
              {
                "node": "Send the reminder",
                "type": "main",
                "index": 0
              }
            ],
            [
              {
                "node": "Flag [OWNER]",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Send the reminder": {
          "main": [
            [
              {
                "node": "Log it",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Flag [OWNER]": {
          "main": [
            [
              {
                "node": "Add to call list",
                "type": "main",
                "index": 0
              }
            ]
          ]
        }
      }
    }

    Download overdue-invoice-reminders-workflow.json

  3. text

    Setup checklist

    About an hour, most of it cleaning up the invoice sheet.

    1. Make a Google Sheet with tabs Invoices, Reminders, Call list and Queue. Invoices columns, in order: Invoice, Customer, Email, Job, Amount, Due (MM/DD/YYYY), Status (Unpaid or Paid).
    2. Import the workflow into n8n and put the sheet ID into 'Read the invoices', 'Queue for Claude', 'Log it' and 'Add to call list'.
    3. Connect Gmail. In 'Flag [OWNER]' replace YOUR_ALERT_EMAIL with the address that should get the call list alerts.
    4. The AI step: I run Claude through a small worker that answers the wait step, so no API key sits in n8n. With an API key, swap 'Queue for Claude' + 'Claude writes it' for n8n's Anthropic or OpenAI chat node and point 'Check the reply' at it.
    5. 'How late is it?' does the date math in plain code: paid or not due yet means nobody is emailed. 'Check the reply' sends anything 30+ days late to the owner, and if the AI's reminder quotes any dollar figure other than the sheet's, it goes to the owner instead. The invoice line under every reminder comes from the sheet.
    6. Swap the form for a Schedule trigger (every weekday at 9) followed by a loop over the unpaid rows, so every overdue invoice gets checked each morning. Add a 'Last reminded' column if you want at most one reminder per invoice per week.
    7. Test with your own addresses first: one invoice 5 days late, one 20 days late, one 40 days late, one marked Paid. Only the first two should email anyone.
    
    Before going live: protect the form (turn on form authentication in n8n, or swap the form for an internal-only trigger), then set TEST_MODE to false in "Check the reply". Until then, reminders go only to the addresses in TEST_ADDRESSES.

Written and built by an AI. What that means →