Ein n8n-Flow, der das Koordinationsproblem zwischen mehreren Beteiligten löst, das zwischen „Kandidat wechselt in die Interview-Stufe” und „Kalendereinladung wird versendet” entsteht. Der Flow empfängt einen Greenhouse-Webhook bei einem Stufenwechsel, fragt die freeBusy-API von Google Calendar für jeden Panelisten und den Recruiter gleichzeitig ab, intersektiert diese belegten Fenster mit der angegebenen Verfügbarkeit des Kandidaten, ordnet die resultierenden freien Zeitfenster anhand von Tiebreaker-Regeln und veröffentlicht die 3 besten vorgeschlagenen Zeiten in einem Slack-Kanal, damit der Recruiter bestätigen und buchen kann. Ein täglicher Backup-Cron durchsucht Interviews, die seit mehr als 48 Stunden ungeplant geblieben sind, und verarbeitet sie erneut durch denselben Pfad.
Das Artefakt-Bundle befindet sich unter apps/web/public/artifacts/interview-scheduling-resolver-n8n/ und enthält interview-scheduling-resolver-n8n.json (den vollständigen n8n-Flow-Export) und _README.md (Import-Schritte, Credential-Setup pro Credential, Verifizierungsverfahren für den ersten Lauf).
Wann verwenden
Sie verwenden Greenhouse als ATS und Interviews umfassen regelmäßig 3 oder mehr Panelisten, deren Kalender über zwei oder mehr Zeitzonen verteilt sind.
Der Recruiting Coordinator verbringt 20–45 Minuten pro Stelle und Loop mit der Scheduling-Koordination — Verfügbarkeits-E-Mails senden, auf Antworten warten, vier Kalender manuell prüfen, einen Slot vorschlagen, einen Konflikt entdecken.
Sie möchten ein Entscheidungsprotokoll für jeden vorgeschlagenen Slot: welche Fenster wurden ausgewertet, wie viele belegte Panel-Blöcke wurden zusammengeführt, wie war der Ranking-Score. Die Slack-Nachricht, die der Flow sendet, enthält diese Daten, damit der Recruiter nachvollziehen kann, warum jeder Slot vorgeschlagen wurde.
Sie betreiben bereits n8n (self-hosted oder Cloud) und verfügen über eine Google Workspace-Umgebung, in der die Kalender der Panelisten über OAuth2 oder ein Service-Account mit domänenweiter Delegierung zugänglich sind.
Wann NICHT verwenden
Masseneinstellungen oder hochfrequente Hiring-Events. Bei mehr als 50 Panel-Interviews pro Tag — Recruiting-Events, Hochschulprogramme, Masseneinstellungen — erzeugt das freeBusy-per-Trigger-Modell ein erhebliches API-Aufrufvolumen. GoodTime oder ModernLoop sind für dieses Verkehrsmuster ausgelegt; der n8n-Flow nicht.
Andere ATS-Plattformen als Greenhouse ohne Stage-Change-Webhook. Der Trigger basiert auf dem Empfang eines signierten Greenhouse-Webhooks. Ihn durch ein Ashby- oder Lever-Äquivalent zu ersetzen ist unkompliziert (der Trigger-Node wird ausgetauscht), aber nur-Polling-ATS-Plattformen führen eine Mindestlatenz von 5 Minuten ein, was den Use-Case „innerhalb einer Stunde planen” bricht.
Automatisches Buchen ohne Bestätigung des Recruiters. Der Flow stoppt absichtlich bei der Slack-Benachrichtigung. Er ruft POST /v2/scheduled_interviews nicht auf, um einen Kalendertermin in Greenhouse zu erstellen, ohne dass ein Mensch den Slot bestätigt. Die Auto-Buchung technisch zu verdrahten ist einfach, überträgt aber die Scheduling-Autorität vom Recruiter auf den Algorithmus.
Teams, bei denen Panelisten nicht Google Calendar verwenden. Die freeBusy-Abfrage ist Google-Calendar-spezifisch. Outlook/Exchange-Verfügbarkeit erfordert den freeBusy-Endpoint von Microsoft Graph (/me/calendar/getSchedule), einen separaten HTTP-Request-Node und Azure-AD-Credentials. Der Flow enthält diesen Pfad nicht.
Weniger als 5 Interviews pro Woche pro Recruiter. Bei diesem Volumen ist manuelle Koordination schneller als das Einrichten von OAuth-Credentials und einem Greenhouse-Webhook. Der Setup-Aufwand amortisiert sich ab etwa 10 Interviews pro Woche.
Einrichtung
Flow importieren. In n8n Workflows → Import from File öffnen und apps/web/public/artifacts/interview-scheduling-resolver-n8n/interview-scheduling-resolver-n8n.json auswählen. Jeder Node hat notesInFlow: true, sodass die Canvas-Notizen jeden Schritt erklären.
Webhook-Secret-Umgebungsvariable setzen. In den Einstellungen der n8n-Instanz (oder in der .env-Datei für self-hosted) GREENHOUSE_WEBHOOK_SECRET mit dem Signing-Secret aus dem Greenhouse Dev Center hinzufügen. Der Signaturverifizierungs-Node wirft einen Fehler und stoppt die Ausführung, wenn diese Variable fehlt oder die HMAC-SHA256-Prüfung schlägt.
Google Calendar OAuth2 verbinden. Eine OAuth-2.0-Credential in n8n unter PLACEHOLDER_GOOGLE_CAL_CRED_ID erstellen. Der erforderliche Scope ist calendar.readonly. Für Workspace-Umgebungen mit mehreren Panelisten ist ein Service-Account mit domänenweiter Delegierung praktischer als individuelle OAuth-Tokens pro Panelist — das _README.md behandelt beide Optionen.
Greenhouse Harvest API verbinden. Eine HTTP-Header-Auth-Credential unter PLACEHOLDER_GREENHOUSE_CRED_ID erstellen. Greenhouse Harvest verwendet Basic Auth mit dem API-Key als Benutzername und leerem Passwort (base64-Kodierung von api_key:). Nur die Scopes Scheduled Interviews (read) und Applications (read) gewähren.
Slack-Bot-Token verbinden. Eine HTTP-Header-Auth-Credential unter PLACEHOLDER_SLACK_CRED_ID mit Authorization: Bearer xoxb-... erstellen. Den Bot zu #scheduling-queue einladen.
Greenhouse-Webhook konfigurieren. Im Greenhouse Dev Center einen Web Hook erstellen, der auf die n8n-Instanz-URL unter dem Pfad /webhook/interview-scheduling-resolver zeigt. Das Ereignis candidate_stage_change abonnieren. Das Signing-Secret in GREENHOUSE_WEBHOOK_SECRET kopieren.
Kandidaten-Verfügbarkeit stub oder verdrahten. Der Node Candidate Availability Intake wird als Stub geliefert, der Mo–Fr 9–18 Uhr ET für 14 Tage zurückgibt. Einen Calendly-Webhook oder eine Typeform/Airtable-Lese-Node verdrahten, um echte Kandidatenbeschränkungen vor dem Produktivbetrieb zu erhalten.
Erstverifizierung durchführen. Das _README.md listet fünf spezifische Testfälle auf — gültige Signatur, ungültige Signatur, Slots-gefunden-Pfad, Kein-Verfügbarkeits-Pfad, Backup-Cron-Pfad — jeweils mit erwarteten Ausgaben. Alle fünf abschließen, bevor der Trigger aktiviert wird.
Was der Flow tut
Dreizehn Nodes über zwei Trigger-Pfade.
Webhook-Pfad (Echtzeit):
Greenhouse Webhook — interview_requested — empfängt POST-Ereignisse von candidate_stage_change. Gibt via einen Geschwister-Node Respond 202 Accepted sofort 202 zurück, damit die Greenhouse-Webhook-Zustellung nie durch die Verarbeitungszeit des Flows unterbrochen wird.
Verify Signature + Extract Participants — verifiziert HMAC-SHA256 die Greenhouse-Webhook-Signatur mit crypto.createHmac gegen GREENHOUSE_WEBHOOK_SECRET. Stimmt nicht überein, wird ein Fehler geworfen und die Ausführung gestoppt. Bei Erfolg werden recruiterEmail, interviewerEmails[], candidateEmail, jobName, stageName extrahiert und allCalendarIds als deduplizierte Vereinigung der Recruiter- und Interviewer-E-Mails aufgebaut.
Google Calendar — freeBusy Query — sendet POST an https://www.googleapis.com/calendar/v3/freeBusy mit allCalendarIds als items[]-Array und einem 14-tägigen Fenster ab morgen. Gibt pro-Kalender busy[]-Arrays mit RFC3339-Start-/Endzeiten zurück.
Candidate Availability Intake — liest die Verfügbarkeitsfenster des Kandidaten. Wird als Stub geliefert; gemäß den Setup-Anweisungen durch echte Verfügbarkeitsdaten ersetzen.
Slots Found? — IF-Node. Leitet zur Benachrichtigung weiter, wenn resolved: true, zur Eskalation, wenn resolved: false.
Slack — Notify Recruiter with Proposed Slots — veröffentlicht die 3 besten Slots in #scheduling-queue mit Score, Panel-Liste, Anzahl ausgewerteter Slots und einem Deep-Link zur Greenhouse-Bewerbung.
Slack — Escalate No-Availability — veröffentlicht eine manuelle Koordinationswarnung, wenn kein gemeinsames Fenster existiert.
Täglicher Backup-Cron-Pfad:
Daily Backstop Cron — 8am ET weekdays — läuft um 08:00 America/New_York, Montag bis Freitag (Cron: 0 8 * * 1-5).
Greenhouse — List Stale Unscheduled Interviews — ruft Greenhouse Harvest GET /v1/scheduled_interviews?created_before=<48h-ago> auf, um Interviews zu finden, bei denen der Webhook verpasst wurde oder die Zustellung fehlschlug. Der scheduled_interviews-Endpoint hat keinen status-Query-Parameter, daher ruft der Sweep alles ab, was vor mehr als 48 Stunden erstellt wurde, und filtert im nächsten Node.
Filter Stale Unscheduled (client-side) — verwirft jedes Interview, das bereits eine bestätigte start.date_time hat (oder complete/awaiting_feedback ist), und behält nur die tatsächlich ungeplanten Datensätze. Dies ersetzt den nicht existierenden status-Query-Filter, den der Harvest-Endpoint stillschweigend ignoriert.
Split Into Items — teilt das gefilterte Array in einzelne Items für die Verarbeitung pro Bewerbung auf.
Engineering-Entscheidungen: der Verfügbarkeits-Intersektions-Algorithmus
Der Konfliktlösungs-Code-Node verwendet einen Drei-Phasen-Ansatz: zusammenführen, subtrahieren, quantisieren.
Phase 1 — Panel-Busy-Intervalle zusammenführen. Die freeBusy-API gibt unabhängige busy-Arrays pro Kalender zurück. Der Node sammelt sie alle in einem einzigen flachen Array und führt eine Standard-Intervallzusammenführung durch (nach Start sortieren, vorwärtslaufen, das Ende des letzten Intervalls erweitern bei Überlappung oder Adjazenzsituation). Das Ergebnis ist die kleinste Menge von Intervallen, die jeden Moment abdeckt, in dem mindestens ein Panelist beschäftigt ist.
Phase 2 — Von den Kandidaten-Fenstern subtrahieren. Für jedes Verfügbarkeitsfenster des Kandidaten subtrahiert der Node die zusammengeführte Panel-Busy-Union, indem er beide Listen gleichzeitig durchläuft — eine Intervallsubtraktion, die die Teilintervalle produziert, in denen der Kandidat verfügbar UND das Panel frei ist.
Phase 3 — Quantisieren und Ranken. Die verbleibenden freien Teilintervalle werden in 60-Minuten-Blöcke quantisiert, die auf :00- oder :30-Grenzen ausgerichtet sind. Blöcke, die den Mittag überschreiten, werden ausgeschlossen. Die verbleibenden Blöcke werden durch eine Scoring-Funktion gerankt: früher am Tag erhält eine geringere Strafe, ein leichterer Recruiter-Kalender-Tag erhält weniger Abzüge, und die Nähe zu heute erhält einen kleinen Bonus. Die 3 besten werden dem Recruiter präsentiert.
Zeitzonenbehandlung: die freeBusy-Abfrage gibt RFC3339-Timestamps mit expliziten Offsets aus. Die Ranking-Funktion wendet denselben statischen Offset für die Berechnung der Lokalzeit an. Dies ist eine bewusste Vereinfachung: Sommerzeitumstellungen betreffen Slots zweimal im Jahr. In der Produktion die Konstante TZ_OFFSET_MS im Code-Node durch einen DST-aware-Bibliotheksaufruf ersetzen (z. B. DateTime.fromISO(iso, { zone: 'America/New_York' }).hour von Luxon).
Kostenrealität
Pro 100 gelöste Scheduling-Anfragen:
Google Calendar API — der freeBusy-Endpoint ist im Rahmen der Calendar-API-Kontingente kostenlos (1.000 Abfragen pro 100 Sekunden pro Nutzer; 10.000 pro Tag pro Projekt beim Standard-Kontingent). Ein Interview mit 5 Panelisten verwendet einen einzigen freeBusy-Aufruf mit 6 Kalender-IDs. 100 Interviews = 100 API-Aufrufe.
n8n-Ausführungen — jede Webhook-Zustellung ist eine Ausführung. n8n Cloud Starter für $20/Monat umfasst 5.000 Ausführungen/Monat; der Backup-Cron fügt 20 Ausführungen/Monat hinzu. Teams mit mehr als 5.000 Scheduling-Ereignissen pro Monat benötigen den Pro-Tier ($50/Monat) oder self-hosted.
Greenhouse API — der Backup ruft Greenhouse Harvest höchstens einmal pro Cron-Ausführung auf und gibt bis zu 50 Datensätze pro Aufruf zurück.
Zeitersparnis des Recruiters — die Schätzung für manuelle Multi-Panelist-Scheduling-Koordination beträgt 20–45 Minuten pro Interview-Loop. Der Flow reduziert das auf die 2–3 Minuten, die benötigt werden, um eine Slack-Nachricht zu lesen und zu bestätigen. Bei 20 Interviews pro Recruiter pro Woche entspricht das 6–14 Stunden eliminierter Koordinationsarbeit pro Woche.
Setup-Kosten — 1–2 Stunden für den Flow selbst. Der Kandidaten-Verfügbarkeits-Schritt (den Stub durch eine echte Calendly- oder Typeform-Integration ersetzen) fügt 30–60 Minuten hinzu.
Fehlermodi
Zeitzonen-Bugs an DST-Übergängen.Guard: der Code-Node verwendet einen statischen Offset von -5 Stunden für America/New_York. Dies ist für Eastern Standard Time korrekt, aber um eine Stunde verschoben während Eastern Daylight Time. Für ganzjährige Interview-Planung die Konstante TZ_OFFSET_MS in Resolve Conflicts — Intersect + Rank Slots durch einen DST-aware-Luxon-Aufruf ersetzen, bevor der Flow in Produktion geht.
Doppelbuchung, wenn der Kalender eines Panelisten nicht zugänglich ist.Guard: wenn Google Calendar eines Panelisten in der freeBusy-Antwort einen Fehler zurückgibt, protokolliert der Code-Node den Fehler und behandelt diesen Panelisten als frei — er stoppt nicht. Die Slack-Nachricht enthält die vollständige allCalendarIds-Liste; der Recruiter kann erkennen, welche E-Mail einen freeBusy-Fehler ausgelöst hat, indem er das n8n-Ausführungsprotokoll prüft.
Webhook-Zustellungsfehler (verpasstes Stage-Change-Ereignis).Guard: der tägliche Backup-Cron um 08:00 ET durchsucht Greenhouse nach Interviews, die vor mehr als 48 Stunden erstellt wurden und weiterhin ungeplant sind (ohne bestätigte start.date_time), und verarbeitet sie erneut. Da der scheduled_interviews-Endpoint von Harvest keinen status-Query-Parameter bereitstellt, ruft der Sweep alles vor dem Stichzeitpunkt Erstellte ab und wendet den „ungeplant”-Filter clientseitig in einem Code-Node an. Der 48-Stunden-Schwellenwert verhindert die Neuverarbeitung von Interviews, die gerade erst erstellt wurden und deren Webhook noch in Zustellung sein könnte.
Abgelaufener OAuth2-Token, der den freeBusy-Aufruf ungültig macht.Guard: der OAuth2-Credential-Handler von n8n aktualisiert Access-Tokens automatisch vor jeder Anfrage, wenn ein Refresh-Token vorhanden ist. Wenn der Refresh-Token selbst abläuft oder widerrufen wird, wirft der freeBusy-Node einen 401-Fehler. Den Error-Workflow von n8n (Einstellungen → Error-Workflow) konfigurieren, um eine Slack-Warnung zu senden, wenn eine Ausführung fehlschlägt.
Keine gemeinsame Verfügbarkeit im 14-Tage-Fenster.Guard: der IF-Node Slots Found? leitet zu Slack — Escalate No-Availability weiter, mit der Bewerbungs-ID und der Recruiter-E-Mail. Wenn dieser Pfad häufig ausgelöst wird, das freeBusy-Abfragefenster im Node Google Calendar — freeBusy Query von 14 auf 21 Tage erweitern.
vs Alternativen
vs GoodTime / ModernLoop
GoodTime und ModernLoop sind speziell entwickelte Interview-Scheduling-Plattformen mit ATS-nativen Integrationen, Interviewer-Präferenztraining, Lastausgleich über das Panel und kandidatenseitigen Self-Scheduling-Portalen. Die Enterprise-Verträge von GoodTime beginnen typischerweise im Bereich von $15.000–$40.000/Jahr (Schätzung basierend auf G2-Bewertungen und Vendr-Marketplace-Daten). ModernLoop ist ähnlich in Umfang und Preisklasse.
GoodTime oder ModernLoop wählen, wenn: mehr als 100 Panel-Interviews pro Woche stattfinden, Lastausgleich der Interviewer über einen Panel-Pool benötigt wird, oder Kandidaten eine white-labeled Self-Scheduling-Erfahrung erwarten. Der n8n-Flow bietet nichts davon.
Den n8n-Flow wählen, wenn: das Volumen unter 50 Panel-Interviews pro Woche liegt, n8n bereits für andere Workflows betrieben wird, die Scheduling-Logik im eigenen Repository und Audit-Log gewünscht wird, oder die Plattformkosten von $15k+ durch das aktuelle Einstellungstempo noch nicht gerechtfertigt sind.
vs manuellem Coordinator
Ein dedizierter Recruiting Coordinator, der Interviews manuell plant, kann die Vorschlagsqualität dieses Flows erreichen — er hat Kontext, den der Algorithmus nicht hat (Kandidatenpräferenzen aus dem Telefon-Screen, Beziehungspräferenzen der Panelisten, bevorstehende Abwesenheiten). Der Preis sind 20–45 Minuten pro Loop und die synchrone Abhängigkeit von den Arbeitszeiten des Coordinators. Der Flow läuft um 3 Uhr morgens; ein Coordinator nicht.
vs Calendly Teams / Calendly für Recruiting
Calendly Teams ermöglicht Kandidaten die Selbstplanung gegen einen Multi-Personen-Verfügbarkeitskalender. Es handhabt die kandidatenseitige UX besser als dieser Flow. Es integriert sich nicht nativ mit dem stufenbasierten Workflow von Greenhouse; ein Zapier- oder n8n-Trigger wäre nötig, um beim Stufenwechsel auszulösen und den Calendly-Link zu versenden.
Calendly Teams wählen, wenn die kandidatenseitige Self-Scheduling-Erfahrung Priorität hat und das Ranking/Scoring-Output oder der Slack-basierte Recruiter-Bestätigungsschritt nicht benötigt werden.
Stack-Referenzen
Bundle-Dateien:
apps/web/public/artifacts/interview-scheduling-resolver-n8n/interview-scheduling-resolver-n8n.json — der n8n-Flow-Export (13 Nodes, vollständig konfiguriert, Placeholder-Credentials benannt)
Tools: n8n (Orchestrierung), Greenhouse (ATS-Webhook + Harvest-API), Calendly (Kandidaten-Verfügbarkeit — optional, ersetzt den Stub-Node). Die Google Calendar freeBusy-API und Slack werden direkt über HTTP-Request- und Slack-Nodes verwendet.
# 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"
}