I build n8n automations for small salons and clinics. The most requested
one is also the simplest: appointment reminders that actually reduce
no-shows. Here is the full build, step by step, with the complete
workflow JSON at the end. It runs on n8n alone, self-hosted or cloud.
What it does
Every 6 hours the workflow reads upcoming appointments from a Google
Sheet, buckets them into T-24h and T-2h reminders, picks the channel per
customer (WhatsApp or email), and skips everything outside a 48h window
so repeated runs never double-send.
A sample row:
appointment_id, customer_name, phone, email, starts_at, practitioner, treatment, channel_preference
APT-1041, Sanne de Vries, +31612345678, sanne@example.com, 2026-09-29T14:00:00+02:00, Lisa, Knippen, whatsapp
The build, node by node
1. Schedule Trigger. Set it to every 6 hours. Reminders do not need
minute precision, and a calm cadence keeps you far away from API rate
limits.
2. Read appointments. A Google Sheets node with the document ID and
the appointments sheet name. While building, I use a Code node with
three demo rows instead, so I can test without touching a real sheet.
The JSON below ships with that demo node and a disabled Sheets node you
swap in.
3. Bucket the reminders. One Code node does the sorting. It computes
hours until the appointment and builds the message text:
const items = $input.all();
const now = Date.now();
const out = [];
for (const item of items) {
const r = item.json;
const startsAt = new Date(r.starts_at);
const hoursUntil = (startsAt.getTime() - now) / 3600000;
if (hoursUntil < 0 || hoursUntil > 48) continue; // not in window
const dateStr = startsAt.toLocaleString('nl-NL', {
weekday: 'long', day: 'numeric', month: 'long',
hour: '2-digit', minute: '2-digit'
});
let template;
if (hoursUntil <= 3) {
template = `Hi ${r.customer_name}, uw afspraak bij ${r.practitioner} is vandaag om ${dateStr} (${r.treatment}). Tot zo!`;
} else {
template = `Hi ${r.customer_name}, herinnering: uw afspraak (${r.treatment}) bij ${r.practitioner} is ${dateStr}. Antwoord BEVESTIG om te bevestigen, WIJZIG om te verplaatsen.`;
}
out.push({
json: {
appointment_id: r.appointment_id,
to: r.channel_preference === 'email' ? r.email : r.phone,
channel: r.channel_preference === 'email' ? 'email' : 'whatsapp',
urgency: hoursUntil <= 3 ? 'same_day' : 'upcoming',
message: template
}
});
}
return out;
Two details matter. The window check (< 0 || > 48) is what makes the
6h schedule safe: an appointment gets at most one reminder per run and
stops being eligible once it starts. And the message text lives in the
code, not in the send nodes, so translating or rewording is a one-place
edit.
4. Split by urgency. An IF node on urgency equals same_day. Same-day
messages go out immediately; upcoming ones can batch.
5. Send. Wire a WhatsApp Business Cloud node and an email node to the
two branches. In the JSON below these are stub Code nodes that mark the
queue, so the workflow imports and runs green before you add credentials.
Testing it
Import the JSON, open the reminder-bucket Code node, and run it. You
should see three items become two: the demo rows sit at T-22h, T-1.5h and
T-5d, and the third one falls outside the window. Change the window in
the code and watch the third row appear. That loop, run locally before
anything touches a real sheet, is how I validate every workflow I ship.
Where this goes next
The reminder is one loop of seven. The others handle the replies
(confirm/cancel parsing), no-show follow-ups with a fee ladder, waitlist
autofill when a slot frees up, and a Monday KPI digest. I packaged the
full set with docs and test data if you want the rest:
https://vlotstream.gumroad.com/l/rhmxw
The complete workflow JSON:
{
"id": "aaaaaaaa-0000-4000-8000-0000000000a1",
"name": "01 - Appointment Reminders (T-24h / T-2h)",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 6
}
]
}
},
"id": "a1000000-0000-4000-8000-000000000001",
"name": "Every 6 hours",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [-200, 0]
},
{
"parameters": {
"jsCode": "// DEMO SOURCE. Replace with the Google Sheets node below.\n// Columns: appointment_id, customer_name, phone, email, starts_at (ISO),\n// practitioner, treatment, channel_preference\nconst demoRows = [\n {\n appointment_id: 'APT-1041',\n customer_name: 'Sanne de Vries',\n phone: '+316****5678',\n email: 'sanne@example.com',\n starts_at: new Date(Date.now() + 22 * 3600 * 1000).toISOString(),\n practitioner: 'Lisa',\n treatment: 'Tandreiniging',\n channel_preference: 'whatsapp'\n },\n {\n appointment_id: 'APT-1042',\n customer_name: 'Mark Jansen',\n phone: '+316****6789',\n email: 'mark@example.com',\n starts_at: new Date(Date.now() + 90 * 60 * 1000).toISOString(),\n practitioner: 'Drs. Bakker',\n treatment: 'Controle',\n channel_preference: 'email'\n },\n {\n appointment_id: 'APT-1043',\n customer_name: 'Fatima El Amrani',\n phone: '+316****7890',\n email: 'fatima@example.com',\n starts_at: new Date(Date.now() + 5 * 24 * 3600 * 1000).toISOString(),\n practitioner: 'Lisa',\n treatment: 'Nieuwe patient',\n channel_preference: 'whatsapp'\n }\n];\nreturn demoRows.map((row) => ({ json: row }));"
},
"id": "a1000000-0000-4000-8000-000000000002",
"name": "Appointments (demo data)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [20, 0]
},
{
"parameters": {
"jsCode": "// Build the reminder message for each appointment.\n// Hours-until buckets decide the template; channel comes from the row.\n// Future appointments beyond 48h are skipped so this workflow can run\n// every few hours safely.\nconst items = $input.all();\nconst now = Date.now();\nconst out = [];\n\nfor (const item of items) {\n const r = item.json;\n const startsAt = new Date(r.starts_at);\n const hoursUntil = (startsAt.getTime() - now) / 3600000;\n\n if (hoursUntil < 0 || hoursUntil > 48) continue; // not in reminder window\n\n const dateStr = startsAt.toLocaleString('nl-NL', {\n weekday: 'long', day: 'numeric', month: 'long',\n hour: '2-digit', minute: '2-digit'\n });\n\n let template;\n if (hoursUntil <= 3) {\n template = `Hi ${r.customer_name}, uw afspraak bij ${r.practitioner} is vandaag om ${dateStr} (${r.treatment}). Tot zo!`;\n } else {\n template = `Hi ${r.customer_name}, herinnering: uw afspraak (${r.treatment}) bij ${r.practitioner} is ${dateStr}. Antwoord BEVESTIG om te bevestigen, WIJZIG om te verplaatsen.`;\n }\n\n const channel = r.channel_preference === 'email' ? 'email' : 'whatsapp';\n out.push({\n json: {\n appointment_id: r.appointment_id,\n customer_name: r.customer_name,\n to: channel === 'email' ? r.email : r.phone,\n channel,\n urgency: hoursUntil <= 3 ? 'same_day' : 'upcoming',\n hours_until: Math.round(hoursUntil * 10) / 10,\n message: template\n }\n });\n}\nreturn out;"
},
"id": "a1000000-0000-4000-8000-000000000003",
"name": "Build reminder messages",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [240, 0]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.urgency }}",
"rightValue": "same_day",
"operator": { "type": "string", "operation": "equals" }
}
],
"combinator": "and"
},
"options": {}
},
"id": "a1000000-0000-4000-8000-000000000004",
"name": "Is same-day?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [460, 0]
},
{
"parameters": {
"jsCode": "// Same-day reminders: WhatsApp first, email fallback when no phone.\n// Wire real send nodes here (WhatsApp Business Cloud / Gmail / SMTP).\nconst items = $input.all();\nreturn items.map((item) => ({\n json: {\n ...item.json,\n queue: item.json.channel === 'whatsapp' ? 'whatsapp_priority' : 'email_priority',\n dispatched_at: new Date().toISOString()\n }\n}));"
},
"id": "a1000000-0000-4000-8000-000000000005",
"name": "Dispatch same-day",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [680, -100]
},
{
"parameters": {
"jsCode": "// Upcoming reminders (next 48h): normal priority batch.\nconst items = $input.all();\nreturn items.map((item) => ({\n json: {\n ...item.json,\n queue: item.json.channel === 'whatsapp' ? 'whatsapp_normal' : 'email_normal',\n dispatched_at: new Date().toISOString()\n }\n}));"
},
"id": "a1000000-0000-4000-8000-000000000006",
"name": "Dispatch upcoming",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [680, 100]
},
{
"parameters": {
"documentId": { "__rl": true, "value": "YOUR_SHEET_ID", "mode": "id" },
"sheetName": { "__rl": true, "value": "appointments", "mode": "name" },
"options": {}
},
"id": "a1000000-0000-4000-8000-000000000007",
"name": "Google Sheets (replace demo)",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [20, 200],
"disabled": true
}
],
"connections": {
"Every 6 hours": { "main": [[{ "node": "Appointments (demo data)", "type": "main", "index": 0 }]] },
"Appointments (demo data)": { "main": [[{ "node": "Build reminder messages", "type": "main", "index": 0 }]] },
"Build reminder messages": { "main": [[{ "node": "Is same-day?", "type": "main", "index": 0 }]] },
"Is same-day?": {
"main": [
[{ "node": "Dispatch same-day", "type": "main", "index": 0 }],
[{ "node": "Dispatch upcoming", "type": "main", "index": 0 }]
]
}
},
"settings": { "executionOrder": "v1" },
"pinData": {}
}
Questions about the window logic or the channel split? Ask in the comments.
Top comments (1)