Un flow n8n qui résout le problème de coordination entre plusieurs participants, celui qui se trouve entre « le candidat avance à l’étape des entretiens » et « l’invitation de calendrier est envoyée. » Le flow reçoit un webhook Greenhouse lors d’un changement d’étape, interroge l’API freeBusy de Google Calendar pour chaque panéliste et le recruiter simultanément, intersecte ces plages occupées avec les disponibilités déclarées du candidat, classe les créneaux libres résultants selon des règles de départage, et publie les 3 meilleures propositions d’horaire dans un canal Slack pour que le recruiter confirme et réserve. Un cron de secours quotidien parcourt les entretiens restés non planifiés depuis plus de 48 heures et les rejoue par le même chemin.
Le bundle d’artefacts se trouve à apps/web/public/artifacts/interview-scheduling-resolver-n8n/ et contient interview-scheduling-resolver-n8n.json (l’export complet du flow n8n) et _README.md (étapes d’import, configuration des credentials, procédure de vérification au premier lancement).
Quand l’utiliser
Vous utilisez Greenhouse comme ATS et les entretiens impliquent régulièrement 3 panélistes ou plus dont les calendriers sont répartis sur deux fuseaux horaires ou davantage.
Le recruiting coordinator passe 20 à 45 minutes par poste et par loop à coordonner le scheduling — envoyer des emails de disponibilité, attendre les réponses, vérifier quatre calendriers manuellement, proposer un créneau, découvrir un conflit.
Vous souhaitez un journal de décision pour chaque créneau proposé : quelles plages ont été évaluées, combien de blocs occupés du panel ont été fusionnés, quel était le score de classement. Le message Slack que le flow publie contient ces données pour que le recruiter comprenne pourquoi chaque créneau a été proposé.
Vous utilisez déjà n8n (self-hosted ou Cloud) et disposez d’un environnement Google Workspace où les calendriers des panélistes sont accessibles via OAuth2 ou un compte de service avec délégation à l’échelle du domaine.
Quand NE PAS l’utiliser
Recrutement en masse ou à haute fréquence. Si vous réalisez plus de 50 entretiens de panel par jour — événements de recrutement, programmes universitaires, recrutement en volume — le modèle freeBusy-par-trigger génère un volume significatif d’appels API. GoodTime ou ModernLoop sont conçus pour ce schéma de trafic ; le flow n8n ne l’est pas.
Plateformes ATS autres que Greenhouse sans webhook de changement d’étape. Le trigger dépend de la réception d’un webhook signé de Greenhouse. Le remplacer par un équivalent Ashby ou Lever est simple (on échange le node de trigger), mais les plateformes ATS en polling uniquement introduisent une latence minimale de 5 minutes, ce qui casse le cas d’usage « planifier en moins d’une heure. »
Réservation automatique sans confirmation du recruiter. Le flow s’arrête délibérément à la notification Slack. Il n’appelle pas POST /v2/scheduled_interviews pour créer un événement de calendrier dans Greenhouse sans qu’un humain ait confirmé le créneau. Automatiser la réservation est techniquement simple, mais transfère l’autorité de scheduling du recruiter à l’algorithme.
Équipes où les panélistes n’utilisent pas Google Calendar. La requête freeBusy est spécifique à Google Calendar. La disponibilité Outlook/Exchange nécessite l’endpoint freeBusy de Microsoft Graph (/me/calendar/getSchedule), un node HTTP Request séparé et des credentials Azure AD. Le flow ne comprend pas ce chemin.
Moins de 5 entretiens par semaine par recruiter. À ce volume, la coordination manuelle est plus rapide que la configuration des credentials OAuth et d’un webhook Greenhouse. Le coût de mise en place est amorti à partir d’environ 10 entretiens par semaine.
Configuration
Importer le flow. Dans n8n, ouvrir Workflows → Import from File et sélectionner apps/web/public/artifacts/interview-scheduling-resolver-n8n/interview-scheduling-resolver-n8n.json. Chaque node a notesInFlow: true pour que les notes sur le canvas expliquent chaque étape.
Définir la variable d’environnement du webhook secret. Dans les paramètres de votre instance n8n (ou dans le fichier .env pour self-hosted), ajouter GREENHOUSE_WEBHOOK_SECRET avec le signing secret du Dev Center de Greenhouse. Le node de vérification de signature lance une erreur et interrompt l’exécution si cette variable est absente ou si la vérification HMAC-SHA256 échoue.
Connecter Google Calendar OAuth2. Créer une credential OAuth 2.0 dans n8n sous PLACEHOLDER_GOOGLE_CAL_CRED_ID. Le scope requis est calendar.readonly. Pour les environnements Workspace avec plusieurs panélistes, un compte de service avec délégation à l’échelle du domaine est plus pratique que des tokens OAuth individuels par panéliste — le _README.md couvre les deux approches.
Connecter l’API Harvest de Greenhouse. Créer une credential HTTP Header Auth sous PLACEHOLDER_GREENHOUSE_CRED_ID. Greenhouse Harvest utilise Basic Auth avec la clé API comme nom d’utilisateur et un mot de passe vide (encoder en base64 api_key:). N’accorder que les scopes Scheduled Interviews (read) et Applications (read).
Connecter le bot token Slack. Créer une credential HTTP Header Auth sous PLACEHOLDER_SLACK_CRED_ID avec Authorization: Bearer xoxb-.... Inviter le bot dans #scheduling-queue.
Configurer le webhook Greenhouse. Dans le Dev Center de Greenhouse, créer un web hook pointant vers l’URL de votre instance n8n au chemin /webhook/interview-scheduling-resolver. S’abonner à l’événement candidate_stage_change. Copier le signing secret dans GREENHOUSE_WEBHOOK_SECRET.
Stub ou connecter la disponibilité du candidat. Le node Candidate Availability Intake est livré en tant que stub retournant Lun–Ven 9h–18h ET sur 14 jours. Connecter un webhook Calendly ou une lecture Typeform/Airtable pour obtenir de vraies contraintes de disponibilité avant d’activer en production.
Effectuer la vérification initiale. Le _README.md liste cinq cas de test spécifiques — signature valide, signature invalide, chemin slots trouvés, chemin sans disponibilité, chemin cron de secours — chacun avec les sorties attendues. Compléter les cinq avant d’activer le trigger.
Ce que fait le flow
Treize nodes répartis sur deux chemins de trigger.
Chemin webhook (temps réel) :
Greenhouse Webhook — interview_requested — reçoit les événements POST de candidate_stage_change. Retourne 202 immédiatement via un node frère Respond 202 Accepted pour que la livraison du webhook Greenhouse n’expire jamais pendant le traitement du flow.
Verify Signature + Extract Participants — vérifie HMAC-SHA256 la signature du webhook Greenhouse avec crypto.createHmac contre GREENHOUSE_WEBHOOK_SECRET. Si elle ne correspond pas, lance une erreur et interrompt. En cas de succès, extrait recruiterEmail, interviewerEmails[], candidateEmail, jobName, stageName, et construit allCalendarIds comme l’union dédupliquée des emails du recruiter et des interviewers.
Google Calendar — freeBusy Query — envoie un POST à https://www.googleapis.com/calendar/v3/freeBusy avec allCalendarIds comme tableau items[] et une fenêtre de 14 jours à partir de demain. Retourne des tableaux busy[] par calendrier avec des horaires RFC3339 de début/fin.
Candidate Availability Intake — lit les plages de disponibilité du candidat. Livré en stub ; remplacer par des données de disponibilité réelles selon les instructions de configuration.
Resolve Conflicts — Intersect + Rank Slots — le node d’algorithme central (voir ci-dessous).
Slots Found ? — node IF. Achemine vers la notification si resolved: true, vers l’escalade si resolved: false.
Slack — Notify Recruiter with Proposed Slots — publie les 3 meilleurs créneaux dans #scheduling-queue avec le score, la liste du panel, le nombre de créneaux évalués et un lien profond vers la candidature dans Greenhouse.
Slack — Escalate No-Availability — publie une alerte de coordination manuelle quand aucune fenêtre commune n’existe.
Chemin cron de secours quotidien :
Daily Backstop Cron — 8am ET weekdays — se déclenche à 08:00 America/New_York, du lundi au vendredi (cron : 0 8 * * 1-5).
Greenhouse — List Stale Unscheduled Interviews — appelle Greenhouse Harvest GET /v1/scheduled_interviews?created_before=<48h-ago> pour trouver les entretiens dont le webhook a été manqué ou dont la livraison a échoué. L’endpoint scheduled_interviews n’a pas de paramètre de requête status, donc le balayage récupère tout ce qui a été créé il y a plus de 48 heures et filtre dans le node suivant.
Filter Stale Unscheduled (client-side) — écarte tout entretien ayant déjà une start.date_time confirmée (ou au statut complete/awaiting_feedback), ne conservant que les enregistrements réellement non planifiés. Cela remplace le filtre de requête status inexistant que l’endpoint Harvest ignore silencieusement.
Split Into Items — divise le tableau filtré en items individuels pour un traitement par candidature.
Décisions d’ingénierie : l’algorithme d’intersection de disponibilité
Le node de code de résolution de conflits utilise une approche en trois phases : fusionner, soustraire, quantiser.
Phase 1 — Fusionner les intervalles occupés du panel. L’API freeBusy retourne des tableaux busy indépendants par calendrier. Le node les rassemble dans un unique tableau plat et exécute une fusion standard d’intervalles (tri par début, avancement, extension de la fin du dernier intervalle en cas de chevauchement ou d’adjacence). Le résultat est le plus petit ensemble d’intervalles couvrant chaque moment où au moins un panéliste est occupé.
Phase 2 — Soustraire des fenêtres du candidat. Pour chaque fenêtre de disponibilité du candidat, le node soustrait l’union des blocs occupés du panel en parcourant les deux listes simultanément — une soustraction d’intervalles qui produit les sous-intervalles où le candidat est disponible ET le panel est libre.
Phase 3 — Quantiser et classer. Les sous-intervalles libres restants sont quantisés en blocs de 60 minutes alignés sur des limites de :00 ou :30. Les blocs qui chevauchent midi sont exclus. Les blocs restants sont classés par une fonction de score : plus tôt dans la journée reçoit une pénalité plus faible, une journée de calendrier plus légère pour le recruiter reçoit moins de déductions, et la proximité avec aujourd’hui reçoit un petit bonus. Les 3 meilleurs sont présentés au recruiter.
Gestion des fuseaux horaires : la requête freeBusy émet des timestamps RFC3339 avec des offsets explicites. La fonction de classement applique le même offset statique pour le calcul de l’heure locale. Il s’agit d’une simplification délibérée : les transitions d’heure d’été affectent les créneaux deux fois par an. En production, remplacer la constante TZ_OFFSET_MS dans le node de code par un appel de bibliothèque DST-aware (par exemple DateTime.fromISO(iso, { zone: 'America/New_York' }).hour de Luxon).
Réalité des coûts
Pour 100 demandes de scheduling résolues :
API Google Calendar — l’endpoint freeBusy est gratuit dans les quotas de la Calendar API (1 000 requêtes par 100 secondes par utilisateur ; 10 000 par jour par projet avec le quota par défaut). Un entretien avec 5 panélistes utilise un seul appel freeBusy avec 6 IDs de calendrier. 100 entretiens = 100 appels API.
Exécutions n8n — chaque livraison de webhook est une exécution. n8n Cloud Starter à $20/mois couvre 5 000 exécutions/mois ; le cron de secours ajoute 20 exécutions/mois. Les équipes dépassant 5 000 demandes de scheduling par mois ont besoin du tier Pro ($50/mois) ou du self-hosted.
API Greenhouse — le cron de secours appelle Greenhouse Harvest au maximum une fois par exécution du cron, retournant jusqu’à 50 enregistrements par appel.
Temps économisé du recruiter — l’estimation pour la coordination manuelle de scheduling multi-panéliste est de 20 à 45 minutes par loop d’entretien. Le flow réduit cela aux 2 à 3 minutes nécessaires pour lire un message Slack et confirmer. Avec 20 entretiens par recruiter par semaine, cela représente 6 à 14 heures de travail de coordination éliminées chaque semaine.
Coût de mise en place — 1 à 2 heures pour le flow lui-même. L’étape de disponibilité du candidat (remplacer le stub par une vraie intégration Calendly ou Typeform) ajoute 30 à 60 minutes.
Modes d’échec
Bugs de fuseau horaire aux transitions d’heure d’été.Guard : le node de code utilise un offset statique de -5 heures pour America/New_York. C’est correct pour Eastern Standard Time, mais décalé d’une heure pendant Eastern Daylight Time. Pour une planification d’entretiens toute l’année, remplacer la constante TZ_OFFSET_MS dans Resolve Conflicts — Intersect + Rank Slots par un appel DST-aware de Luxon avant de passer en production.
Double réservation quand le calendrier d’un panéliste est inaccessible.Guard : si Google Calendar d’un panéliste retourne une erreur dans la réponse freeBusy, le node de code enregistre l’erreur et traite ce panéliste comme libre — il ne s’arrête pas. Le message Slack inclut la liste complète des allCalendarIds ; le recruiter peut identifier quel email a déclenché une erreur freeBusy en consultant le journal d’exécution n8n.
Échec de livraison du webhook (événement de changement d’étape manqué).Guard : le cron de secours quotidien à 08:00 ET parcourt Greenhouse à la recherche d’entretiens créés il y a plus de 48 heures qui restent non planifiés (sans start.date_time confirmée) et les rejoue. Comme l’endpoint scheduled_interviews de Harvest n’expose pas de paramètre de requête status, le balayage récupère tout ce qui a été créé avant le seuil et applique le filtre « non planifié » côté client dans un node de code. Le seuil de 48 heures évite de retraiter des entretiens récemment créés dont le webhook pourrait encore être en transit.
Token OAuth2 expiré invalidant l’appel freeBusy.Guard : le gestionnaire de credentials OAuth2 de n8n actualise les access tokens automatiquement avant chaque requête quand un refresh token est disponible. Si le refresh token lui-même expire ou est révoqué, le node freeBusy lèvera une erreur 401. Configurer le workflow d’erreur de n8n (Paramètres → Error Workflow) pour publier une alerte Slack en cas d’échec d’une exécution.
Aucune disponibilité commune dans la fenêtre de 14 jours.Guard : le node IF Slots Found ? achemine vers Slack — Escalate No-Availability avec l’ID de la candidature et l’email du recruiter. Si ce chemin se déclenche fréquemment, étendre la fenêtre de requête freeBusy de 14 à 21 jours dans le node Google Calendar — freeBusy Query.
vs alternatives
vs GoodTime / ModernLoop
GoodTime et ModernLoop sont des plateformes de scheduling d’entretiens spécialement conçues, avec des intégrations ATS natives, un apprentissage des préférences des interviewers, un équilibrage de charge sur le panel et des portails de self-scheduling pour les candidats. Les contrats enterprise de GoodTime commencent typiquement dans la fourchette de $15 000 à $40 000/an (estimation basée sur les avis G2 et les données du marketplace Vendr). ModernLoop est similaire en périmètre et en niveau de prix.
Choisir GoodTime ou ModernLoop si : vous réalisez plus de 100 entretiens de panel par semaine, vous avez besoin d’un équilibrage de charge des interviewers sur un groupe de panélistes, ou vos candidats attendent une expérience de self-scheduling en marque blanche. Le flow n8n ne fait rien de tout cela.
Choisir le flow n8n si : votre volume est inférieur à 50 entretiens de panel par semaine, vous utilisez déjà n8n pour d’autres workflows, vous souhaitez la logique de scheduling dans votre propre dépôt et journal d’audit, ou le coût de la plateforme à $15k+ n’est pas encore justifié par votre rythme de recrutement.
vs coordinateur manuel
Un recruiting coordinator dédié à la planification manuelle des entretiens peut égaler la qualité de proposition de ce flow — il dispose d’un contexte que l’algorithme n’a pas (préférences du candidat de l’appel téléphonique, préférences relationnelles des panélistes, prochaines absences). Le prix est ces 20 à 45 minutes par loop et la dépendance synchrone aux horaires de travail du coordinateur. Le flow tourne à 3h du matin ; un coordinateur non.
vs Calendly Teams / Calendly pour le Recruiting
Calendly Teams permet aux candidats de se planifier eux-mêmes sur un calendrier de disponibilité multi-personnes. Il gère mieux l’UX côté candidat que ce flow. Il ne s’intègre pas nativement au workflow basé sur les étapes de Greenhouse ; un trigger Zapier ou n8n serait nécessaire pour se déclencher sur le changement d’étape et envoyer le lien Calendly.
Choisir Calendly Teams si l’expérience de self-scheduling côté candidat est la priorité et que le scoring/classement ou l’étape de confirmation du recruiter via Slack ne sont pas nécessaires.
apps/web/public/artifacts/interview-scheduling-resolver-n8n/_README.md — procédure d’import, configuration par credential, câblage de la disponibilité du candidat, résumé de l’algorithme, vérification au premier lancement (5 cas de test)
Outils : n8n (orchestration), Greenhouse (webhook ATS + API Harvest), Calendly (disponibilité du candidat — optionnel, remplace le node stub). L’API freeBusy de Google Calendar et Slack sont utilisés directement via des nodes HTTP Request et Slack respectivement.
# 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"
}