Un flow de n8n que resuelve el problema de coordinación entre múltiples participantes que se interpone entre “el candidato avanza a la etapa de entrevistas” y “se envía la invitación de calendario.” El flow recibe un webhook de Greenhouse al cambiar de etapa, consulta la API freeBusy de Google Calendar para cada panelista y el recruiter simultáneamente, intersecta esas ventanas ocupadas con la disponibilidad declarada del candidato, ordena los intervalos libres resultantes aplicando reglas de desempate, y publica las 3 mejores propuestas de horario en un canal de Slack para que el recruiter confirme y reserve. Un cron diario de respaldo busca entrevistas que quedaron sin agendar por más de 48 horas y las reprocesa por el mismo camino.
El paquete de artefactos se encuentra en apps/web/public/artifacts/interview-scheduling-resolver-n8n/ y contiene interview-scheduling-resolver-n8n.json (la exportación completa del flow de n8n) y _README.md (pasos de importación, configuración de credenciales, procedimiento de verificación en la primera ejecución).
Cuándo usarlo
Usas Greenhouse como ATS y las entrevistas suelen involucrar 3 o más panelistas con calendarios distribuidos en dos o más zonas horarias.
El recruiting coordinator pasa entre 20 y 45 minutos por rol y por loop coordinando el scheduling — enviando emails de disponibilidad, esperando respuestas, revisando cuatro calendarios manualmente, proponiendo un horario, descubriendo un conflicto.
Quieres un registro de decisión para cada slot propuesto: qué ventanas se evaluaron, cuántos bloques ocupados del panel se fusionaron, cuál fue el puntaje de ranking. El mensaje de Slack que publica el flow incluye estos datos para que el recruiter vea por qué se propuso cada horario.
Ya usas n8n (self-hosted o Cloud) y tienes un entorno de Google Workspace donde los calendarios de los panelistas son accesibles vía OAuth2 o una cuenta de servicio con delegación a nivel de dominio.
Cuándo NO usarlo
Contratación masiva o de alta frecuencia. Si realizas más de 50 entrevistas de panel por día — eventos de reclutamiento, programas universitarios, contratación masiva — el modelo freeBusy-por-trigger genera un volumen significativo de llamadas a la API. GoodTime o ModernLoop están diseñados para este patrón de tráfico; el flow de n8n no.
Plataformas ATS distintas a Greenhouse sin webhook de cambio de etapa. El trigger depende de recibir un webhook firmado de Greenhouse. Reemplazarlo por un equivalente de Ashby o Lever es directo (se intercambia el nodo de trigger), pero las plataformas ATS que solo admiten polling introducen una latencia mínima de 5 minutos, lo que rompe el caso de uso de “agendar en menos de una hora.”
Reserva automática sin confirmación del recruiter. El flow se detiene deliberadamente en la notificación de Slack. No llama a POST /v2/scheduled_interviews para escribir un evento de calendario en Greenhouse sin que un humano confirme el slot. Automatizar la reserva es técnicamente sencillo, pero transfiere la autoridad de scheduling del recruiter al algoritmo.
Equipos donde los panelistas no usan Google Calendar. La consulta freeBusy es específica de Google Calendar. La disponibilidad en Outlook/Exchange requiere el endpoint freeBusy de Microsoft Graph (/me/calendar/getSchedule), que necesita un nodo HTTP Request separado y credenciales de Azure AD. El flow no incluye esa ruta.
Menos de 5 entrevistas por semana por recruiter. A ese volumen, la coordinación manual es más rápida que configurar credenciales OAuth y un webhook de Greenhouse. El costo de configuración se amortiza a partir de aproximadamente 10 entrevistas por semana.
Configuración
Importa el flow. En n8n, abre Workflows → Import from File y selecciona apps/web/public/artifacts/interview-scheduling-resolver-n8n/interview-scheduling-resolver-n8n.json. Cada nodo tiene notesInFlow: true para que las notas en el canvas expliquen cada paso.
Define la variable de entorno del webhook secret. En la configuración de tu instancia de n8n (o en el archivo .env para self-hosted), añade GREENHOUSE_WEBHOOK_SECRET con el signing secret del Dev Center de Greenhouse. El nodo de verificación de firma lanza un error y detiene la ejecución si esta variable está ausente o si la comprobación HMAC-SHA256 falla.
Conecta Google Calendar OAuth2. Crea una credencial OAuth 2.0 en n8n bajo PLACEHOLDER_GOOGLE_CAL_CRED_ID. El scope requerido es calendar.readonly. Para entornos de Workspace con múltiples panelistas, una cuenta de servicio con delegación a nivel de dominio es más práctica que tokens OAuth individuales por panelista — el _README.md cubre ambas opciones.
Conecta la API Harvest de Greenhouse. Crea una credencial HTTP Header Auth bajo PLACEHOLDER_GREENHOUSE_CRED_ID. Greenhouse Harvest usa Basic Auth con la API key como nombre de usuario y contraseña en blanco (codifica en base64 api_key:). Otorga únicamente los scopes Scheduled Interviews (read) y Applications (read).
Conecta el bot token de Slack. Crea una credencial HTTP Header Auth bajo PLACEHOLDER_SLACK_CRED_ID con Authorization: Bearer xoxb-.... Invita al bot a #scheduling-queue.
Configura el webhook de Greenhouse. En Greenhouse Dev Center, crea un web hook apuntando a la URL de tu instancia de n8n en la ruta /webhook/interview-scheduling-resolver. Suscríbete al evento candidate_stage_change. Copia el signing secret en GREENHOUSE_WEBHOOK_SECRET.
Stub o conecta la disponibilidad del candidato. El nodo Candidate Availability Intake se entrega como stub retornando Lun–Vie 9am–6pm ET durante 14 días. Conecta un webhook de Calendly o una lectura de Typeform/Airtable para obtener restricciones reales del candidato antes de activar en producción.
Ejecuta la verificación inicial. El _README.md lista cinco casos de prueba específicos — firma válida, firma inválida, ruta de slots encontrados, ruta de sin disponibilidad, ruta del cron de respaldo — cada uno con las salidas esperadas. Completa los cinco antes de activar el trigger.
Qué hace el flow
Trece nodos distribuidos en dos rutas de trigger.
Ruta del webhook (tiempo real):
Greenhouse Webhook — interview_requested — recibe eventos POST de candidate_stage_change. Retorna 202 inmediatamente vía un nodo hermano Respond 202 Accepted para que la entrega del webhook de Greenhouse nunca expire mientras el flow procesa.
Verify Signature + Extract Participants — verifica HMAC-SHA256 la firma del webhook de Greenhouse usando crypto.createHmac contra GREENHOUSE_WEBHOOK_SECRET. Si no coincide, lanza un error y detiene la ejecución. Si pasa, extrae recruiterEmail, interviewerEmails[], candidateEmail, jobName, stageName, y construye allCalendarIds como la unión deduplicada de los emails del recruiter e interviewers.
Google Calendar — freeBusy Query — hace POST a https://www.googleapis.com/calendar/v3/freeBusy con allCalendarIds como el array items[] y una ventana de 14 días comenzando mañana. Retorna arrays busy[] por calendario con tiempos RFC3339 de inicio/fin.
Candidate Availability Intake — lee las ventanas de disponibilidad del candidato. Se entrega como stub; reemplázalo con datos reales de disponibilidad según las instrucciones de configuración.
Resolve Conflicts — Intersect + Rank Slots — el nodo de algoritmo central (ver más abajo).
Slots Found? — nodo IF. Ruta hacia notificación si resolved: true, hacia escalación si resolved: false.
Slack — Notify Recruiter with Proposed Slots — publica los 3 mejores slots en #scheduling-queue con puntaje, lista del panel, cantidad de slots evaluados y un enlace directo a la aplicación en Greenhouse.
Slack — Escalate No-Availability — publica una alerta de coordinación manual cuando no existe ventana común.
Ruta del cron diario de respaldo:
Daily Backstop Cron — 8am ET weekdays — se ejecuta a las 08:00 America/New_York, de lunes a viernes (cron: 0 8 * * 1-5).
Greenhouse — List Stale Unscheduled Interviews — llama a Greenhouse Harvest GET /v1/scheduled_interviews?created_before=<48h-ago> para encontrar entrevistas donde el webhook fue perdido o la entrega falló. El endpoint scheduled_interviews no tiene un parámetro de query status, así que el barrido obtiene todo lo creado hace más de 48 horas y filtra en el siguiente nodo.
Filter Stale Unscheduled (client-side) — descarta cualquier entrevista que ya tenga un start.date_time confirmado (o que esté complete/awaiting_feedback), conservando solo los registros genuinamente sin agendar. Esto reemplaza el filtro de query status inexistente que el endpoint de Harvest ignora silenciosamente.
Split Into Items — divide el array filtrado en ítems individuales para procesamiento por aplicación.
Decisiones de ingeniería: el algoritmo de intersección de disponibilidad
El nodo de código de resolución de conflictos usa un enfoque de tres fases: fusionar, restar, cuantizar.
Fase 1 — Fusionar intervalos ocupados del panel. La API freeBusy retorna arrays busy independientes por calendario. El nodo los recolecta en un único array plano y ejecuta una fusión estándar de intervalos (ordena por inicio, avanza, extiende el fin del último intervalo cuando hay superposición o adyacencia). El resultado es el conjunto mínimo de intervalos que cubre cada momento en que al menos un panelista está ocupado.
Fase 2 — Restar de las ventanas del candidato. Para cada ventana de disponibilidad del candidato, el nodo sustrae la unión de bloques ocupados del panel recorriendo ambas listas simultáneamente, produciendo los sub-intervalos donde el candidato está disponible Y el panel está libre.
Fase 3 — Cuantizar y rankear. Los sub-intervalos libres restantes se cuantizan en bloques de 60 minutos alineados a límites de :00 o :30. Los bloques que cruzan el mediodía se excluyen. Los bloques restantes se rankean por una función de puntaje: más temprano en el día recibe menor penalización, día con agenda más ligera del recruiter recibe menos deducciones, y la proximidad a hoy recibe un pequeño bono. Se presentan los 3 mejores al recruiter.
Manejo de zonas horarias: la consulta freeBusy emite timestamps RFC3339 con offsets explícitos. La función de ranking aplica el mismo offset estático para el cómputo de hora local. Esta es una simplificación deliberada: las transiciones de horario de verano afectan los slots dos veces al año. En producción, reemplaza la constante TZ_OFFSET_MS en el nodo de código por una llamada de librería DST-aware (por ejemplo, Luxon’s DateTime.fromISO(iso, { zone: 'America/New_York' }).hour).
Realidad de costos
Por cada 100 solicitudes de scheduling resueltas:
API de Google Calendar — el endpoint freeBusy es gratuito bajo las cuotas de la Calendar API (1.000 consultas por 100 segundos por usuario; 10.000 por día por proyecto con la cuota por defecto). Una entrevista con 5 panelistas usa una sola llamada freeBusy con 6 IDs de calendario. 100 entrevistas = 100 llamadas a la API.
Ejecuciones de n8n — cada entrega de webhook es una ejecución. n8n Cloud Starter a $20/mes cubre 5.000 ejecuciones/mes. El cron de respaldo añade 20 ejecuciones/mes. Equipos que superan las 5.000 solicitudes de scheduling por mes necesitan el tier Pro ($50/mes) o self-hosted.
API de Greenhouse — el respaldo llama a Greenhouse Harvest como máximo una vez por ejecución del cron, retornando hasta 50 registros por llamada.
Tiempo ahorrado del recruiter — la estimación para coordinación manual de scheduling multi-panelista es de 20–45 minutos por loop de entrevista. El flow reduce eso a los 2–3 minutos necesarios para leer un mensaje de Slack y confirmar. A 20 entrevistas por recruiter por semana, eso equivale a 6–14 horas de trabajo de coordinación eliminadas semanalmente.
Costo de configuración — 1–2 horas para el flow en sí. El paso de disponibilidad del candidato (reemplazar el stub con una integración real de Calendly o Typeform) añade 30–60 minutos.
Modos de fallo
Bugs de zona horaria en los cambios de horario de verano.Guard: el nodo de código usa un offset estático de -5 horas para America/New_York. Esto es correcto para Eastern Standard Time pero desfasado en una hora durante Eastern Daylight Time. Si tu equipo agenda entrevistas durante todo el año, reemplaza la constante TZ_OFFSET_MS en Resolve Conflicts — Intersect + Rank Slots con una llamada de Luxon DST-aware antes de pasar a producción.
Doble reserva cuando el calendario de un panelista no es accesible.Guard: si el Google Calendar de un panelista retorna un error en la respuesta freeBusy, el nodo de código registra el error y trata a ese panelista como libre — no detiene la ejecución. El mensaje de Slack incluye la lista completa de allCalendarIds; el recruiter puede identificar qué email generó un error freeBusy revisando el log de ejecución de n8n.
Fallo en la entrega del webhook (evento de cambio de etapa perdido).Guard: el cron diario de respaldo a las 08:00 ET barre Greenhouse buscando entrevistas creadas hace más de 48 horas que siguen sin agendar (sin un start.date_time confirmado) y las reprocesa. Como el endpoint scheduled_interviews de Harvest no expone un parámetro de query status, el barrido obtiene todo lo creado antes del corte y aplica el filtro de “sin agendar” del lado del cliente en un nodo de código. El umbral de 48 horas evita reprocesar entrevistas recién creadas cuyo webhook aún puede estar en tránsito.
Token OAuth2 vencido que invalida la llamada freeBusy.Guard: el manejador de credenciales OAuth2 de n8n actualiza los access tokens automáticamente antes de cada solicitud cuando hay un refresh token disponible. Si el refresh token en sí vence o es revocado, el nodo freeBusy lanzará un error 401. La ejecución fallará visiblemente en n8n Executions. Configura el workflow de error de n8n (Settings → Error Workflow) para publicar una alerta en Slack cuando cualquier ejecución falle.
Sin disponibilidad común en la ventana de 14 días.Guard: el nodo IF Slots Found? enruta a Slack — Escalate No-Availability con el ID de la aplicación y el email del recruiter. Si esta ruta se activa frecuentemente, extiende la ventana de la consulta freeBusy de 14 a 21 días en el nodo Google Calendar — freeBusy Query.
vs alternativas
vs GoodTime / ModernLoop
GoodTime y ModernLoop son plataformas de scheduling de entrevistas diseñadas específicamente para este fin, con integraciones nativas con ATS, entrenamiento por preferencias de los entrevistadores, balanceo de carga entre el panel, y portales de auto-agenda para el candidato. Los contratos enterprise de GoodTime suelen comenzar en el rango de $15.000–$40.000/año (estimación basada en reseñas de G2 y datos del marketplace de Vendr). ModernLoop es similar en alcance y nivel de precio.
Elige GoodTime o ModernLoop si: realizas más de 100 entrevistas de panel por semana, necesitas balanceo de carga de entrevistadores entre un grupo de panelistas, o tus candidatos esperan una experiencia de auto-agenda con marca propia. El flow de n8n no hace ninguna de esas cosas.
Elige el flow de n8n si: tu volumen es de menos de 50 entrevistas de panel por semana, ya tienes n8n funcionando para otros workflows, quieres la lógica de scheduling en tu propio repositorio y log de auditoría, o el costo de la plataforma de $15k+ todavía no está justificado por tu ritmo de contratación.
vs coordinador manual
Un recruiting coordinator dedicado a agendar entrevistas manualmente puede igualar la calidad de propuesta de este flow — tiene contexto que el algoritmo no tiene (preferencias del candidato de la llamada telefónica, preferencias de relación de los panelistas, próximas ausencias). El costo es esos 20–45 minutos por loop y la dependencia sincrónica con el horario laboral del coordinador. El flow corre a las 3am; un coordinador no.
vs Calendly Teams / Calendly para Recruiting
Calendly Teams permite que los candidatos se auto-agenden contra un calendario de disponibilidad de múltiples personas. Maneja mejor la UX para el candidato que este flow. No se integra con el workflow basado en etapas de Greenhouse de forma nativa; necesitarías un trigger de Zapier o n8n para disparar ante cambio de etapa y enviar el link de Calendly.
Elige Calendly Teams si la experiencia de auto-agenda para el candidato es la prioridad y no necesitas el output de ranking/puntaje ni el paso de confirmación del recruiter vía Slack.
Referencias del stack
Archivos del bundle:
apps/web/public/artifacts/interview-scheduling-resolver-n8n/interview-scheduling-resolver-n8n.json — la exportación del flow de n8n (13 nodos, completamente configurados, credenciales placeholder nombradas)
apps/web/public/artifacts/interview-scheduling-resolver-n8n/_README.md — procedimiento de importación, configuración por credencial, conexión de disponibilidad del candidato, resumen del algoritmo, verificación en la primera ejecución (5 casos de prueba)
Herramientas: n8n (orquestación), Greenhouse (webhook ATS + API Harvest), Calendly (disponibilidad del candidato — opcional, reemplaza el nodo stub). La API freeBusy de Google Calendar y Slack se usan directamente mediante nodos HTTP Request y Slack respectivamente.
# 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"
}