Um flow de n8n que resolve o problema de coordenação entre múltiplos participantes que fica entre “o candidato avança para a etapa de entrevistas” e “o convite de calendário é enviado.” O flow recebe um webhook do Greenhouse na mudança de etapa, consulta a API freeBusy do Google Calendar para cada panelista e o recruiter simultaneamente, intersecta essas janelas ocupadas com a disponibilidade declarada do candidato, ordena os intervalos livres resultantes usando regras de desempate, e publica os 3 melhores horários propostos em um canal do Slack para o recruiter confirmar e reservar. Um cron diário de backup varre as entrevistas deixadas sem agendamento por mais de 48 horas e as reprocessa pelo mesmo caminho.
O pacote de artefatos está em apps/web/public/artifacts/interview-scheduling-resolver-n8n/ e contém interview-scheduling-resolver-n8n.json (a exportação completa do flow n8n) e _README.md (passos de importação, configuração de credenciais, procedimento de verificação na primeira execução).
Quando usar
Você usa o Greenhouse como ATS e as entrevistas normalmente envolvem 3 ou mais panelistas com calendários distribuídos em dois ou mais fusos horários.
O recruiting coordinator gasta entre 20 e 45 minutos por vaga e por loop coordenando o scheduling — enviando e-mails de disponibilidade, esperando respostas, verificando quatro calendários manualmente, propondo um horário, descobrindo um conflito.
Você quer um registro de decisão para cada slot proposto: quais janelas foram avaliadas, quantos blocos ocupados do painel foram fundidos, qual foi o score de ranking. A mensagem do Slack que o flow publica inclui esses dados para que o recruiter entenda por que cada horário foi sugerido.
Você já usa n8n (self-hosted ou Cloud) e tem um ambiente Google Workspace onde os calendários dos panelistas são acessíveis via OAuth2 ou uma conta de serviço com delegação em nível de domínio.
Quando NÃO usar
Contratação em alto volume ou alta frequência. Se você realiza mais de 50 entrevistas de painel por dia — eventos de recrutamento, programas universitários, contratação em massa — o modelo freeBusy-por-trigger gera um volume significativo de chamadas de API. GoodTime ou ModernLoop são feitos para esse padrão de tráfego; o flow n8n não.
Plataformas ATS diferentes do Greenhouse sem webhook de mudança de etapa. O trigger depende de receber um webhook assinado do Greenhouse. Substituí-lo por um equivalente do Ashby ou Lever é direto (basta trocar o nodo de trigger), mas plataformas ATS somente com polling introduzem uma latência mínima de 5 minutos, o que quebra o caso de uso de “agendar em menos de uma hora.”
Reserva automática sem confirmação do recruiter. O flow deliberadamente para na notificação do Slack. Ele não chama POST /v2/scheduled_interviews para criar um evento de calendário no Greenhouse sem um humano confirmar o slot. Automatizar a reserva é tecnicamente simples, mas transfere a autoridade de scheduling do recruiter para o algoritmo.
Equipes onde os panelistas não usam Google Calendar. A consulta freeBusy é específica do Google Calendar. Disponibilidade no Outlook/Exchange requer o endpoint freeBusy do Microsoft Graph (/me/calendar/getSchedule), que precisa de um nodo HTTP Request separado e credenciais do Azure AD. O flow não inclui esse caminho.
Menos de 5 entrevistas por semana por recruiter. Nesse volume, a coordenação manual é mais rápida do que configurar credenciais OAuth e um webhook do Greenhouse. O custo de setup se amortiza a partir de aproximadamente 10 entrevistas por semana.
Configuração
Importe o flow. No n8n, abra Workflows → Import from File e selecione apps/web/public/artifacts/interview-scheduling-resolver-n8n/interview-scheduling-resolver-n8n.json. Cada nodo tem notesInFlow: true para que as notas no canvas expliquem cada etapa.
Configure a variável de ambiente do webhook secret. Nas configurações da sua instância n8n (ou no arquivo .env para self-hosted), adicione GREENHOUSE_WEBHOOK_SECRET com o signing secret do Dev Center do Greenhouse. O nodo de verificação de assinatura lança um erro e interrompe a execução se essa variável estiver ausente ou se a verificação HMAC-SHA256 falhar.
Conecte o Google Calendar OAuth2. Crie uma credencial OAuth 2.0 no n8n em PLACEHOLDER_GOOGLE_CAL_CRED_ID. O scope necessário é calendar.readonly. Para ambientes Workspace com múltiplos panelistas, uma conta de serviço com delegação em nível de domínio é mais prática do que tokens OAuth individuais por panelista — o _README.md cobre ambas as abordagens.
Conecte a API Harvest do Greenhouse. Crie uma credencial HTTP Header Auth em PLACEHOLDER_GREENHOUSE_CRED_ID. O Greenhouse Harvest usa Basic Auth com a API key como nome de usuário e senha em branco (encode em base64 api_key:). Conceda apenas os scopes Scheduled Interviews (read) e Applications (read).
Conecte o bot token do Slack. Crie uma credencial HTTP Header Auth em PLACEHOLDER_SLACK_CRED_ID com Authorization: Bearer xoxb-.... Convide o bot para #scheduling-queue.
Configure o webhook do Greenhouse. No Greenhouse Dev Center, crie um web hook apontando para a URL da sua instância n8n no caminho /webhook/interview-scheduling-resolver. Assine o evento candidate_stage_change. Copie o signing secret para GREENHOUSE_WEBHOOK_SECRET.
Stub ou conecte a disponibilidade do candidato. O nodo Candidate Availability Intake é entregue como stub retornando Seg–Sex 9h–18h ET por 14 dias. Conecte um webhook do Calendly ou uma leitura do Typeform/Airtable para obter restrições reais do candidato antes de ativar em produção.
Execute a verificação inicial. O _README.md lista cinco casos de teste específicos — assinatura válida, assinatura inválida, caminho de slots encontrados, caminho de sem disponibilidade, caminho do cron de backup — cada um com as saídas esperadas. Conclua os cinco antes de ativar o trigger.
O que o flow faz
Treze nodos distribuídos em dois caminhos de trigger.
Caminho do webhook (tempo real):
Greenhouse Webhook — interview_requested — recebe eventos POST de candidate_stage_change. Retorna 202 imediatamente via um nodo irmão Respond 202 Accepted para que a entrega do webhook do Greenhouse nunca expire enquanto o flow processa.
Verify Signature + Extract Participants — verifica HMAC-SHA256 a assinatura do webhook do Greenhouse usando crypto.createHmac contra GREENHOUSE_WEBHOOK_SECRET. Se não corresponder, lança um erro e interrompe. Se passar, extrai recruiterEmail, interviewerEmails[], candidateEmail, jobName, stageName, e constrói allCalendarIds como a união deduplicada dos e-mails do recruiter e dos interviewers.
Google Calendar — freeBusy Query — faz POST para https://www.googleapis.com/calendar/v3/freeBusy com allCalendarIds como o array items[] e uma janela de 14 dias começando amanhã. Retorna arrays busy[] por calendário com horários RFC3339 de início/fim.
Candidate Availability Intake — lê as janelas de disponibilidade do candidato. Entregue como stub; substitua por dados reais de disponibilidade conforme as instruções de configuração.
Resolve Conflicts — Intersect + Rank Slots — o nodo de algoritmo central (ver abaixo).
Slots Found? — nodo IF. Redireciona para notificação se resolved: true, para escalação se resolved: false.
Slack — Notify Recruiter with Proposed Slots — publica os 3 melhores slots no #scheduling-queue com score, lista do painel, quantidade de slots avaliados e um deep link para a aplicação no Greenhouse.
Slack — Escalate No-Availability — publica um alerta de coordenação manual quando não existe janela comum.
Caminho do cron diário de backup:
Daily Backstop Cron — 8am ET weekdays — executa às 08:00 America/New_York, de segunda a sexta (cron: 0 8 * * 1-5).
Greenhouse — List Stale Unscheduled Interviews — chama o Greenhouse Harvest GET /v1/scheduled_interviews?created_before=<48h-ago> para encontrar entrevistas onde o webhook foi perdido ou a entrega falhou. O endpoint scheduled_interviews não tem um parâmetro de query status, então a varredura busca tudo o que foi criado há mais de 48 horas e filtra no nodo seguinte.
Filter Stale Unscheduled (client-side) — descarta qualquer entrevista que já tenha um start.date_time confirmado (ou que esteja complete/awaiting_feedback), mantendo apenas os registros genuinamente sem agendamento. Isso substitui o filtro de query status inexistente que o endpoint do Harvest ignora silenciosamente.
Split Into Items — divide o array filtrado em itens individuais para processamento por aplicação.
Decisões de engenharia: o algoritmo de interseção de disponibilidade
O nodo de código de resolução de conflitos usa uma abordagem de três fases: fundir, subtrair, quantizar.
Fase 1 — Fundir intervalos ocupados do painel. A API freeBusy retorna arrays busy independentes por calendário. O nodo os coleta em um único array plano e executa uma fusão padrão de intervalos (ordena por início, avança, estende o fim do último intervalo quando há sobreposição ou adjacência). O resultado é o conjunto mínimo de intervalos que cobre cada momento em que pelo menos um panelista está ocupado.
Fase 2 — Subtrair das janelas do candidato. Para cada janela de disponibilidade do candidato, o nodo subtrai a união de blocos ocupados do painel percorrendo ambas as listas simultaneamente, produzindo os sub-intervalos onde o candidato está disponível E o painel está livre.
Fase 3 — Quantizar e rankear. Os sub-intervalos livres restantes são quantizados em blocos de 60 minutos alinhados a limites de :00 ou :30. Blocos que cruzam o meio-dia são excluídos. Os blocos restantes são rankeados por uma função de score: mais cedo no dia recebe menor penalização, dia com agenda mais leve do recruiter recebe menos deduções, e proximidade com hoje recebe um pequeno bônus. Os 3 melhores são apresentados ao recruiter.
Tratamento de fuso horário: a consulta freeBusy emite timestamps RFC3339 com offsets explícitos. A função de ranking aplica o mesmo offset estático para o cálculo de hora local. Esta é uma simplificação deliberada: as transições de horário de verão afetam os slots duas vezes por ano. Em produção, substitua a constante TZ_OFFSET_MS no nodo de código por uma chamada de biblioteca DST-aware (por exemplo, DateTime.fromISO(iso, { zone: 'America/New_York' }).hour do Luxon).
Realidade de custos
Por cada 100 solicitações de scheduling resolvidas:
API do Google Calendar — o endpoint freeBusy é gratuito dentro das cotas da Calendar API (1.000 consultas por 100 segundos por usuário; 10.000 por dia por projeto na cota padrão). Uma entrevista com 5 panelistas usa uma única chamada freeBusy com 6 IDs de calendário. 100 entrevistas = 100 chamadas de API.
Execuções do n8n — cada entrega de webhook é uma execução. n8n Cloud Starter a $20/mês cobre 5.000 execuções/mês. O cron de backup adiciona 20 execuções/mês. Equipes que excedem 5.000 solicitações de scheduling por mês precisam do tier Pro ($50/mês) ou self-hosted.
API do Greenhouse — o backup chama o Greenhouse Harvest no máximo uma vez por execução do cron, retornando até 50 registros por chamada.
Tempo economizado do recruiter — a estimativa para coordenação manual de scheduling multi-panelista é de 20–45 minutos por loop de entrevista. O flow reduz isso para os 2–3 minutos necessários para ler uma mensagem do Slack e confirmar. Com 20 entrevistas por recruiter por semana, isso representa 6–14 horas de trabalho de coordenação eliminadas semanalmente.
Custo de configuração — 1–2 horas para o flow em si. A etapa de disponibilidade do candidato (substituir o stub por uma integração real com Calendly ou Typeform) adiciona 30–60 minutos.
Modos de falha
Bugs de fuso horário nas transições de horário de verão.Guard: o nodo de código usa um offset estático de -5 horas para America/New_York. Isso está correto para Eastern Standard Time, mas está errado por uma hora durante o Eastern Daylight Time. Se a sua equipe agenda entrevistas durante todo o ano, substitua a constante TZ_OFFSET_MS em Resolve Conflicts — Intersect + Rank Slots por uma chamada DST-aware do Luxon antes de ir para produção.
Dupla reserva quando o calendário de um panelista não é acessível.Guard: se o Google Calendar de um panelista retorna um erro na resposta freeBusy, o nodo de código registra o erro e trata esse panelista como livre — não interrompe a execução. A mensagem do Slack inclui a lista completa de allCalendarIds; o recruiter pode identificar qual e-mail gerou um erro freeBusy verificando o log de execução do n8n.
Falha na entrega do webhook (evento de mudança de etapa perdido).Guard: o cron diário de backup às 08:00 ET varre o Greenhouse buscando entrevistas criadas há mais de 48 horas que continuam sem agendamento (sem um start.date_time confirmado) e as reprocessa. Como o endpoint scheduled_interviews do Harvest não expõe um parâmetro de query status, a varredura busca tudo o que foi criado antes do corte e aplica o filtro de “sem agendamento” do lado do cliente em um nodo de código. O limite de 48 horas evita reprocessar entrevistas recém-criadas cujo webhook ainda pode estar a caminho.
Token OAuth2 expirado que invalida a chamada freeBusy.Guard: o handler de credenciais OAuth2 do n8n atualiza os access tokens automaticamente antes de cada solicitação quando há um refresh token disponível. Se o próprio refresh token expirar ou for revogado, o nodo freeBusy lançará um erro 401. Configure o error workflow do n8n (Settings → Error Workflow) para publicar um alerta no Slack quando qualquer execução falhar.
Sem disponibilidade comum na janela de 14 dias.Guard: o nodo IF Slots Found? redireciona para Slack — Escalate No-Availability com o ID da aplicação e o e-mail do recruiter. Se esse caminho disparar com frequência, estenda a janela da consulta freeBusy de 14 para 21 dias no nodo Google Calendar — freeBusy Query.
vs alternativas
vs GoodTime / ModernLoop
GoodTime e ModernLoop são plataformas de scheduling de entrevistas construídas especificamente para isso, com integrações nativas com ATS, treinamento por preferências dos entrevistadores, balanceamento de carga entre o painel e portais de auto-agenda para o candidato. Os contratos enterprise do GoodTime normalmente começam na faixa de $15.000–$40.000/ano (estimativa baseada em reviews do G2 e dados do marketplace do Vendr). ModernLoop é similar em escopo e nível de preço.
Escolha GoodTime ou ModernLoop se: você realiza mais de 100 entrevistas de painel por semana, precisa de balanceamento de carga de entrevistadores em um grupo de panelistas, ou seus candidatos esperam uma experiência de auto-agenda com a marca da empresa. O flow n8n não faz nenhuma dessas coisas.
Escolha o flow n8n se: seu volume é de menos de 50 entrevistas de painel por semana, você já tem o n8n rodando para outros workflows, quer a lógica de scheduling no seu próprio repositório e log de auditoria, ou o custo da plataforma de $15k+ ainda não é justificado pelo seu ritmo de contratação.
vs coordenador manual
Um recruiting coordinator dedicado ao scheduling de entrevistas manualmente pode igualar a qualidade de proposta deste flow — ele tem contexto que o algoritmo não tem (preferências do candidato da call telefônica, preferências de relacionamento dos panelistas, próximas ausências). O custo são esses 20–45 minutos por loop e a dependência síncrona com o horário de trabalho do coordenador. O flow roda às 3h da manhã; um coordenador não.
vs Calendly Teams / Calendly para Recruiting
O Calendly Teams permite que os candidatos se auto-agendem em um calendário de disponibilidade de múltiplas pessoas. Ele trata melhor a UX para o candidato do que este flow. Ele não se integra nativamente ao workflow baseado em etapas do Greenhouse; seria necessário um trigger do Zapier ou n8n para disparar na mudança de etapa e enviar o link do Calendly.
Escolha o Calendly Teams se a experiência de auto-agenda para o candidato for a prioridade e você não precisar do output de ranking/score nem da etapa de confirmação do recruiter via Slack.
Referências do stack
Arquivos do bundle:
apps/web/public/artifacts/interview-scheduling-resolver-n8n/interview-scheduling-resolver-n8n.json — a exportação do flow n8n (13 nodos, completamente configurados, credenciais placeholder nomeadas)
apps/web/public/artifacts/interview-scheduling-resolver-n8n/_README.md — procedimento de importação, configuração por credencial, conexão de disponibilidade do candidato, resumo do algoritmo, verificação na primeira execução (5 casos de teste)
Ferramentas: n8n (orquestração), Greenhouse (webhook ATS + API Harvest), Calendly (disponibilidade do candidato — opcional, substitui o nodo stub). A API freeBusy do Google Calendar e o Slack são usados diretamente via nodos HTTP Request e 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"
}