DEV Community

vlotstroom
vlotstroom

Posted on

Turn finished visits into reviews and rebookings: an n8n follow-up workflow, JSON included

I build n8n automations for small salons and clinics. The series so far
covered preventing no-shows, refilling cancelled slots, and recovering
fees. This build covers what happens after a visit goes well: the
follow-up that turns a good visit into a review or a next booking. It is
the cheapest automation in the whole loop, because every customer it
messages already likes you. Complete workflow JSON at the end, runs on
n8n alone.

The follow-up that never gets sent

Every salon owner knows the playbook: message the customer the day after,
ask a first-timer for a review, nudge a regular to rebook. In practice
the counter is busy, the list lives in someone's head, and the message
goes out for two weeks and then never again. The customers most likely
to come back simply age out of attention.

The fix is boring and effective: a workflow that runs every 12 hours,
picks the visits that ended 20 to 30 hours ago, and sends exactly one
message. First-timers get the review ask. Regulars get a rebooking
nudge with a date computed from their treatment.

Why 20 to 30 hours

Same reasoning as the fee ladder in part three, opposite emotion. A
message the same evening reads as pushy sales. A message a week later
reads as a newsletter. The next morning, roughly 20 to 30 hours after
the visit, the visit is still a fresh good memory and the customer has
a minute to answer. That window is where review replies and BOOK replies
actually happen.

The window also does the usual double duty: it guarantees one message
per visit and keeps each run tiny.

Two messages, one flow

The split is one field: first_visit.

  • First visit: a short review ask with your review link. One sentence of thanks, one ask, done.
  • Regular: a rebooking nudge that already contains the suggested date. "Your next haircut is due in about 5 weeks (Friday 13 November). Want to reserve a spot now? Reply BOOK."

The date is the part that does the work. A nudge with a concrete date
is a booking; a nudge without one is a brochure.

The interval map is the real product

The workflow needs to know how often each treatment comes back. That
knowledge usually lives with the senior stylist and nowhere else. The
build puts it in one plain object at the top of the code node:

const INTERVAL_WEEKS = {
  'Hair treatment': 12,
  'Color touch-up': 6,
  'Haircut': 5,
  'Check-up': 6,
  'Default': 8
};
Enter fullscreen mode Exit fullscreen mode

Edit the treatments and weeks to match your own list. Clinics translate
this trivially: check-up intervals and recall periods are exactly this
map. When a treatment is missing, the Default entry catches it, so no
customer silently drops out.

What the workflow does

Seven nodes, one schedule trigger:

  1. Every 12 hours, a trigger fires.
  2. Completed visits are read. Demo rows in the JSON below; swap in your Google Sheet of completed visits.
  3. One code node picks the 20-30 hour window, picks review-ask versus rebook-nudge, computes the date, and builds the message.
  4. A placeholder send node stands in for WhatsApp Business Cloud, SMS, or email.
  5. An If node splits the two kinds.
  6. Two log nodes stamp the outcome: review_sent and rebook_sent, ready to wire into a sheet.

The columns the source needs:

appointment_id, customer_name, phone, visited_at (ISO), treatment, practitioner, first_visit
APT-3001, Anna Voorbeeld, +316****5678, 2026-10-01T09:00:00Z, Hair treatment, Stylist A, false
APT-3002, Tom Voorbeeld, +316****5432, 2026-10-03T08:00:00Z, Haircut, Stylist B, true
Enter fullscreen mode Exit fullscreen mode

The code node that matters

Window, kind, date, and message in one pass:

const items = $input.all();
const now = Date.now();
const out = [];
const INTERVAL_WEEKS = { 'Hair treatment': 12, 'Color touch-up': 6, 'Haircut': 5, 'Check-up': 6, 'Default': 8 };
for (const item of items) {
  const r = item.json;
  const hoursAgo = (now - new Date(r.visited_at).getTime()) / 3600000;
  if (hoursAgo < 20 || hoursAgo > 30) continue;
  const weeks = INTERVAL_WEEKS[r.treatment] || INTERVAL_WEEKS['Default'];
  const rebookDate = new Date(now + weeks * 7 * 24 * 3600 * 1000);
  const rebookStr = rebookDate.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' });
  let message;
  if (r.first_visit) {
    message = 'Hi ' + r.customer_name + ', thanks for your first visit! How was it? A review helps us a lot: REVIEWLINK. It takes 1 minute.';
  } else {
    message = 'Hi ' + r.customer_name + ', great to see you again! Your next ' + r.treatment + ' is due in about ' + weeks + ' weeks (' + rebookStr + '). Want to reserve a spot now? Reply BOOK.';
  }
  out.push({
    json: {
      appointment_id: r.appointment_id,
      customer_name: r.customer_name,
      to: r.phone,
      channel: 'whatsapp',
      kind: r.first_visit ? 'review_ask' : 'rebook_nudge',
      rebook_weeks: weeks,
      rebook_date: rebookDate.toISOString(),
      message: message
    }
  });
}
return out;
Enter fullscreen mode Exit fullscreen mode

