I build n8n automations for small salons and clinics. After reminders,
the second most common ask is this: a customer cancels at 18:00 for a
slot tomorrow morning, the owner does not want to spend the evening
phoning down a waitlist. Here is the full build: a webhook that takes
the cancellation, ranks the waitlist for that exact slot, and offers it
to the top three people at once. First reply wins. Complete workflow
JSON at the end, runs on n8n alone.
Why rank a waitlist instead of calling top-down
Calling one person at a time wastes the hours you are trying to save:
each no-answer burns 30 minutes of your evening. Offering to three
people at once means the first TAKE reply fills the slot and the rest
get a polite note. The only thing you have to decide in advance is who
counts as "top", and that is a scoring question, not a phone question.
My ranking, after a year of doing this for salon owners:
- Treatment match is worth 50 points. Someone who wanted exactly this treatment says yes fast.
- A flexible flag is worth 20. These people said "call me whenever something opens".
- Your own priority score is the tiebreaker. Most owners keep a 0-10 gut-feel number per regular anyway.
What the workflow does
Twelve nodes, one webhook entry:
- A booking system (or a human) POSTs the freed slot as JSON.
- The payload is normalized and validated. Bad payloads get a clean 422 back so the sender knows immediately instead of guessing.
- The waitlist is read (demo rows in the JSON below, swap in your Google Sheet).
- Every entry gets a match score, the top three get an offer message.
- The response tells the sender the slot is being offered.
A sample cancellation payload:
{
"slot_start": "2026-10-02T09:00:00+02:00",
"practitioner": "Lisa",
"treatment": "Knippen",
"duration_minutes": 45,
"location": "Salon Vlot"
}
And the offer message that goes out:
Hi Lisa Mertens, a spot just opened up Friday 09:00 with Lisa. You are
on our waitlist. Want this spot? Reply TAKE within 30 minutes, after
that it goes to the next person.
The build, node by node
1. Webhook. Path waitlist-offer, HTTP POST, respond immediately.
Every booking tool that can fire a webhook (Cal.com, Google Calendar
add-ons, plain Forms) can drive this.
2. Normalize cancellation. A Code node that coerces the payload
into one shape and validates it:
const b = $json.body || $json;
const out = {
slot_start: b.slot_start,
practitioner: b.practitioner || 'Team',
treatment: b.treatment || 'any',
duration_minutes: Number(b.duration_minutes) || 30,
};
const valid = out.slot_start && !Number.isNaN(Date.parse(out.slot_start));
return [{ json: { ...out, valid } }];
3. Valid? IF node. One branch for good payloads, one that returns
a 422 with a reason. Validation at the edge keeps junk out of your
waitlist logic.
4. Waitlist source. Demo rows in the JSON below, a disabled Google
Sheets node in real use. The columns that matter:
customer_name, phone, wanted_treatment, flexible, priority_score
Lisa Mertens, +316****5641, Haircut, true, 9
Daan Cox, +316****5642, Balayage, false, 6
Nina Blaauw, +316****5643, Haircut, true, 7
5. Rank + build offers. The scoring heart, one Code node:
const slot = $('Normalize cancellation').first().json;
const items = $input.all();
const ranked = items
.map((item) => {
const r = item.json;
let score = 0;
if (slot.treatment !== 'any' && r.wanted_treatment === slot.treatment) score += 50;
if (r.flexible) score += 20;
score += Number(r.priority_score) || 0;
return { json: { ...r, match_score: score } };
})
.sort((a, b) => b.json.match_score - a.json.match_score)
.slice(0, 3);
const slotStr = new Date(slot.slot_start).toLocaleString('en-GB', {
weekday: 'long', hour: '2-digit', minute: '2-digit'
});
return ranked.map((entry, i) => ({
json: {
...entry.json,
offer_position: i + 1,
slot_start: slot.slot_start,
message: 'Hi ' + entry.json.customer_name + ', a spot just opened up ' + slotStr + ' with ' + slot.practitioner + '. You are on our waitlist. Want this spot? Reply TAKE within 30 minutes, after that it goes to the next person.'
}
}));
The $('Normalize cancellation') lookup matters: the rank node needs
slot data from a different branch, and this is the n8n way to reach it.
6. Send offers. A placeholder Code node in the JSON below. Replace
it with WhatsApp Business Cloud, SMS or email nodes. In production I
split per customer channel preference, same as in my reminder build.
7. Respond. The webhook response says the slot is being offered and
to how many people.
Test it without sending anything
The workflow JSON below has a pinned demo payload on the webhook node.
Click Execute Workflow in the n8n editor and the whole flow runs on
that pinned data, offers built, no message ever leaves. That pinned
payload is also how I smoke-test every change before it goes live.
Two lessons from running this in production
First, cap the offers at three. I tried five once and two people
replied TAKE within minutes, which means an awkward "sorry, already
taken" to a customer you want to keep. Three is the largest number
where first-reply-wins still feels fair.
Second, the 30-minute window in the message text is load-bearing copy,
not a timer. The workflow does not enforce it; the message sets the
expectation so late replies arrive pre-apologized. Owners who want a
real timer can add a Wait node and a check step, and I usually talk
them out of it: the manual path is simpler and the message does the
work.
The complete workflow JSON
Import into n8n (Workflows, Import from File), open the webhook node,
and execute with the pinned payload:
{
"name": "Fill cancelled slots from a ranked waitlist",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "waitlist-cancellation",
"responseMode": "responseNode",
"options": {}
},
"id": "f1000000-0000-4000-8000-000000000001",
"name": "Webhook: slot cancelled",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
-200,
0
]
},
{
"parameters": {
"jsCode": "// Normalize the cancellation event. Expected body:\n// slot_start (ISO), practitioner, treatment, duration_min\nconst b = $input.first().json.body || $input.first().json;\nif (!b.slot_start || isNaN(new Date(b.slot_start).getTime())) {\n return [{ json: { ok: false, error: 'slot_start invalid' } }];\n}\nreturn [{\n json: {\n ok: true,\n slot_start: new Date(b.slot_start).toISOString(),\n practitioner: b.practitioner || 'any',\n treatment: b.treatment || 'any',\n duration_min: Number(b.duration_min) || 60,\n released_at: new Date().toISOString()\n }\n}];"
},
"id": "f1000000-0000-4000-8000-000000000002",
"name": "Normalize cancellation",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
20,
0
]
},
{
"parameters": {
"jsCode": "// DEMO SOURCE. Replace with your waitlist sheet.\n// Columns: customer_name, phone, wanted_treatment, flexible (bool), priority_score\nconst demoWaitlist = [\n { customer_name: 'Lisa Mertens', phone: '+316****5641', wanted_treatment: 'Haircut', flexible: true, priority_score: 9 },\n { customer_name: 'Daan Cox', phone: '+316****5642', wanted_treatment: 'Balayage', flexible: false, priority_score: 6 },\n { customer_name: 'Nina Blaauw', phone: '+316****5643', wanted_treatment: 'Haircut', flexible: true, priority_score: 7 }\n];\nreturn demoWaitlist.map((row) => ({ json: row }));"
},
"id": "f1000000-0000-4000-8000-000000000003",
"name": "Waitlist (demo data)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
240,
0
]
},
{
"parameters": {
"jsCode": "// Rank waitlist entries for this slot: treatment match first, flexible\n// entries next, then priority_score. Offer to the top 3 at once (first\n// reply wins, the rest get a friendly 'already taken').\nconst slot = $('Normalize cancellation').first().json;\nconst items = $input.all();\nconst ranked = items\n .map((item) => {\n const r = item.json;\n let score = 0;\n if (slot.treatment !== 'any' && r.wanted_treatment === slot.treatment) score += 50;\n if (r.flexible) score += 20;\n score += Number(r.priority_score) || 0;\n return { json: { ...r, match_score: score } };\n })\n .sort((a, b) => b.json.match_score - a.json.match_score)\n .slice(0, 3);\nconst slotStr = new Date(slot.slot_start).toLocaleString('en-GB', {\n weekday: 'long', hour: '2-digit', minute: '2-digit'\n});\nreturn ranked.map((entry, i) => ({\n json: {\n ...entry.json,\n offer_position: i + 1,\n slot_start: slot.slot_start,\n message: 'Hi ' + entry.json.customer_name + ', a spot just opened up ' + slotStr + ' with ' + slot.practitioner + '. You are on our waitlist. Want this spot? Reply TAKE within 30 minutes, after that it goes to the next person.'\n }\n}));"
},
"id": "f1000000-0000-4000-8000-000000000004",
"name": "Rank + build offers",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
460,
0
]
},
{
"parameters": {
"jsCode": "// Placeholder for the send node (WhatsApp Business Cloud / SMS).\nconst items = $input.all();\nreturn items.map((item) => ({\n json: { ...item.json, sent_at: new Date().toISOString(), transport: 'demo' }\n}));"
},
"id": "f1000000-0000-4000-8000-000000000005",
"name": "Send offers (replace me)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
0
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { \"ok\": true, \"offers_sent\": $input.all().length } }}",
"options": {}
},
"id": "f1000000-0000-4000-8000-000000000006",
"name": "Respond",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
900,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.ok }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "b0000000-0000-4000-8000-000000000006",
"name": "Valid?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
130,
0
]
},
{
"parameters": {
"respondWith": "json",
"responseCode": 422,
"responseBody": "={{ { \"ok\": false, \"error\": $json.error } }}",
"options": {}
},
"id": "b0000000-0000-4000-8000-000000000016",
"name": "Respond invalid",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
130,
220
],
"webhookId": "b0000000-0000-4000-8000-000000000026"
},
{
"parameters": {
"content": "## Fill cancelled slots from a ranked waitlist\n\n### How it works\n\nWhen a booking is cancelled, your calendar system POSTs the freed slot to a webhook. The workflow checks the payload, then ranks your waitlist for that exact slot: treatment match counts most, flexibility next, then your own priority score. The top three people get an offer message at the same time, first reply wins, and the slot rarely stays empty. Invalid payloads get a clean 422 back so the sender knows immediately. Demo data is included, and the pinned webhook payload lets you run the whole flow without sending anything.\n\n### Setup steps\n\n- Point your booking system's cancellation event at the webhook URL.\n- Disable the demo waitlist node and connect your waitlist sheet. Columns are listed in that node.\n- Replace the placeholder send node with WhatsApp Business Cloud or SMS.\n- Optional: change how many people get an offer (currently 3).\n\n### Requirements\n\n- A waitlist with customer name, phone, wanted treatment, a flexible flag, and a priority score.\n\n### Customization\n\nChange the ranking weights, the offer count, the 30-minute reply window, or the message copy.",
"color": "#fff59d",
"width": 340,
"height": 560
},
"id": "cccc0000-0000-4000-8000-0000000000f1",
"name": "Sticky Note 00f1",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-560,
-280
]
},
{
"parameters": {
"content": "## 1. Cancellation in\nWebhook plus payload check. Bad payloads get a 422.",
"color": "#ffffff",
"width": 240,
"height": 140
},
"id": "cccc0000-0000-4000-8000-0000000000f2",
"name": "Sticky Note 00f2",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-240,
-160
]
},
{
"parameters": {
"content": "## 2. Rank\nTreatment match first, flexible next, then priority. Top 3 get offers.",
"color": "#ffffff",
"width": 260,
"height": 140
},
"id": "cccc0000-0000-4000-8000-0000000000f3",
"name": "Sticky Note 00f3",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
140,
-160
]
},
{
"parameters": {
"content": "## 3. Offers out\nPlaceholder send node plus JSON response for the caller.",
"color": "#ffffff",
"width": 260,
"height": 140
},
"id": "cccc0000-0000-4000-8000-0000000000f4",
"name": "Sticky Note 00f4",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
520,
-160
]
}
],
"connections": {
"Webhook: slot cancelled": {
"main": [
[
{
"node": "Normalize cancellation",
"type": "main",
"index": 0
}
]
]
},
"Normalize cancellation": {
"main": [
[
{
"node": "Valid?",
"type": "main",
"index": 0
}
]
]
},
"Waitlist (demo data)": {
"main": [
[
{
"node": "Rank + build offers",
"type": "main",
"index": 0
}
]
]
},
"Rank + build offers": {
"main": [
[
{
"node": "Send offers (replace me)",
"type": "main",
"index": 0
}
]
]
},
"Send offers (replace me)": {
"main": [
[
{
"node": "Respond",
"type": "main",
"index": 0
}
]
]
},
"Valid?": {
"main": [
[
{
"node": "Waitlist (demo data)",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond invalid",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"Webhook: slot cancelled": [
{
"json": {
"body": {
"slot_start": "2099-01-15T10:00:00.000Z",
"practitioner": "Emma",
"treatment": "Haircut",
"duration_min": 45
}
}
}
]
}
}
Where this fits
This is the second of my salon automations I am publishing as full
builds. The first was appointment reminders with a no-double-send
guard. Together they cover the two sides of the same problem: reminders
prevent no-shows, waitlist backfill recovers the ones that happen
anyway. If you want the Dutch-language versions plus the surrounding
pack (booking intake, dunning, follow-ups, KPI digest) in one download,
it is here: https://vlotstream.gumroad.com/l/rhmxw
If you build this, the one thing to tune is the ranking weights. Every
owner ranks their waitlist slightly differently, and that is the whole
point: the machine phones nobody, it just puts your own order to work.
Related in this series
This is part 2 of a small series on appointment automation with n8n. The other part: Appointment reminders that cut no-shows: a complete n8n workflow, JSON included
Top comments (0)