An n8n flow that resolves the multi-party scheduling problem that sits between “candidate advances to interview stage” and “calendar invite sent.” The flow receives a Greenhouse webhook on stage change, queries Google Calendar’s freeBusy API for every panelist and the recruiter simultaneously, intersects those busy windows against the candidate’s stated availability, ranks the resulting open slots by a set of tie-break rules, and posts the top 3 proposed times to a Slack channel for the recruiter to confirm and book. A daily backstop cron sweeps for interviews left unscheduled for more than 48 hours and replays them through the same path.
The artifact bundle lives at apps/web/public/artifacts/interview-scheduling-resolver-n8n/ and contains interview-scheduling-resolver-n8n.json (the complete n8n flow export) and _README.md (import steps, per-credential setup, first-run verification procedure).
When to use
You are using Greenhouse as your ATS and interviews routinely involve 3 or more panelists whose calendars are spread across two or more time zones.
The recruiting coordinator spends 20–45 minutes per role per loop on the scheduling ping-pong — sending availability emails, waiting for responses, checking four calendars manually, proposing a slot, discovering a conflict.
You want a decision record for every proposed slot: which windows were evaluated, how many panel-busy blocks were merged, what the ranking score was. The Slack message the flow posts includes this data so the recruiter can see why each slot was surfaced.
You are already on n8n (self-hosted or Cloud) and have a Google Workspace environment where panelists’ calendars are accessible via OAuth2 or a service account with domain-wide delegation.
When NOT to use
High-volume / high-frequency hiring events. If you are running 50+ panel interviews per day — recruiting events, university programs, high-volume hourly — the freeBusy-per-trigger model generates significant API call volume. Google Calendar’s freeBusy endpoint is not rate-limited under normal quota, but at high volume you will need to batch calls. GoodTime or ModernLoop are built for this traffic pattern; the n8n flow is not.
ATS platforms other than Greenhouse without a stage-change webhook. The trigger depends on receiving a signed Greenhouse webhook. Replacing it with an Ashby or Lever equivalent is straightforward (swap the trigger node), but polling-only ATS platforms introduce at least a 5-minute latency floor, which breaks the “schedule within the hour” use case.
Auto-booking without recruiter confirmation. The flow deliberately stops at Slack notification. It does not call POST /v2/scheduled_interviews to write a calendar event back to Greenhouse without a human confirming the slot. Wiring auto-book is technically simple but transfers scheduling authority from the recruiter to the algorithm. Timezone edge cases, candidate preference signals, and late-breaking panelist constraints all make the confirmation step worth keeping.
Teams where panelists don’t use Google Calendar. The freeBusy query is Google Calendar-specific. Outlook/Exchange availability requires the Microsoft Graph freeBusy endpoint (/me/calendar/getSchedule), which needs a separate HTTP Request node and Azure AD credentials. The flow does not ship that path.
Under 5 interviews per week per recruiter. At that volume, manual coordination is faster than setting up OAuth credentials and a Greenhouse webhook. The setup cost pays back at roughly the 10-interview-per-week mark.
Setup
Import the flow. In n8n, open Workflows → Import from File and select apps/web/public/artifacts/interview-scheduling-resolver-n8n/interview-scheduling-resolver-n8n.json. Every node carries notesInFlow: true so the canvas notes explain each step.
Set the webhook secret env var. In your n8n instance settings (or .env for self-hosted), add GREENHOUSE_WEBHOOK_SECRET with the signing secret from the Greenhouse Dev Center. The signature-verification node throws and halts if this variable is absent or if the HMAC-SHA256 check fails.
Wire Google Calendar OAuth2. Create an OAuth 2.0 credential in n8n under PLACEHOLDER_GOOGLE_CAL_CRED_ID. The required scope is calendar.readonly. For Workspace environments with multiple panel members, a service account with domain-wide delegation is more practical than individual OAuth tokens per panelist — the _README.md covers both paths.
Wire Greenhouse Harvest API. Create an HTTP Header Auth credential under PLACEHOLDER_GREENHOUSE_CRED_ID. Greenhouse Harvest uses Basic Auth with the API key as the username and a blank password (base64-encode api_key:). Grant Scheduled Interviews (read) and Applications (read) scopes only.
Wire Slack bot token. Create an HTTP Header Auth credential under PLACEHOLDER_SLACK_CRED_ID with Authorization: Bearer xoxb-.... Invite the bot to #scheduling-queue.
Wire the Greenhouse webhook. In Greenhouse Dev Center, create a web hook pointing to your n8n instance URL at path /webhook/interview-scheduling-resolver. Subscribe to candidate_stage_change. Copy the signing secret into GREENHOUSE_WEBHOOK_SECRET.
Stub or wire candidate availability. The Candidate Availability Intake node ships as a stub returning Mon–Fri 9am–6pm ET for 14 days. Wire a Calendly webhook or a Typeform/Airtable read to get real candidate constraints before enabling in production.
Run first-run verification. The _README.md lists five specific test cases — valid signature, invalid signature, slots-found path, no-availability path, backstop cron path — each with expected outputs. Complete all five before activating the trigger.
What the flow does
Thirteen nodes across two trigger paths.
Webhook path (real-time):
Greenhouse Webhook — interview_requested — receives candidate_stage_change POST events. Returns 202 immediately via a sibling Respond 202 Accepted node so the Greenhouse webhook delivery never times out while the flow processes.
Verify Signature + Extract Participants — HMAC-SHA256 verifies the Greenhouse webhook signature using crypto.createHmac against GREENHOUSE_WEBHOOK_SECRET. Mismatch throws and halts. On pass, extracts recruiterEmail, interviewerEmails[], candidateEmail, jobName, stageName, and builds allCalendarIds as the deduplicated union of recruiter and interviewer emails. The HMAC check is non-optional: Greenhouse webhooks arrive from the public internet.
Google Calendar — freeBusy Query — POSTs to https://www.googleapis.com/calendar/v3/freeBusy with allCalendarIds as the items[] array and a 14-day window starting tomorrow. Returns per-calendar busy[] arrays with RFC3339 start/end times. Uses the PLACEHOLDER_GOOGLE_CAL_CRED_ID OAuth2 credential with calendar.readonly scope.
Candidate Availability Intake — reads the candidate’s available windows. Ships as a stub; swap for real availability data per the setup instructions.
Resolve Conflicts — Intersect + Rank Slots — the core algorithm node (see below).
Slots Found? — IF node. Routes to the notify path if resolved: true, to the escalation path if resolved: false.
Slack — Notify Recruiter with Proposed Slots — posts top 3 slots to #scheduling-queue with score, panel list, slots evaluated count, and a deep link to the Greenhouse application.
Slack — Escalate No-Availability — posts a manual-coordination alert when no common window exists.
Daily backstop path:
Daily Backstop Cron — 8am ET weekdays — fires at 08:00 America/New_York, Monday through Friday (cron: 0 8 * * 1-5).
Greenhouse — List Stale Unscheduled Interviews — calls Greenhouse Harvest GET /v1/scheduled_interviews?created_before=<48h-ago> to find interviews where the webhook was missed or delivery failed. The scheduled_interviews endpoint has no status query parameter, so the sweep fetches everything created more than 48 hours ago and filters in the next node.
Filter Stale Unscheduled (client-side) — drops any interview that already has a confirmed start.date_time (or is complete/awaiting_feedback), keeping only the genuinely unscheduled records. This replaces the non-existent status query filter the Harvest endpoint silently ignores.
Split Into Items — splits the filtered array into individual items for per-application processing.
Engineering choices: the free/busy intersection algorithm
The conflict-resolution Code node uses a three-phase approach: merge, subtract, quantize.
Phase 1 — Merge panel-busy intervals. The freeBusy API returns independent busy arrays per calendar. The node collects all of them into a single flat array and runs a standard interval-merge (sort by start, walk forward, extend the last interval’s end when overlap or adjacency exists). The result is the smallest set of intervals that covers every moment when at least one panelist is busy. This is the union, not the intersection: a slot is only valid when the entire panel is free.
Phase 2 — Subtract from candidate windows. For each candidate availability window, the node subtracts the merged panel-busy union by walking through both lists simultaneously — an interval-subtraction that produces the sub-intervals where the candidate is available AND the panel is free.
Phase 3 — Quantize and rank. The remaining free sub-intervals are quantized into 60-minute blocks aligned to :00 or :30 boundaries (to produce calendar-friendly times). Blocks straddling noon are excluded — slots starting within 30 minutes of noon ET have a measurably higher no-show rate for panel calls because lunch conflicts are unpredictable. The surviving blocks are then ranked by a scoring function: earlier in the day earns a lower penalty (5 points per hour past 9am), lighter recruiter load on that day earns fewer deductions (3 points per existing recruiter busy block), and proximity to today gets a small bonus (the first 10 candidate slots in the window get +10). The top 3 by score are surfaced to the recruiter.
Tie-break rationale: morning slots are preferred because scheduling research and practitioner consensus from recruiting operations teams at high-volume companies consistently shows higher same-day completion rates when panel calls are booked before noon. The recruiter-load heuristic gives the recruiter a lighter-schedule day for the interview so they have capacity for an immediate debrief call — the most time-sensitive follow-up in a competitive interview process.
Timezone handling: the freeBusy query issues RFC3339 timestamps with explicit offsets (America/New_York standard offset applied). The slot-ranking function applies the same static offset for local-hour computation. This is a deliberate simplification: DST transitions affect ET slots twice per year. In production, replace the static TZ_OFFSET_MS constant in the Code node with a DST-aware library call (e.g., Luxon’s DateTime.fromISO(iso, { zone: 'America/New_York' })). The _README.md flags this as the first item to address before relying on the flow through a March or November DST boundary.
Cost reality
Per 100 interview scheduling requests resolved:
Google Calendar API — the freeBusy endpoint is free under Google’s Calendar API quotas (1,000 queries per 100 seconds per user; 10,000 per day per project on the default quota). A 5-panelist interview uses one freeBusy call with 6 calendar IDs. 100 interviews = 100 API calls, well within quota on any plan.
n8n executions — each webhook delivery is one execution. n8n Cloud Starter at $20/month covers 5,000 executions/month; the backstop cron adds 20 executions/month (one per weekday). Teams running more than 5,000 scheduling events per month need the Pro tier ($50/month) or self-hosted. Self-hosted n8n has no per-execution cost.
Greenhouse API — the backstop calls Greenhouse Harvest at most once per cron run, returning up to 50 records per call. Greenhouse’s default rate limit is 50 requests per second; the backstop uses a fraction of that.
Recruiter time saved — the industry estimate for manual multi-panelist scheduling coordination is 20–45 minutes per interview loop (availability email, response wait, calendar check, slot proposal, confirmation). The flow reduces that to the time required to read a Slack message and click a Greenhouse link to confirm: approximately 2–3 minutes per loop. At 20 interviews per recruiter per week, that is 6–14 hours per week of coordination overhead eliminated. At 40 interviews per week it doubles.
Setup cost — 1–2 hours for the flow itself. The candidate availability step (replacing the stub with a real Calendly or Typeform integration) adds 30–60 minutes depending on which tool you use.
Failure modes
Timezone offset bugs around DST boundaries.Guard: the Code node uses a static -5 hour offset for America/New_York. This is correct for Eastern Standard Time (November through March) but is off by one hour during Eastern Daylight Time (March through November). If your team schedules interviews year-round, replace the TZ_OFFSET_MS constant in Resolve Conflicts — Intersect + Rank Slots with Luxon’s DateTime.fromMillis(ms, { zone: 'America/New_York' }).hour before going to production. The mismatch would cause the noon-buffer exclusion to fire at the wrong hour and the hourly ranking penalty to be off by 60 minutes — not catastrophic, but visible in slot quality.
Double-booking when a panelist’s calendar is inaccessible.Guard: if a panelist’s Google Calendar returns an error in the freeBusy response (e.g., notFound, calendar restricted, OAuth token expired), the Code node logs the error and treats that panelist as free — it does not halt. This is the safe-fail direction for the flow’s purpose (a proposed slot is still better than no proposal), but it means the Slack message could propose a slot that panelist is actually busy for. The Slack notification includes the full allCalendarIds list; the recruiter can spot which email triggered a freeBusy error by checking the n8n execution log. Fix: rotate the OAuth token or re-grant calendar access, then re-run the application through the resolver.
Webhook delivery failure (missed stage-change event).Guard: the daily backstop cron at 08:00 ET sweeps Greenhouse for interviews created more than 48 hours ago that are still unscheduled (no confirmed start.date_time) and replays them. Because the Harvest scheduled_interviews endpoint exposes no status query parameter, the sweep fetches everything created before the cutoff and applies the unscheduled filter client-side in a Code node. The 48-hour threshold is a deliberate lag — it avoids re-processing interviews that were just created and whose webhook is still in-flight. Tune the threshold down to 24 hours if your team SLA requires same-day proposals.
Stale OAuth2 token invalidating the freeBusy call.Guard: n8n’s OAuth2 credential handler refreshes access tokens automatically before each request when a refresh token is present. If the refresh token itself expires or is revoked (Google revokes refresh tokens after 6 months of inactivity on unverified apps), the freeBusy node will throw a 401. The execution will fail visibly in n8n Executions with an authentication error. Set up n8n’s error workflow (Settings → Error Workflow) to post a Slack alert when any execution fails so a credential rotation doesn’t silently block scheduling for hours.
No common availability in the 14-day window.Guard: the Slots Found? IF node routes to Slack — Escalate No-Availability with the application ID and recruiter email. The Slack message flags the case for manual coordinator intervention. If this path fires frequently, extend the freeBusy query window from 14 days to 21 days in the Google Calendar — freeBusy Query node’s timeMax expression. For roles with distributed panels across 3+ time zones, increasing the slot duration from 60 minutes to 90 minutes (change SLOT_DURATION_MINUTES in the Code node) sometimes reduces no-availability outcomes by requiring fewer back-to-back availability coincidences.
vs alternatives
vs GoodTime / ModernLoop
GoodTime and ModernLoop are purpose-built interview scheduling platforms with ATS-native integrations, interviewer preference training, interviewer load balancing across the team, and candidate-facing self-scheduling portals. GoodTime’s pricing is not publicly listed; enterprise contracts typically start in the $15,000–$40,000/year range (estimate based on G2 reviews and Vendr marketplace data). ModernLoop is similar in scope and pricing tier.
Pick GoodTime or ModernLoop if: you run more than 100 panel interviews per week, you need interviewer load balancing across a panel pool (not just availability checking), or your candidates expect a white-labeled self-scheduling experience. The n8n flow does not do any of those things.
Pick the n8n flow if: your volume is under 50 panel interviews per week, you already have n8n running for other workflows, you want the scheduling logic in your own repo and audit log, or the $15k+ platform cost is not yet justified by your hiring pace.
vs manual coordinator
A dedicated recruiting coordinator scheduling interviews manually can match the proposal quality of this flow — they have context the algorithm doesn’t (candidate preferences from the phone screen, panelist relationship preferences, upcoming out-of-office). The cost is the 20–45 minutes per loop and the synchronous dependency on the coordinator’s working hours. The flow runs at 3am; a coordinator does not.
Pick manual coordination if: you have a dedicated coordinator with context about panelist preferences that isn’t captured in calendar data, or if you are scheduling fewer than 10 interviews per week where the setup cost of the flow doesn’t pay back.
vs Calendly Teams / Calendly for Recruiting
Calendly Teams allows candidates to self-schedule against a multi-person availability calendar. It handles the candidate-facing UX better than this flow (no form or email to collect availability — the candidate just picks from a live link). It does not integrate with Greenhouse’s stage-based workflow out of the box; you would need a Zapier or n8n trigger to fire on stage change and send the Calendly link.
Pick Calendly Teams if the candidate-facing self-scheduling experience is the priority and you don’t need the ranking/scoring output or the Slack-based recruiter confirmation step.
Tools: n8n (orchestration), Greenhouse (ATS webhook + Harvest API), Calendly (candidate availability — optional, replaces the stub node). Google Calendar freeBusy API and Slack are used directly via HTTP Request and Slack nodes respectively.
# Interview scheduling conflict resolver — n8n flow
This bundle automates the scheduling coordination loop for multi-person interview panels. A Greenhouse webhook fires when a candidate moves to a stage with interviews in `to_be_scheduled` status; the flow fetches free/busy data for every participant via the Google Calendar freeBusy API, intersects those windows against candidate availability, ranks the resulting open slots by a set of tie-break rules, and posts the top 3 proposed times to a Slack channel for the recruiter to confirm and book. A daily backstop cron sweeps for applications that have been stuck unscheduled for more than 48 hours and replays them through the same path.
## Import
1. In your n8n instance open **Workflows → Import from File** and select `interview-scheduling-resolver-n8n.json`.
2. Open **Settings** on the imported workflow and confirm:
- `Execution Order` is set to `v1`
- `Timezone` is `America/New_York` (or your team's primary timezone — change this and update the freeBusy query node's `timeMin`/`timeMax` expressions to match)
3. Set the environment variable `GREENHOUSE_WEBHOOK_SECRET` in your n8n instance (Settings → Environment Variables or `.env` file for self-hosted). This is the secret string you configure when registering the webhook in Greenhouse Dev Center. The signature-verification node will throw and halt on every request if this variable is absent.
4. Wire the four credentials described below.
5. Complete the first-run verification before activating the Greenhouse webhook trigger.
## Credentials
### `PLACEHOLDER_GOOGLE_CAL_CRED_ID` — Google Calendar OAuth2
Used by `Google Calendar — freeBusy Query`.
1. Go to [Google Cloud Console](https://console.cloud.google.com) → APIs & Services → Enabled APIs → Enable **Google Calendar API**.
2. Create an OAuth 2.0 client ID (Desktop app or Web application, depending on your n8n setup).
3. In n8n, add a **Google Calendar OAuth2 API** credential. Paste the Client ID and Client Secret from GCP; complete the OAuth consent flow.
4. Required scope: `https://www.googleapis.com/auth/calendar.readonly`. The flow reads free/busy data only — it does not create or modify calendar events.
5. The OAuth token must be authorized for each panel member's Google Workspace account if they are on separate accounts. For Workspace-managed organizations, use a service account with domain-wide delegation instead, and grant it the same readonly Calendar scope across the domain.
### `PLACEHOLDER_GREENHOUSE_CRED_ID` — Greenhouse Harvest API
Used by `Greenhouse — List Stale Unscheduled Interviews` (the daily backstop path).
1. In Greenhouse, go to **Configure → Dev Center → API Credential Management** and create a new Harvest API key.
2. Grant scopes: `Scheduled Interviews` (read) and `Applications` (read). No write scope is needed; the flow does not modify Greenhouse records.
3. In n8n, add an **HTTP Header Auth** credential:
- Header name: `Authorization`
- Value: `Basic ` + base64 encoding of `your_api_key:` (note the trailing colon — Greenhouse uses the key as the username with a blank password)
4. Greenhouse Harvest API v1 and v2 are scheduled for deprecation on 2026-08-31. After that date, migrate the backstop node to the v3 endpoint.
### `PLACEHOLDER_GREENHOUSE_WEBHOOK` — Greenhouse webhook configuration (not a credential in n8n)
The webhook trigger is not a named n8n credential but requires a configuration step in Greenhouse:
1. Go to **Configure → Dev Center → Web Hooks → Add Web Hook**.
2. Set the endpoint URL to your n8n webhook URL: `https://<your-n8n-host>/webhook/interview-scheduling-resolver`.
3. Subscribe to the `candidate_stage_change` event (the flow filters for interviews with `to_be_scheduled` status inside the signature-verification node).
4. Copy the webhook secret that Greenhouse generates and set it as the `GREENHOUSE_WEBHOOK_SECRET` environment variable in n8n.
### `PLACEHOLDER_SLACK_CRED_ID` — Slack bot token
Used by `Slack — Notify Recruiter with Proposed Slots` and `Slack — Escalate No-Availability`.
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and create a new app (from scratch).
2. Add the `chat:write` bot scope under **OAuth & Permissions → Scopes**.
3. Install the app to your workspace and copy the `xoxb-...` Bot User OAuth Token.
4. In n8n, add an **HTTP Header Auth** credential:
- Header name: `Authorization`
- Value: `Bearer xoxb-<your-token>`
5. Invite the bot user to `#scheduling-queue` (or whatever channel you configure in the Slack nodes).
## Candidate availability intake
The `Candidate Availability Intake` node ships as a stub that returns Mon–Fri 9am–6pm ET for the full 14-day window. This means the flow will find all panel-free slots in the window on first run — useful for verifying the algorithm is working — but it does not reflect real candidate constraints.
To wire real candidate availability:
- **Option A — Calendly**: use a Calendly webhook trigger or poll the `/scheduled_events` endpoint after the candidate books their preferred windows. Replace the stub node with an HTTP Request node that reads the booked windows.
- **Option B — Typeform / Tally**: collect availability via a form, store responses in Airtable or Google Sheets, and replace the stub node with an Airtable or Sheets read node keyed on `applicationId`.
- **Option C — embedded availability link**: send the candidate a Calendly or Doodle availability-sharing link via a separate notification email node; the stub makes the flow functional while you build this step.
## Conflict-resolution algorithm (summary)
The `Resolve Conflicts — Intersect + Rank Slots` Code node:
1. Collects all participant busy intervals from the Google Calendar freeBusy response.
2. Merges overlapping busy intervals into a single union per union-find, so a 60-minute block counts as unavailable if any one panelist is busy during any part of it.
3. Subtracts the merged panel-busy union from the candidate's stated available windows, leaving only the sub-intervals where everyone is free.
4. Quantizes the remaining free sub-intervals into 60-minute blocks aligned to :00 or :30 boundaries.
5. Excludes blocks that straddle noon (slots starting within 30 minutes of noon in ET — lunch collision rate is high).
6. Ranks the remaining blocks by: (a) earlier in the day (lower penalty per hour past 9am), (b) lighter recruiter calendar load on that day (fewer existing busy intervals), (c) proximity to today (the first 10 candidate slots get a +10 boost).
7. Returns the top 3 ranked slots, or sets `resolved: false` if no common window exists.
## First-run verification
Activate the workflow only after all five paths below pass. Use n8n's **Test Workflow** or manually trigger each node in isolation.
### 1. Signature verification — valid payload
Send a test POST to your n8n webhook URL with the header `Signature: sha256 <correct-hmac>` (Greenhouse's format is the algorithm, a single space, then the hex HMAC — not `sha256=<hmac>`) computed against the exact raw request body and your `GREENHOUSE_WEBHOOK_SECRET`. The Webhook node uses `rawBody: true` so the HMAC is verified against the original bytes Greenhouse sent. Expected: the `Verify Signature + Extract Participants` node passes and outputs a normalized JSON item. Confirm `applicationId`, `recruiterEmail`, and `interviewerEmails` are populated.
### 2. Signature verification — invalid payload
Send the same POST with a deliberately wrong signature value. Expected: the node throws an error visible in n8n Executions; the flow halts before reaching any downstream node.
### 3. Slots-found path
With valid participant emails configured and the Google Calendar credential authorized, send a well-formed payload for a real application where you know the recruiter and at least one interviewer have some free time in the next 14 days. Expected: `Slots Found?` routes to the true branch; `Slack — Notify Recruiter with Proposed Slots` posts a message to `#scheduling-queue` with at least one proposed slot, the application ID, and the panel member list.
### 4. No-availability path
Temporarily edit the `Candidate Availability Intake` node's `windows` array to return zero windows (empty array). Re-run. Expected: `resolved: false` from the resolver; `Slots Found?` routes to the false branch; `Slack — Escalate No-Availability` posts the escalation message.
### 5. Backstop cron path
Trigger `Daily Backstop Cron — 8am ET weekdays` manually. Expected: `Greenhouse — List Stale Unscheduled Interviews` calls the Harvest API with `created_before=<48h-ago>` (the `scheduled_interviews` endpoint has no `status` query param, so this fetches every interview created more than 48 hours ago); `Filter Stale Unscheduled (client-side)` then drops any interview that already has a confirmed `start.date_time` or is `complete`/`awaiting_feedback`, keeping only the genuinely unscheduled ones; `Split Into Items` outputs one item per remaining interview. If Greenhouse returns an empty array, or every interview is already scheduled, the run ends cleanly with no items — that is correct behavior.
Only after all five paths pass should you activate the Greenhouse webhook trigger and the cron node.
{
"name": "Interview scheduling conflict resolver",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "interview-scheduling-resolver",
"responseMode": "responseNode",
"options": {
"rawBody": true
}
},
"id": "3a3a3a3a-0003-0000-0000-000000000001",
"name": "Greenhouse Webhook — interview_requested",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [200, 300],
"webhookId": "interview-scheduling-resolver-webhook",
"notesInFlow": true,
"notes": "Receives POST events from the Greenhouse recruiting webhook. Configure the Greenhouse webhook under Settings → Dev Center → Web Hooks to send candidate_stage_change events (status: to_be_scheduled). Greenhouse signs each request with HMAC-SHA256; signature is verified in the next node."
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={ \"received\": true, \"applicationId\": \"{{ $json.body.payload.application.id }}\" }",
"options": {
"responseCode": 202
}
},
"id": "3a3a3a3a-0003-0000-0000-000000000002",
"name": "Respond 202 Accepted",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [200, 480],
"notesInFlow": true,
"notes": "Acknowledge Greenhouse immediately with 202 so the webhook call never times out. The rest of the flow runs asynchronously."
},
{
"parameters": {
"jsCode": "// Verify Greenhouse webhook HMAC-SHA256 signature.\n// Greenhouse sends the signature in the header 'Signature' as:\n// sha256 <hex-digest> (algorithm, a SPACE, then the hex digest)\n// NOT 'sha256=<hex-digest>'. We split on the space and compare against the hash.\n// The HMAC must be computed over the EXACT raw request body bytes Greenhouse sent\n// (Unicode escaped as \\uXXXX), so the Webhook node is configured with rawBody: true.\n// The secret is set on the webhook in the Greenhouse Dev Center.\n\nconst crypto = require('crypto');\n\nconst secret = $env['GREENHOUSE_WEBHOOK_SECRET'] || '';\nconst headers = $input.first().json.headers || {};\nconst signatureHeader = (headers['signature'] || headers['Signature'] || '').trim();\n\n// With rawBody: true the Webhook node exposes the original bytes as a base64\n// binary property named 'data'. Decode those bytes for the HMAC so the digest\n// byte-matches Greenhouse's original payload. Fall back to the rawBody string if\n// present; only as a last resort re-stringify (which will not match Greenhouse's\n// Unicode escaping and is provided to avoid a hard crash, not for verification).\nlet rawBodyBuf = null;\nconst binary = $input.first().binary;\nif (binary?.data?.data) {\n rawBodyBuf = Buffer.from(binary.data.data, 'base64');\n} else if (typeof $input.first().json.rawBody === 'string') {\n rawBodyBuf = Buffer.from($input.first().json.rawBody, 'utf8');\n} else if (Buffer.isBuffer($input.first().json.rawBody)) {\n rawBodyBuf = $input.first().json.rawBody;\n} else {\n rawBodyBuf = Buffer.from(JSON.stringify($input.first().json.body || {}), 'utf8');\n}\n\nif (!secret) {\n throw new Error('GREENHOUSE_WEBHOOK_SECRET env var is not set. Cannot verify signature.');\n}\n\nif (!signatureHeader) {\n throw new Error('No Signature header found in webhook request. Rejecting.');\n}\n\n// Header is 'sha256 <hex>'. Take everything after the first space as the digest.\nconst spaceIdx = signatureHeader.indexOf(' ');\nconst receivedHex = (spaceIdx === -1 ? signatureHeader : signatureHeader.slice(spaceIdx + 1)).trim().toLowerCase();\n\nconst expectedHex = crypto\n .createHmac('sha256', secret)\n .update(rawBodyBuf)\n .digest('hex');\n\n// Constant-time compare. Both buffers must be the same length for timingSafeEqual,\n// so guard on length first (a length mismatch is itself a failed verification).\nconst receivedBuf = Buffer.from(receivedHex, 'hex');\nconst expectedBuf = Buffer.from(expectedHex, 'hex');\nif (receivedBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(receivedBuf, expectedBuf)) {\n throw new Error('Signature mismatch. Dropping webhook (computed HMAC did not match the Signature header).');\n}\n\n// Verified. Extract the fields we need for the rest of the flow.\nconst payload = $input.first().json.body?.payload || $input.first().json.body || {};\nconst application = payload.application || {};\nconst candidate = application.candidate || payload.candidate || {};\nconst job = application.jobs?.[0] || payload.job || {};\nconst currentStage = payload.current_stage || application.current_stage || {};\n\n// Collect interviewers from the current stage's interviews array.\n// Each interview has an interviewers[] array with email addresses.\nconst interviews = currentStage.interviews || [];\nconst interviewerEmails = [];\nfor (const interview of interviews) {\n for (const iv of (interview.interviewers || [])) {\n if (iv.email && !interviewerEmails.includes(iv.email)) {\n interviewerEmails.push(iv.email);\n }\n }\n}\n\n// Recruiter email comes from the organizer field of the application or the\n// assigned recruiter on the job.\nconst recruiterEmail = application.recruiter?.email ||\n payload.recruiter?.email ||\n job.recruiter?.email || '';\n\nreturn [{\n json: {\n applicationId: String(application.id || ''),\n candidateName: candidate.name || `${candidate.first_name || ''} ${candidate.last_name || ''}`.trim(),\n candidateEmail: (candidate.email_addresses || []).find(e => e.type === 'work')?.value ||\n (candidate.email_addresses || [])[0]?.value || candidate.email || '',\n jobId: String(job.id || ''),\n jobName: job.name || '',\n stageName: currentStage.name || '',\n recruiterEmail,\n interviewerEmails,\n allCalendarIds: [...new Set([recruiterEmail, ...interviewerEmails].filter(Boolean))],\n greenhouseApplicationId: String(application.id || ''),\n receivedAt: new Date().toISOString(),\n }\n}];"
},
"id": "3a3a3a3a-0003-0000-0000-000000000003",
"name": "Verify Signature + Extract Participants",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [420, 300],
"notesInFlow": true,
"notes": "Verifies the Greenhouse HMAC-SHA256 webhook signature to reject spoofed requests. Greenhouse sends the 'Signature' header as 'sha256 <hex-digest>' (algorithm, a space, then the hash) and signs the entire raw request body, so the Webhook node uses rawBody: true and this node hashes the original bytes and compares (constant-time) against the hash after the space. Extracts recruiter, interviewers, candidate contact, and job context. If GREENHOUSE_WEBHOOK_SECRET is unset or the signature mismatches, this node throws and halts the execution — the error is visible in n8n Executions."
},
{
"parameters": {
"method": "POST",
"url": "https://www.googleapis.com/calendar/v3/freeBusy",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleCalendarOAuth2Api",
"sendBody": true,
"contentType": "json",
"body": {
"timeMin": "={{ $now.plus({days: 1}).startOf('day').setZone('America/New_York').toISO() }}",
"timeMax": "={{ $now.plus({days: 14}).endOf('day').setZone('America/New_York').toISO() }}",
"timeZone": "America/New_York",
"items": "={{ $json.allCalendarIds.map(id => ({ id })) }}"
},
"options": {
"timeout": 10000,
"response": {
"response": {
"fullResponse": false,
"neverError": false
}
}
}
},
"id": "3a3a3a3a-0003-0000-0000-000000000004",
"name": "Google Calendar — freeBusy Query",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [640, 300],
"credentials": {
"googleCalendarOAuth2Api": {
"id": "PLACEHOLDER_GOOGLE_CAL_CRED_ID",
"name": "Google Calendar — OAuth2"
}
},
"notesInFlow": true,
"notes": "Calls the Google Calendar freeBusy endpoint for all participants (recruiter + every interviewer). Returns busy[] time windows for the next 14 business days. Requires calendar scope: https://www.googleapis.com/auth/calendar.readonly. If a calendar ID is not found or access is denied, the API returns an error per calendar; the next node handles that gracefully."
},
{
"parameters": {
"jsCode": "// Candidate availability intake stub.\n// In a real deployment this node reads from a Typeform / Calendly webhook response\n// or from a candidate-facing availability form whose results were stored in Airtable/Google Sheets.\n// For the initial run, the node returns a wide open window (the full 14-day range)\n// so the conflict-resolution logic can compute panel availability unconditionally.\n// Replace this node with a Typeform, Airtable, or Google Sheets read node once\n// you have a candidate-availability collection step wired.\n\nconst applicationId = $('Verify Signature + Extract Participants').first().json.applicationId;\nconst candidateName = $('Verify Signature + Extract Participants').first().json.candidateName;\n\n// Build a set of wide-open candidate windows:\n// Mon-Fri 9am-6pm ET for each day in the next 14 days.\nconst windows = [];\nconst tz = 'America/New_York';\nconst now = new Date();\nfor (let d = 1; d <= 14; d++) {\n const date = new Date(now);\n date.setDate(date.getDate() + d);\n const dow = date.getDay(); // 0=Sun, 6=Sat\n if (dow === 0 || dow === 6) continue;\n const yyyy = date.getFullYear();\n const mm = String(date.getMonth() + 1).padStart(2, '0');\n const dd = String(date.getDate()).padStart(2, '0');\n windows.push({\n start: `${yyyy}-${mm}-${dd}T09:00:00-05:00`,\n end: `${yyyy}-${mm}-${dd}T18:00:00-05:00`,\n });\n}\n\nreturn [{\n json: {\n applicationId,\n candidateName,\n candidateWindows: windows,\n source: 'stub-wide-open',\n }\n}];"
},
"id": "3a3a3a3a-0003-0000-0000-000000000005",
"name": "Candidate Availability Intake",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [640, 480],
"notesInFlow": true,
"notes": "Reads candidate availability windows. The stub returns Mon-Fri 9am-6pm ET for the full 14-day window. Replace with a Typeform or Calendly webhook node to ingest real candidate constraints once you have that step in your process."
},
{
"parameters": {
"jsCode": "// Conflict-resolution algorithm: free-window intersection + slot ranking.\n//\n// INPUTS:\n// $('Google Calendar — freeBusy Query').first().json → freeBusy API response\n// $('Candidate Availability Intake').first().json → candidateWindows[]\n// $('Verify Signature + Extract Participants').first().json → allCalendarIds, recruiterEmail\n//\n// ALGORITHM:\n// 1. For each panel member, collect their busy intervals from the freeBusy response.\n// 2. Build the candidate's free intervals from candidateWindows.\n// 3. Intersect all participant free windows to find slots when *everyone* is free.\n// 4. Quantize to 60-minute blocks (configurable via SLOT_DURATION_MINUTES below).\n// 5. Apply tie-break preference rules to rank the candidate slots:\n// a. Earlier in the day beats later (candidates respond better to morning slots).\n// b. Days with fewer total busy intervals for the recruiter are preferred\n// (lighter-schedule days leave room for immediate debrief).\n// c. Never propose slots straddling noon (11:30–12:30 ET) — lunch collision.\n// 6. Return the top 3 ranked slots.\n\nconst SLOT_DURATION_MINUTES = 60;\nconst MAX_PROPOSALS = 3;\nconst NOON_BUFFER_MINUTES = 30; // exclude slots starting within 30 min of noon\nconst TZ_OFFSET_MS = -5 * 60 * 60 * 1000; // America/New_York standard; DST not applied here — use a DST-aware library in production\n\n// Parse ISO string to ms timestamp.\nfunction toMs(iso) { return new Date(iso).getTime(); }\nfunction toISO(ms) { return new Date(ms).toISOString(); }\n\n// Merge overlapping/adjacent intervals (sorted by start).\nfunction mergeIntervals(intervals) {\n if (!intervals.length) return [];\n const sorted = [...intervals].sort((a, b) => a.start - b.start);\n const merged = [sorted[0]];\n for (let i = 1; i < sorted.length; i++) {\n const last = merged[merged.length - 1];\n if (sorted[i].start <= last.end) {\n last.end = Math.max(last.end, sorted[i].end);\n } else {\n merged.push({ ...sorted[i] });\n }\n }\n return merged;\n}\n\n// Subtract busy intervals from a free window, returning the remaining free sub-intervals.\nfunction subtractBusy(freeWindow, busyIntervals) {\n let remaining = [{ ...freeWindow }];\n for (const busy of busyIntervals) {\n const next = [];\n for (const free of remaining) {\n if (busy.end <= free.start || busy.start >= free.end) {\n next.push(free); // no overlap\n } else {\n if (busy.start > free.start) next.push({ start: free.start, end: busy.start });\n if (busy.end < free.end) next.push({ start: busy.end, end: free.end });\n }\n }\n remaining = next;\n }\n return remaining;\n}\n\n// --- Build per-participant busy intervals ---\nconst freeBusyResponse = $('Google Calendar — freeBusy Query').first().json;\nconst { allCalendarIds, recruiterEmail } = $('Verify Signature + Extract Participants').first().json;\n\nconst participantBusy = {};\nfor (const calId of allCalendarIds) {\n const calData = freeBusyResponse.calendars?.[calId];\n if (!calData) {\n participantBusy[calId] = []; // calendar not found or no access — treat as free\n continue;\n }\n if (calData.errors?.length) {\n // Calendar returned an error (e.g. notFound, restricted).\n // Log it and treat as free so we don't block on one unavailable calendar.\n console.warn(`freeBusy error for ${calId}:`, JSON.stringify(calData.errors));\n participantBusy[calId] = [];\n continue;\n }\n participantBusy[calId] = (calData.busy || []).map(b => ({\n start: toMs(b.start),\n end: toMs(b.end),\n }));\n}\n\n// Merge all participant busy intervals into a single union (the panel is blocked\n// if ANY member is busy).\nconst allBusy = Object.values(participantBusy).flat();\nconst mergedPanelBusy = mergeIntervals(allBusy);\n\n// Count how many busy intervals the recruiter has per calendar-day (tie-break input).\nconst recruiterBusyByDay = {};\nfor (const interval of (participantBusy[recruiterEmail] || [])) {\n const dayKey = new Date(interval.start).toISOString().slice(0, 10);\n recruiterBusyByDay[dayKey] = (recruiterBusyByDay[dayKey] || 0) + 1;\n}\n\n// --- Candidate free windows ---\nconst { candidateWindows } = $('Candidate Availability Intake').first().json;\nconst candidateFreeIntervals = candidateWindows.map(w => ({\n start: toMs(w.start),\n end: toMs(w.end),\n}));\n\n// --- Compute free intersection ---\n// For each candidate window, subtract the merged panel busy intervals.\nconst freeSlots = []; // { start: ms, end: ms }\nfor (const window of candidateFreeIntervals) {\n const overlappingBusy = mergedPanelBusy.filter(b => b.end > window.start && b.start < window.end);\n const freeInWindow = subtractBusy(window, overlappingBusy);\n freeSlots.push(...freeInWindow);\n}\n\n// --- Quantize to SLOT_DURATION_MINUTES blocks ---\nconst slotMs = SLOT_DURATION_MINUTES * 60 * 1000;\nconst candidates = [];\nfor (const free of freeSlots) {\n let t = free.start;\n // Round up to next 30-min boundary to align to :00 or :30.\n const thirtyMin = 30 * 60 * 1000;\n if (t % thirtyMin !== 0) {\n t = Math.ceil(t / thirtyMin) * thirtyMin;\n }\n while (t + slotMs <= free.end) {\n const slotStart = new Date(t);\n const hour = slotStart.getUTCHours() + (TZ_OFFSET_MS / (60 * 60 * 1000));\n const minute = slotStart.getUTCMinutes();\n // Exclude slots straddling noon (11:30-12:00 start = ends at 12:30-13:00).\n const hourFraction = hour + minute / 60;\n if (!(hourFraction >= 11.5 - SLOT_DURATION_MINUTES / 60 && hourFraction < 12)) {\n candidates.push({ start: t, end: t + slotMs });\n }\n t += slotMs;\n }\n}\n\n// --- Rank slots ---\n// Score = base 100\n// - Subtract (localHour - 9) * 5 → earlier is better (9am = 0 penalty, 5pm = 40 penalty)\n// - Subtract recruiterBusyByDay[day] * 3 → lighter recruiter day preferred\n// - Add 10 if slot is in the first 5 business days (urgency preference)\nconst ranked = candidates.map(slot => {\n const d = new Date(slot.start);\n const dayKey = d.toISOString().slice(0, 10);\n const localHour = d.getUTCHours() + (TZ_OFFSET_MS / (60 * 60 * 1000));\n const recruiterLoad = recruiterBusyByDay[dayKey] || 0;\n const score = 100\n - (localHour - 9) * 5\n - recruiterLoad * 3\n + (candidates.indexOf(slot) < 10 ? 10 : 0); // first 10 candidate slots are in the near-term\n return { ...slot, score, dayKey, localHour };\n}).sort((a, b) => b.score - a.score);\n\nconst top = ranked.slice(0, MAX_PROPOSALS);\n\nif (top.length === 0) {\n return [{\n json: {\n resolved: false,\n reason: 'no_common_availability',\n applicationId: $('Verify Signature + Extract Participants').first().json.applicationId,\n recruiterEmail,\n message: 'No overlapping free windows found in the 14-day lookahead. Routing to manual coordinator.',\n }\n }];\n}\n\nreturn [{\n json: {\n resolved: true,\n applicationId: $('Verify Signature + Extract Participants').first().json.applicationId,\n candidateName: $('Candidate Availability Intake').first().json.candidateName,\n recruiterEmail,\n allCalendarIds,\n proposedSlots: top.map(s => ({\n start: toISO(s.start),\n end: toISO(s.end),\n score: s.score,\n })),\n slotsEvaluated: candidates.length,\n panelBusyIntervalCount: mergedPanelBusy.length,\n }\n}];"
},
"id": "3a3a3a3a-0003-0000-0000-000000000006",
"name": "Resolve Conflicts — Intersect + Rank Slots",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [860, 300],
"notesInFlow": true,
"notes": "Core algorithm. Merges all panel members' busy intervals into one union, subtracts them from the candidate's available windows, quantizes to 60-min blocks, then ranks by: (1) earlier in the day, (2) lighter recruiter schedule on that day, (3) proximity to today. Returns the top 3 slots. If no overlap exists, sets resolved: false and routes to the no-availability branch."
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.resolved }}",
"value2": true
}
]
}
},
"id": "3a3a3a3a-0003-0000-0000-000000000007",
"name": "Slots Found?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1080, 300],
"notesInFlow": true,
"notes": "Routes to the Slack notification if the resolver found at least one common window, or to the manual-escalation path if no overlap was found in the 14-day window."
},
{
"parameters": {
"authentication": "predefinedCredentialType",
"nodeCredentialType": "slackApi",
"resource": "message",
"operation": "post",
"channel": "#scheduling-queue",
"text": "=:calendar: *Interview slot proposals ready* — {{ $json.candidateName }}\n\n*Job:* {{ $('Verify Signature + Extract Participants').first().json.jobName }}\n*Stage:* {{ $('Verify Signature + Extract Participants').first().json.stageName }}\n*Application ID:* {{ $json.applicationId }}\n\n*Top {{ $json.proposedSlots.length }} proposed slots (ET):*\n{{ $json.proposedSlots.map((s, i) => `${i+1}. ${s.start} → ${s.end} (score: ${s.score})`).join('\\n') }}\n\n*Panel:* {{ $json.allCalendarIds.join(', ') }}\n*Slots evaluated:* {{ $json.slotsEvaluated }} | *Panel busy blocks merged:* {{ $json.panelBusyIntervalCount }}\n\nPick a slot and book via Greenhouse Scheduled Interviews:\nhttps://app.greenhouse.io/people?application_id={{ $json.applicationId }}"
},
"id": "3a3a3a3a-0003-0000-0000-000000000008",
"name": "Slack — Notify Recruiter with Proposed Slots",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [1300, 220],
"credentials": {
"slackApi": {
"id": "PLACEHOLDER_SLACK_CRED_ID",
"name": "Slack — Bot Token"
}
},
"notesInFlow": true,
"notes": "Posts the top 3 proposed slots to #scheduling-queue with the scoring rationale, panel member list, and a deep link to the Greenhouse application. The recruiter picks a slot and creates the scheduled interview in Greenhouse manually (or via the POST /v2/scheduled_interviews endpoint if you wire an additional node). The flow does NOT auto-book to preserve the recruiter's agency over the final slot choice."
},
{
"parameters": {
"authentication": "predefinedCredentialType",
"nodeCredentialType": "slackApi",
"resource": "message",
"operation": "post",
"channel": "#scheduling-queue",
"text": "=:warning: *No common availability found* — {{ $('Verify Signature + Extract Participants').first().json.candidateName || $json.candidateName }}\n\n*Application ID:* {{ $json.applicationId }}\n*Reason:* {{ $json.reason }}\n*Message:* {{ $json.message }}\n\nPanel: {{ ($json.recruiterEmail ? [$json.recruiterEmail] : []).concat([]).join(', ') }}\n\nManual scheduling required. Open application in Greenhouse:\nhttps://app.greenhouse.io/people?application_id={{ $json.applicationId }}"
},
"id": "3a3a3a3a-0003-0000-0000-000000000009",
"name": "Slack — Escalate No-Availability",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [1300, 400],
"credentials": {
"slackApi": {
"id": "PLACEHOLDER_SLACK_CRED_ID",
"name": "Slack — Bot Token"
}
},
"notesInFlow": true,
"notes": "When the resolver finds no common window in 14 days, posts an escalation to #scheduling-queue so a coordinator can reach out directly. Includes the application ID and recruiter email for context. Consider extending the lookahead window (in the freeBusy query node) if this escalation fires frequently."
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 8 * * 1-5"
}
]
},
"options": {
"timezone": "America/New_York"
}
},
"id": "3a3a3a3a-0003-0000-0000-000000000010",
"name": "Daily Backstop Cron — 8am ET weekdays",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [200, 680],
"notesInFlow": true,
"notes": "Fires at 08:00 America/New_York on weekdays. Sweeps Greenhouse for interviews that are still unscheduled (no confirmed start time) more than 48 hours after creation and replays them through the resolver. This catches webhook delivery failures or cases where the stage-change event was missed. The backstop calls the Greenhouse Harvest API GET /v1/scheduled_interviews filtered by created_before (48h ago), then filters for unscheduled/stale interviews client-side in a Code node (the endpoint has no status query param)."
},
{
"parameters": {
"method": "GET",
"url": "=https://harvest.greenhouse.io/v1/scheduled_interviews?created_before={{ $now.minus({hours: 48}).toISO() }}&per_page=50",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpHeaderAuth",
"options": {
"timeout": 15000,
"response": {
"response": {
"fullResponse": false,
"neverError": true
}
}
}
},
"id": "3a3a3a3a-0003-0000-0000-000000000011",
"name": "Greenhouse — List Stale Unscheduled Interviews",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [420, 680],
"credentials": {
"httpHeaderAuth": {
"id": "PLACEHOLDER_GREENHOUSE_CRED_ID",
"name": "Greenhouse Harvest API — Basic Auth"
}
},
"notesInFlow": true,
"notes": "Queries Greenhouse Harvest API for scheduled_interviews created more than 48 hours ago. The scheduled_interviews endpoint has NO 'status' query param (valid filters: created_before/after, starts_before/after, ends_before/after, actionable, per_page, page), so the sweep filters for unscheduled/stale interviews client-side in the next Code node. Credentials: PLACEHOLDER_GREENHOUSE_CRED_ID — set up a Greenhouse API key (Harvest scope) and store it as HTTP Header Auth (header name: Authorization, value: Basic base64(api_key:))."
},
{
"parameters": {
"jsCode": "// Client-side filter for stale UNSCHEDULED interviews.\n// The Harvest scheduled_interviews endpoint has no 'status' query param, so the\n// HTTP node fetched ALL interviews created >48h ago. Here we keep only the ones\n// that still have no confirmed time slot — i.e. the ones the resolver should chase.\n//\n// A scheduled_interviews record carries:\n// status: 'scheduled' | 'awaiting_feedback' | 'complete'\n// start: { date_time: <ISO> } | { date: <YYYY-MM-DD> } | null when unscheduled\n// interviewers[]: { response_status: 'needs_action'|'declined'|'tentative'|'accepted', ... }\n//\n// 'Unscheduled / stale' = no concrete start.date_time has been set yet. We also\n// exclude anything already complete or awaiting feedback (those are past the\n// scheduling stage). Records the API returns as a bare array land in $json.body\n// (HTTP node: fullResponse:false). With neverError:true, an error response would\n// not be an array — guard for that and emit nothing rather than crash.\n\nconst raw = $input.first().json.body;\nconst interviews = Array.isArray(raw) ? raw : (Array.isArray(raw?.body) ? raw.body : []);\n\nconst stale = interviews.filter((iv) => {\n if (!iv || typeof iv !== 'object') return false;\n // Already scheduled with a concrete time → not our problem.\n const hasConfirmedTime = !!(iv.start && iv.start.date_time);\n if (hasConfirmedTime) return false;\n // Past the scheduling stage entirely.\n if (iv.status === 'complete' || iv.status === 'awaiting_feedback') return false;\n return true;\n});\n\n// Re-wrap as a single item carrying the filtered array under 'body' so the\n// downstream Split Out node (fieldToSplitOut: 'body') can fan it back out.\nreturn [{ json: { body: stale, fetchedCount: interviews.length, staleCount: stale.length } }];"
},
"id": "3a3a3a3a-0003-0000-0000-000000000013",
"name": "Filter Stale Unscheduled (client-side)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [560, 680],
"notesInFlow": true,
"notes": "Filters the full scheduled_interviews list down to interviews that still have no confirmed start.date_time (and are not complete/awaiting_feedback). This replaces the removed 'status=to_be_scheduled' query param, which the Harvest endpoint silently ignores. Emits a single item whose 'body' is the filtered array for the Split Out node to fan back out."
},
{
"parameters": {
"fieldToSplitOut": "body",
"options": {
"destinationFieldName": "interview"
}
},
"id": "3a3a3a3a-0003-0000-0000-000000000012",
"name": "Split Into Items",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [760, 680],
"notesInFlow": true,
"notes": "Splits the Greenhouse response array into individual items so each stale interview is processed independently through the resolver chain."
}
],
"connections": {
"Greenhouse Webhook — interview_requested": {
"main": [
[
{ "node": "Respond 202 Accepted", "type": "main", "index": 0 },
{ "node": "Verify Signature + Extract Participants", "type": "main", "index": 0 }
]
]
},
"Verify Signature + Extract Participants": {
"main": [
[
{ "node": "Google Calendar — freeBusy Query", "type": "main", "index": 0 },
{ "node": "Candidate Availability Intake", "type": "main", "index": 0 }
]
]
},
"Google Calendar — freeBusy Query": {
"main": [
[
{ "node": "Resolve Conflicts — Intersect + Rank Slots", "type": "main", "index": 0 }
]
]
},
"Candidate Availability Intake": {
"main": [
[
{ "node": "Resolve Conflicts — Intersect + Rank Slots", "type": "main", "index": 1 }
]
]
},
"Resolve Conflicts — Intersect + Rank Slots": {
"main": [
[
{ "node": "Slots Found?", "type": "main", "index": 0 }
]
]
},
"Slots Found?": {
"main": [
[
{ "node": "Slack — Notify Recruiter with Proposed Slots", "type": "main", "index": 0 }
],
[
{ "node": "Slack — Escalate No-Availability", "type": "main", "index": 0 }
]
]
},
"Daily Backstop Cron — 8am ET weekdays": {
"main": [
[
{ "node": "Greenhouse — List Stale Unscheduled Interviews", "type": "main", "index": 0 }
]
]
},
"Greenhouse — List Stale Unscheduled Interviews": {
"main": [
[
{ "node": "Filter Stale Unscheduled (client-side)", "type": "main", "index": 0 }
]
]
},
"Filter Stale Unscheduled (client-side)": {
"main": [
[
{ "node": "Split Into Items", "type": "main", "index": 0 }
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1",
"timezone": "America/New_York",
"saveManualExecutions": true,
"callerPolicy": "workflowsFromSameOwner",
"errorWorkflow": ""
},
"staticData": null,
"tags": [
{ "createdAt": "2026-05-23T00:00:00.000Z", "updatedAt": "2026-05-23T00:00:00.000Z", "id": "recruiting", "name": "recruiting" },
{ "createdAt": "2026-05-23T00:00:00.000Z", "updatedAt": "2026-05-23T00:00:00.000Z", "id": "scheduling", "name": "scheduling" }
],
"triggerCount": 2,
"updatedAt": "2026-05-23T00:00:00.000Z",
"versionId": "1"
}