The continue on the window check keeps every run idempotent: rows
outside the window drop out before a message exists, and rows older
than 30 hours are gone forever, which is correct. You had one morning
to catch that customer and the workflow used it.

Close the loop on BOOK replies

The rebook log node is where the series connects. When a customer
replies BOOK, that reply should land in a booking intake flow with the
suggested slot prefilled, not in an inbox. My booking intake build
(validated webhook, field checks, instant structured responses) is in
the pack linked below; wiring the BOOK reply into its webhook is two
nodes.

Test it without sending anything

The JSON below has demo data wired in. Import it into n8n, open the
demo visits node, and shift visited_at to roughly 26 hours ago (the
demo rows already sit there relative to now). Execute and watch the If
node split the first-timer from the regular. No message leaves: the
send node is a placeholder by design.

The complete workflow JSON

Import into n8n (Workflows, Import from File), replace the demo source
with your completed-visits sheet, and put a real send node in the
middle. Point REVIEWLINK at your review page:

{
  "name": "Send post-visit review asks and rebooking nudges",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 12
            }
          ]
        }
      },
      "id": "d1000000-0000-4000-8000-000000000001",
      "name": "Every 12 hours",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -200,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// DEMO SOURCE. Replace with your Google Sheet of completed visits.\n// Columns: appointment_id, customer_name, phone, visited_at (ISO), treatment, practitioner, first_visit\nconst demoRows = [\n  {\n    appointment_id: 'APT-3001',\n    customer_name: 'Anna Voorbeeld',\n    phone: '+316****5678',\n    visited_at: new Date(Date.now() - 26 * 3600 * 1000).toISOString(),\n    treatment: 'Hair treatment',\n    practitioner: 'Stylist A',\n    first_visit: false\n  },\n  {\n    appointment_id: 'APT-3002',\n    customer_name: 'Tom Voorbeeld',\n    phone: '+316****5432',\n    visited_at: new Date(Date.now() - 3 * 3600 * 1000).toISOString(),\n    treatment: 'Haircut',\n    practitioner: 'Stylist B',\n    first_visit: true\n  }\n];\nreturn demoRows.map((row) => ({ json: row }));"
      },
      "id": "d1000000-0000-4000-8000-000000000002",
      "name": "Completed visits (demo data)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        20,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Pick visits that ended 20..30 hours ago (the sweet spot: next morning).\n// First-time customers get the review ask; regulars get a rebooking nudge\n// with their usual interval.\nconst items = $input.all();\nconst now = Date.now();\nconst out = [];\nconst INTERVAL_WEEKS = { 'Hair treatment': 12, 'Color touch-up': 6, 'Haircut': 5, 'Check-up': 6, 'Default': 8 };\nfor (const item of items) {\n  const r = item.json;\n  const hoursAgo = (now - new Date(r.visited_at).getTime()) / 3600000;\n  if (hoursAgo < 20 || hoursAgo > 30) continue;\n  const weeks = INTERVAL_WEEKS[r.treatment] || INTERVAL_WEEKS['Default'];\n  const rebookDate = new Date(now + weeks * 7 * 24 * 3600 * 1000);\n  const rebookStr = rebookDate.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' });\n  let message;\n  if (r.first_visit) {\n    message = 'Hi ' + r.customer_name + ', thanks for your first visit! How was it? A review helps us a lot: REVIEWLINK. It takes 1 minute.';\n  } else {\n    message = 'Hi ' + r.customer_name + ', great to see you again! Your next ' + r.treatment + ' is due in about ' + weeks + ' weeks (' + rebookStr + '). Want to reserve a spot now? Reply BOOK.';\n  }\n  out.push({\n    json: {\n      appointment_id: r.appointment_id,\n      customer_name: r.customer_name,\n      to: r.phone,\n      channel: 'whatsapp',\n      kind: r.first_visit ? 'review_ask' : 'rebook_nudge',\n      rebook_weeks: weeks,\n      rebook_date: rebookDate.toISOString(),\n      message: message\n    }\n  });\n}\nreturn out;"
      },
      "id": "d1000000-0000-4000-8000-000000000003",
      "name": "Pick window + build message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        240,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Placeholder for the send node (WhatsApp Business Cloud / SMS / email).\nconst items = $input.all();\nreturn items.map((item) => ({\n    json: { ...item.json, sent_at: new Date().toISOString(), transport: 'demo' }\n}));"
      },
      "id": "d1000000-0000-4000-8000-000000000004",
      "name": "Send follow-up (replace me)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        460,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.kind }}",
              "rightValue": "review_ask",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "d1000000-0000-4000-8000-000000000005",
      "name": "Review ask?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        680,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Review-ask outcomes: log which channel converted (wire a Sheet write here).\nconst items = $input.all();\nreturn items.map((item) => ({ json: { ...item.json, logged_as: 'review_sent' } }));"
      },
      "id": "d1000000-0000-4000-8000-000000000006",
      "name": "Log review ask",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        900,
        -100
      ]
    },
    {
      "parameters": {
        "jsCode": "// Rebook-nudge outcomes: a BOOK reply should trigger your booking intake\n// workflow with the suggested slot prefilled.\nconst items = $input.all();\nreturn items.map((item) => ({ json: { ...item.json, logged_as: 'rebook_sent' } }));"
      },
      "id": "d1000000-0000-4000-8000-000000000007",
      "name": "Log rebook nudge",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        900,
        100
      ]
    },
    {
      "parameters": {
        "content": "## Send post-visit review asks and rebooking nudges\n\n### How it works\n\nEvery 12 hours the workflow reads completed visits and picks the ones that ended 20 to 30 hours ago: next-morning timing, high response rates. First-time customers get a review ask. Regulars get a rebooking nudge with the interval their treatment usually needs. Each message is built with the customer name, the treatment, and the suggested rebooking date, then handed to your WhatsApp, SMS, or email send node.\n\n### Setup steps\n\n- Replace the demo visits node with your Google Sheets source of completed visits.\n- Replace the send placeholder with a WhatsApp Business Cloud, SMS, or email node.\n- Point REVIEWLINK in the review message at your review page.\n\n### Customization\n\nTreatment intervals live in the INTERVAL_WEEKS map in the window-picker node. The 20-30 hour window and the 12-hour schedule fit most salons and clinics; shift them for your own rhythm.\n\n### Requirements\n\nn8n on any plan. The demo data runs without credentials. For production you need a Google Sheets credential and a WhatsApp Business Cloud, SMS, or email send credential.",
        "width": 600,
        "height": 820
      },
      "id": "cccc0000-0000-4000-8000-0000000000f1",
      "name": "Sticky Note 00f1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -960,
        -300
      ]
    },
    {
      "parameters": {
        "content": "## 1. Read visits\nDemo rows stand in for your Sheet of completed visits. Swap the node, keep the columns.",
        "color": 7,
        "width": 455,
        "height": 420
      },
      "id": "cccc0000-0000-4000-8000-0000000000f2",
      "name": "Sticky Note 00f2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -320,
        -200
      ]
    },
    {
      "parameters": {
        "content": "## 2. Pick window + build message\n20-30 hours after the visit. First visit: review ask. Regular: rebooking nudge with treatment interval.",
        "color": 7,
        "width": 400,
        "height": 420
      },
      "id": "cccc0000-0000-4000-8000-0000000000f3",
      "name": "Sticky Note 00f3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        175,
        -200
      ]
    },
    {
      "parameters": {
        "content": "## 3. Send + log\nReplace the placeholder with your real send node. Both branches log what went out.",
        "color": 7,
        "width": 515,
        "height": 580
      },
      "id": "cccc0000-0000-4000-8000-0000000000f4",
      "name": "Sticky Note 00f4",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        615,
        -200
      ]
    }
  ],
  "connections": {
    "Every 12 hours": {
      "main": [
        [
          {
            "node": "Completed visits (demo data)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Completed visits (demo data)": {
      "main": [
        [
          {
            "node": "Pick window + build message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick window + build message": {
      "main": [
        [
          {
            "node": "Send follow-up (replace me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send follow-up (replace me)": {
      "main": [
        [
          {
            "node": "Review ask?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Review ask?": {
      "main": [
        [
          {
            "node": "Log review ask",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log rebook nudge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Where this fits

This is the fourth full build in the series. Appointment reminders
with a no-double-send
guard
,
waitlist backfill that fills cancelled slots
automatically
,
and fee recovery with a polite escalation
ladder

came before. This one grows the good side of the ledger: reviews and
rebookings instead of losses. If you want the Dutch-language versions
plus the surrounding pack (booking intake, KPI digest) in one download,
it is here: https://vlotstream.gumroad.com/l/rhmxw

Free companion tool

Want the number before the workflow? I put the no-show math in a small
free calculator (EN/NL, no signup, runs in your browser): no-show cost
calculator

Top comments (0)