Most small companies handle holiday requests the same way: someone emails their manager, the manager replies "fine", and somebody is supposed to update a spreadsheet. Three months later nobody agrees on how many days anyone has left, and the request that came in over Easter counted the bank holidays as leave.
HR software fixes this, but it's a per-seat subscription for a team of eight. This post shows how to build a proper leave approval workflow in n8n with nothing more than an n8n Form, Google Sheets, Gmail and a free public holidays API. No AI needed: this is a case where plain logic is the right tool.
What it does
- An employee fills in a hosted form: email, leave type, first and last day, half day, notes.
- The workflow counts real working days: weekends and your country's public holidays removed, half days supported.
- It checks their remaining annual leave in Google Sheets and bounces impossible requests back to the employee with a clear reason.
- The manager gets an email with Approve and Decline buttons, and the workflow waits (up to three days).
- Approved: the balance is updated, the request logged, the employee emailed. Declined: logged and the employee told. No answer: HR is emailed.
The data model
One Google Sheet, two tabs.
Employees:
email | name | manager_email | annual_allowance | days_taken
Requests:
request_id | submitted | employee | email | leave_type | start_date | end_date | working_days | status | decided | reason
Keep emails lower case in the sheet, and lower-case what the form receives before looking it up. Jane.Doe@ and jane.doe@ are the same person to a human and two different people to a spreadsheet filter.
The flow in n8n
Form Trigger (Request time off)
-> Set: leave policy (country code, region, working days, HR email)
-> Code: which years do we need holidays for?
-> HTTP Request: Nager.Date public holidays (per year)
-> Google Sheets: look up employee by email
-> Code: count working days, validate, check balance
-> IF valid? -- no --> Gmail: tell the employee what's wrong
-> Gmail: Send and Wait for Response (double approval: Approve / Decline)
-> IF approved -> re-read balance -> update days_taken -> log -> email employee
-> IF declined -> log -> email employee
-> else (timed out) -> email HR
Step 1: Public holidays without an API key
Nager.Date is a free, open-source public holiday API covering 100+ countries. No key, no signup:
GET https://date.nager.at/api/v3/PublicHolidays/2026/GB
A request from 22 December to 5 January spans two years, so a tiny Code node first works out which years to fetch:
const f = $('Leave request form').first().json;
const y1 = Number(String(f.start_date).slice(0, 4)), y2 = Number(String(f.end_date).slice(0, 4));
const years = [...new Set([y1, y2].filter(y => y > 2000 && y < 2100))];
return (years.length ? years : [new Date().getFullYear()]).map(year => ({ json: { year } }));
The HTTP Request node then runs once per year. Set Never Error on in its options: if the API is down, you still want the request to go through (counted without holidays) rather than fail.
Regional holidays. Some holidays only apply in part of a country (Scotland's St Andrew's Day, US state holidays). Nager returns those with a counties array. The rule: include a holiday if it has no counties (national) or if your region, say GB-SCT, is in the list.
Step 2: Count working days properly
This is the heart of it:
const holidays = {};
for (const h of holidayItems.flatMap(i => Array.isArray(i.data) ? i.data : (i.date ? [i] : [])))
if (!h.counties || (region && h.counties.includes(region))) holidays[h.date] = h.localName || h.name;
const workDays = String(policy.working_days || '1,2,3,4,5').split(',').map(Number); // 1 = Monday
const parse = s => { const m = String(s || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
return m ? new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])) : null; };
let days = 0; const skipped = [];
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
const iso = d.toISOString().slice(0, 10), dow = d.getUTCDay() || 7; // Sunday -> 7
if (!workDays.includes(dow)) continue;
if (holidays[iso]) { skipped.push(`${iso} ${holidays[iso]}`); continue; }
days++;
}
if (f.half_day !== 'No' && days > 0) days -= 0.5;
Two details that bite people:
-
Do date maths in UTC. If you build dates with
new Date('2026-03-27')and loop in local time, the clocks changing in late March will eventually make you skip or double-count a day. Constructing withDate.UTCand stepping withsetUTCDateavoids that entirely. -
getDay()returns 0 for Sunday.|| 7converts it to ISO weekday numbers so1,2,3,4,5reads naturally as Monday to Friday. A four-day-week company sets1,2,3,4.
The same node collects human-readable reasons when something is wrong: end before start, no working days in the range, start date in the past (sick leave may be logged up to 14 days back), longer than two months, email not in the employee list, or more annual leave than remains. If there are any reasons, the employee gets an email listing them ("please submit the form again with corrected details") and nothing goes to the manager.
Step 3: One-click approval with Gmail "Send and Wait"
n8n's Gmail node has a Send and Wait for Response operation that is perfect for this. Choose approval type Double with labels Approve and Decline. The email includes the employee, dates, working days, which public holidays were excluded and the balance after this request, so the manager can decide without opening anything else.
Under options, Limit Wait Time of 3 days stops a forgotten email from leaving executions hanging forever.
After the wait, the response is in $json.data.approved:
-
true: approved -
false: declined - missing: the wait timed out
Use two IF nodes (approved === true, then approved === false) so the timeout case falls through to the HR email instead of being treated as a decline.
Step 4: Re-read before you write
The approval might come back two days later. In the meantime, another request for the same person could have been approved. So don't write days_taken + working_days using the number you read at submission time. Read the employee row again after approval, then add:
days_taken = {{ (Number($json.days_taken) || 0) + (leave_type === 'Annual leave' ? working_days : 0) }}
Only annual leave reduces the balance. Sick and unpaid leave are logged but don't touch days_taken.
Setup
- Import the workflow, connect Google Sheets and Gmail credentials.
- Create the sheet with the two tabs above and put its ID into the Google Sheets nodes.
- In the policy Set node:
country_code(GB, US, IE, DE, AU...), optionalholiday_region(GB-SCT,US-CA), working days and HR email. - Activate, copy the form's production URL and share it with the team (or put it on your intranet).
Gotchas
-
Waiting executions need a persistent n8n. Send and Wait parks the execution until someone clicks. On self-hosted n8n, make sure your instance's public URL (
WEBHOOK_URL) is set, or the buttons in the email will point at localhost. -
The form's date fields return
YYYY-MM-DDstrings. Parse those yourself rather than trustingnew Date()with a locale. - Two requests in quick succession can both pass the balance check before either is approved. The simple version accepts that risk; the fix is to reserve pending days (below).
Going further
This free approach covers the full request, approve, update cycle. Teams that need more usually ask for the same few things: stopping three people in one team being off together, sick leave that doesn't need approval, escalation when a manager is on holiday themselves, and approved leave on a shared calendar. I built those into a Pro version: team clash checks with a max_off_at_once per team, pending days reserved against the balance, escalation to HR after two days, approved leave as all-day Google Calendar events, and a Monday "who's off this week" email. It's $29 on Payhip: Leave approval workflow Pro. More templates on the store and my n8n creator profile.
Questions or a country whose holidays come out wrong? Ask in the comments.
The free workflow JSON for this and 24 other n8n templates is on GitHub: n8n-ai-workflow-templates.
Top comments (0)