{
  "name": "Send Google review request emails to customers with Gmail and Google Sheets",
  "nodes": [
    {
      "id": "f4b0s001-0000-4000-8000-000000000001",
      "name": "Sticky Note \u2014 read me first",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -800,
        -80
      ],
      "parameters": {
        "width": 700,
        "height": 840,
        "content": "## Google reviews, asked for at the right moment \u2014 and never twice\n\n### How it works\nYou track finished jobs in one Google Sheet. Once a day, this workflow reads every open row and sends a friendly review request 2 days after the job, while the experience is still fresh. If the row is still open 7 days after the ask, it sends one gentle reminder, then stops for good. Mark a row `done` (review arrived) or `skip` and nothing more is ever sent. Every send is written back to the sheet, so a customer can never get the same email twice, even on manual re-runs.\n\nNo AI key needed \u2014 both templates are plain text with placeholders, so this runs 100% free.\n\n### Setup\n1. Connect **Gmail** and **Google Sheets** (one-click Google sign-in on n8n Cloud).\n2. Make a sheet with headers: `job_id, customer_name, customer_email, job_description, job_date, status, request_sent, reminder_sent` \u2014 set `status` to `open` and `job_date` as YYYY-MM-DD.\n3. Get your review link: Google Business Profile \u2192 **Ask for reviews** \u2192 copy the short link into **\u2699\ufe0f Your settings**.\n4. Select your sheet in both Sheets nodes, edit the two templates to sound like you, and activate. It checks once a day at 9am.\n\n### Honest notes\nThis sends for real \u2014 run one test row with your own email first. It cannot tell whether a review was left: marking a row `done` is on you. Ask everyone and never offer rewards \u2014 selective asking (review gating) and incentives break Google's review policy. Built with retries and an idempotent write-back for the day something breaks mid-run."
      },
      "typeVersion": 1
    },
    {
      "id": "f4b0s002-0000-4000-8000-000000000002",
      "name": "Sticky Note \u2014 section 1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -60,
        -80
      ],
      "parameters": {
        "color": 7,
        "width": 560,
        "height": 840,
        "content": "## 1. Read the customer ledger\nRuns daily at 9am. Pulls every row from your jobs sheet \u2014 the sheet stays the single source of truth that you and the workflow both edit."
      },
      "typeVersion": 1
    },
    {
      "id": "f4b0s003-0000-4000-8000-000000000003",
      "name": "Sticky Note \u2014 section 2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        520,
        -80
      ],
      "parameters": {
        "color": 7,
        "width": 460,
        "height": 840,
        "content": "## 2. Decide who to ask today\nKeeps only rows with status `open`. The first ask goes out once the job is 2+ days old; the single reminder only ever follows an ask that was actually sent, 7+ days later. This double guard (status + sent-flags) is what makes re-runs safe: no customer ever gets a duplicate, and nobody gets a reminder for an email they never received."
      },
      "typeVersion": 1
    },
    {
      "id": "f4b0s004-0000-4000-8000-000000000004",
      "name": "Sticky Note \u2014 section 3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1000,
        -80
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 840,
        "content": "## 3. Send + write back\nFills the template with the customer's name, the job and your review link, sends it from your Gmail, then immediately stamps the sheet (`request_sent` / `reminder_sent` = date). Write-back happens right after each send, so even a crash mid-run cannot cause a double email tomorrow."
      },
      "typeVersion": 1
    },
    {
      "id": "f4b00001-0000-4000-8000-00000000000a",
      "name": "Every day at 9am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        0,
        320
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 9
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "f4b00002-0000-4000-8000-00000000000b",
      "name": "\u2699\ufe0f Your settings",
      "type": "n8n-nodes-base.set",
      "position": [
        180,
        320
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "b1",
              "name": "businessName",
              "type": "string",
              "value": "YOUR BUSINESS NAME"
            },
            {
              "id": "b2",
              "name": "yourName",
              "type": "string",
              "value": "YOUR NAME"
            },
            {
              "id": "b3",
              "name": "reviewLink",
              "type": "string",
              "value": "https://g.page/r/YOUR-REVIEW-LINK/review"
            },
            {
              "id": "b4",
              "name": "requestOffset",
              "type": "number",
              "value": 2
            },
            {
              "id": "b5",
              "name": "reminderGap",
              "type": "number",
              "value": 7
            },
            {
              "id": "b6",
              "name": "subject1",
              "type": "string",
              "value": "How did we do, {customer_name}?"
            },
            {
              "id": "b7",
              "name": "body1",
              "type": "string",
              "value": "Hi {customer_name},\n\nThanks again for choosing {business_name} for {job_description}. If you have a spare minute, a Google review would mean a lot \u2014 it's how a small business like ours gets found:\n\n{review_link}\n\nAnd if anything about the job wasn't right, just reply to this email instead \u2014 I'd rather hear it from you and fix it.\n\n{your_name}\n{business_name}"
            },
            {
              "id": "b8",
              "name": "subject2",
              "type": "string",
              "value": "One small favor, {customer_name}?"
            },
            {
              "id": "b9",
              "name": "body2",
              "type": "string",
              "value": "Hi {customer_name},\n\nJust a gentle nudge \u2014 if you've already left a review, thank you, and please ignore this. If not, it takes about a minute:\n\n{review_link}\n\nEither way, thanks for your business. And if anything still needs attention, just reply \u2014 happy to help.\n\n{your_name}\n{business_name}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "f4b00003-0000-4000-8000-00000000000c",
      "name": "Read all customers",
      "type": "n8n-nodes-base.googleSheets",
      "maxTries": 3,
      "position": [
        360,
        320
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.5,
      "waitBetweenTries": 5000
    },
    {
      "id": "f4b00004-0000-4000-8000-00000000000d",
      "name": "Pick who to ask today",
      "type": "n8n-nodes-base.code",
      "position": [
        540,
        320
      ],
      "parameters": {
        "jsCode": "// Decide who gets the review request (or the single reminder) today.\n// Double idempotency guard: status must be 'open' AND the step's flag must be empty.\n// The reminder is anchored to request_sent, so it can never fire before the ask itself.\nconst cfg = $('\u2699\ufe0f Your settings').first().json;\nconst today = new Date();\ntoday.setHours(0, 0, 0, 0);\nconst daysSince = (dateStr) => {\n  const d = new Date(dateStr);\n  if (isNaN(d.getTime())) return null;\n  d.setHours(0, 0, 0, 0);\n  return Math.floor((today - d) / 86400000);\n};\nconst due = [];\n\nfor (const item of $('Read all customers').all()) {\n  const row = item.json;\n  if (String(row.status || '').trim().toLowerCase() !== 'open') continue;\n  if (!row.job_date || !row.customer_email) continue;\n\n  let step = null;\n  if (!row.request_sent) {\n    const days = daysSince(row.job_date);\n    if (days !== null && days >= cfg.requestOffset) step = 1;\n  } else if (!row.reminder_sent) {\n    const days = daysSince(row.request_sent);\n    if (days !== null && days >= cfg.reminderGap) step = 2;\n  }\n  if (!step) continue;\n\n  const fill = (t) => String(t)\n    .replaceAll('{customer_name}', row.customer_name || 'there')\n    .replaceAll('{job_description}', row.job_description || 'your project')\n    .replaceAll('{review_link}', cfg.reviewLink)\n    .replaceAll('{your_name}', cfg.yourName)\n    .replaceAll('{business_name}', cfg.businessName);\n\n  due.push({\n    json: {\n      job_id: row.job_id,\n      customer_email: row.customer_email,\n      step,\n      subject: fill(cfg['subject' + step]),\n      body: fill(cfg['body' + step]),\n      flag_column: step === 1 ? 'request_sent' : 'reminder_sent',\n    },\n  });\n}\n\nreturn due;"
      },
      "typeVersion": 2
    },
    {
      "id": "f4b00005-0000-4000-8000-00000000000e",
      "name": "One customer at a time",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        720,
        320
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "f4b00006-0000-4000-8000-00000000000f",
      "name": "Send the review request",
      "type": "n8n-nodes-base.gmail",
      "maxTries": 3,
      "position": [
        1060,
        336
      ],
      "parameters": {
        "sendTo": "={{ $json.customer_email }}",
        "message": "={{ $json.body }}",
        "options": {},
        "subject": "={{ $json.subject }}",
        "emailType": "text"
      },
      "retryOnFail": true,
      "typeVersion": 2.1,
      "waitBetweenTries": 5000
    },
    {
      "id": "f4b00007-0000-4000-8000-000000000010",
      "name": "Stamp the sheet (sent)",
      "type": "n8n-nodes-base.googleSheets",
      "maxTries": 3,
      "position": [
        1260,
        336
      ],
      "parameters": {
        "columns": {
          "value": {
            "job_id": "={{ $('One customer at a time').item.json.job_id }}",
            "={{ $('One customer at a time').item.json.flag_column }}": "={{ $now.toFormat('yyyy-MM-dd') }}"
          },
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "job_id"
          ]
        },
        "options": {},
        "operation": "update",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.5,
      "waitBetweenTries": 5000
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Every day at 9am": {
      "main": [
        [
          {
            "node": "\u2699\ufe0f Your settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read all customers": {
      "main": [
        [
          {
            "node": "Pick who to ask today",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u2699\ufe0f Your settings": {
      "main": [
        [
          {
            "node": "Read all customers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick who to ask today": {
      "main": [
        [
          {
            "node": "One customer at a time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "One customer at a time": {
      "main": [
        [],
        [
          {
            "node": "Send the review request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stamp the sheet (sent)": {
      "main": [
        [
          {
            "node": "One customer at a time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send the review request": {
      "main": [
        [
          {
            "node": "Stamp the sheet (sent)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}