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 an upcoming and a same-day reminder, picks the
channel per customer (WhatsApp or email), and keeps a send log in
workflow static data so a rerun never sends the same reminder stage
twice.
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, with a send log. One Code node does the
sorting and the duplicate guard. It computes hours until the appointment,
checks the send log, and builds the message text:
const items = $input.all();
const now = Date.now();
const sent = $getWorkflowStaticData('global').sent || {};
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 stage = hoursUntil <= 3 ? 'same_day' : 'upcoming';
if (sent[r.appointment_id + ':' + stage]) continue; // already sent
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: stage,
message: template
}
});
sent[r.appointment_id + ':' + stage] = new Date().toISOString();
}
$getWorkflowStaticData('global').sent = sent;
return out;
Two details matter. The send log keyed on appointment_id + ':' + is what makes the 6h schedule safe: a rerun inside the window, an
stage
error recovery rerun, even a manual run all skip stages already logged,
and the log survives across executions because it lives in workflow
static data. And the message text lives in the code, not in the send
nodes, so translating or rewording is a one-place edit.
A reader correctly flagged that the first version of this article relied
on the window check alone, which re-sent the upcoming reminder on every
run while an appointment sat inside the 48h window. The code above and
the JSON below now carry the send log. The version in my template
library uses the same guard across three bands (48-72h heads-up, 8-48h
confirm ask, 0.5-3h same-day).
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 sent = ($getWorkflowStaticData('global').sent) || {};\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 stage = hoursUntil <= 3 ? 'same_day' : 'upcoming';\n if (sent[r.appointment_id + ':' + stage]) continue; // already sent\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: stage,\n hours_until: Math.round(hoursUntil * 10) / 10,\n message: template\n }\n });\n sent[r.appointment_id + ':' + stage] = new Date().toISOString();\n}\n$getWorkflowStaticData('global').sent = sent;\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.
Related in this series
This is part 1 of a small series on appointment automation with n8n. The other parts: Fill cancelled appointments automatically: a ranked waitlist n8n workflow, JSON included, Recover no-show fees without the awkward conversation: an n8n escalation ladder, JSON included and Turn finished visits into reviews and rebookings: an n8n follow-up workflow, JSON included. The loop closes with a weekly KPI digest that tells you whether it all worked.
Coming from Pipedream?
Pipedream Workflows shuts down on March 31, 2027. I wrote a migration map that covers what the export gives you, what maps where in n8n, and how the patterns in this series land on tested library templates: Pipedream Workflows shuts down in March 2027: a migration map to n8n
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 (2)
6시간 주기로 실행하면서 48시간 안의 모든 예약을 통과시키면 같은 예약에 여러 번 알림이 갈 수 있어 보입니다. 본문 코드의 시간 창은 예약 전/후만 거르므로, 예약 ID와 알림 단계(T-24h·T-2h)를 묶은 발송 기록을 확인하고 성공 상태를 남기는 절차가 추가되면 재실행·오류 복구 때도 중복 발송을 막는 데 도움이 되겠습니다.
Good catch, and you are right about the version that was printed here: the window check alone re-sent the
upcomingreminder on every 6h run while an appointment sat inside the 48h window.I have updated the article and the workflow JSON. The bucket node now keeps a send log in workflow static data, exactly the record-and-check step you describe:
appointment_id + ':' + stage(stage = upcoming / same_day)The template-library version I maintain uses the same guard across three bands (48-72h heads-up, 8-48h confirm ask, 0.5-3h same-day), so each appointment can get at most three reminders, one per band, no matter how often the schedule fires. Thanks for flagging it.