Home/ Workflows/ HR Managers/ Recruitment
Workflow · HR Managers

n8n Workflow for Tracking Job Application Pipeline in 2026

Automate your job application pipeline tracking in n8n. Sync candidate stages from Google Sheets to Slack in real time. Built for HR Managers.

This workflow monitors a Google Sheets applicant tracker for stage changes — Applied, Phone Screen, Interview, Offer, Hired, Rejected — and automatically posts a formatted Slack message to your recruiting channel and sends a status email to the candidate when their stage advances. It replaces 30 minutes of manual copy-paste and Slack updates every morning before your recruiting stand-up.

Why this automation matters

Most HR teams track candidates in a shared Google Sheet and then manually post updates to Slack so hiring managers know who moved forward overnight. When that update is forgotten — which happens every third day — a hiring manager walks into an interview without knowing the candidate was already rejected, or a recruiter follows up with someone who already received an offer. This workflow fires the moment a stage column is updated, so the Slack channel and candidate inbox are never more than seconds behind the sheet.

What you need before starting


How to build it: step by step

1. Google Sheets Trigger — Poll the Pipeline tab for row changes

Node type: Google Sheets Trigger Credential: Your Google Sheets OAuth2 credential Field: Trigger On → set to Row Updated Field: Document → select your Applicant Tracker spreadsheet Field: Sheet → select Pipeline Field: Poll Times → Every → set to 1 Minute Field: Columns to Watch → set to Stage

Output: Every time a value in the Stage column changes, this node emits one item containing all columns for that row: Candidate Name, Email, Role, Stage, Last Updated, and the row number.

Why this configuration matters: Watching only the Stage column means the workflow does not fire when a recruiter corrects a typo in a name or updates a phone number. It fires exactly when a candidate moves through the funnel — nothing else.


2. IF — Filter out rows where Stage is blank or unchanged

Node type: IF Condition 1:

Condition 2:

Logic: AND

Output (True branch): Items where Stage has a real value pass forward. Output (False branch): Items are discarded silently.

Why this configuration matters: Google Sheets Trigger occasionally emits a row when a collaborator opens the sheet without editing. This filter kills those ghost triggers before they spam your Slack channel or send a blank email to a candidate.


3. Switch — Route by Stage value

Node type: Switch Field: Value to Match{{ $json["Stage"] }}

Configure five output routes:

Route nameCondition
Phone Screenequals Phone Screen
Interviewequals Interview
Offerequals Offer
Hiredequals Hired
Rejectedequals Rejected

Default output: Leave unconfigured (items with stage Applied or any unexpected value fall through and are discarded — Applied is the starting state and needs no notification).

Output: Each candidate item exits through exactly one named output branch.

Why this configuration matters: Different stages need different Slack messages and different candidate emails. Splitting here lets you write stage-specific copy instead of one generic message that reads like an automated form letter.


4. Set — Build the notification payload (one per Switch branch)

You need five Set nodes, one wired to each Switch output. Each Set node constructs the variables used by the Slack and Gmail nodes downstream. The configuration is identical across all five — only the message text differs. Below is the full configuration for the Interview branch; replicate it for the others, changing the values in bold.

Node type: Set Mode: Manual Mapping

Field nameValue
slackMessage:calendar: *{{ $json["Candidate Name"] }}* has moved to **Interview** for *{{ $json["Role"] }}*. Update the scorecard before their session.
emailSubjectYour application update — {{ $json["Role"] }} at [Your Company]
emailBodyHi {{ $json["Candidate Name"] }},\n\nGreat news — you've been selected for an interview for the {{ $json["Role"] }} role. Our team will be in touch within one business day to confirm the time.\n\nBest,\nThe Recruiting Team
candidateEmail{{ $json["Email"] }}
candidateName{{ $json["Candidate Name"] }}
role{{ $json["Role"] }}
stage{{ $json["Stage"] }}

Slack message text for each branch:

Output: Each item now carries slackMessage, emailSubject, emailBody, candidateEmail, and supporting fields ready for the downstream nodes.


5. Slack — Post stage update to recruiting channel

Node type: Slack Credential: Your Slack OAuth2 credential Field: ResourceMessage Field: OperationSend Field: Channel → paste your channel ID, e.g. C08XXXXXXXX Field: Text{{ $json["slackMessage"] }} Field: Unfurl Linksfalse

Output: Slack returns a ts (timestamp) confirming the message was posted. The item passes forward with this added to the JSON.

Why this configuration matters: Using the channel ID instead of the channel name means the workflow survives channel renames. If someone renames #recruiting to #talent-acquisition next quarter, this node keeps working.


6. Gmail — Send stage notification email to candidate

Node type: Gmail Credential: Your Gmail OAuth2 credential Field: ResourceMessage Field: OperationSend Field: To{{ $json["candidateEmail"] }} Field: Subject{{ $json["emailSubject"] }} Field: Message{{ $json["emailBody"] }} Field: Email TypeText

Output: Gmail returns the sent message ID. The item continues to the final logging node.

Why this configuration matters: Setting Email Type to Text prevents n8n from wrapping your carefully written message in a default HTML template that strips your line breaks and makes the email look broken on mobile.


7. Google Sheets — Log the notification timestamp

Node type: Google Sheets Credential: Your Google Sheets OAuth2 credential Field: ResourceSheet Within Document Field: OperationUpdate Row Field: DocumentApplicant Tracker Field: SheetPipeline Field: Mapping ColumnEmail (this is the unique key that identifies which row to update) Field: Fields to Update:

Output: The Last Updated cell for that candidate row is stamped with the exact time the notification fired.

Why this configuration matters: Without this write-back, the trigger node can re-fire on the same stage change the next polling cycle if the sheet metadata hasn’t refreshed. Writing Last Updated gives you an audit trail and a reliable way to query “which candidates were updated today” without opening Slack history.


Full workflow JSON

{
  "name": "Job Application Pipeline Tracker",
  "nodes": [
    {
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        },
        "documentId": {
          "__rl": true,
          "value": "YOUR_GOOGLE_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Pipeline",
          "mode": "name"
        },
        "triggerOn": "rowUpdate",
        "columnsToWatch": ["Stage"]
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000001",
      "name": "Google Sheets Trigger",
      "type": "n8n-nodes-base.googleSheetsTrigger",
      "typeVersion": 1,
      "position": [240, 300],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "REPLACE_WITH_YOUR_GOOGLE_SHEETS_CREDENTIAL_ID",
          "name": "Google Sheets OAuth2"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{ $json[\"Stage\"] }}",
              "operation": "isNotEmpty"
            },
            {
              "value1": "={{ $json[\"Stage\"] }}",
              "operation": "notEqual",
              "value2": "undefined"
            }
          ]
        },
        "combineOperation": "all"
      },
      "id": "a1b2c3d4-0002-0002-0002-000000000002",
      "name": "IF Stage Is Valid",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [460, 300]
    },
    {
      "parameters": {
        "dataType": "string",
        "value1": "={{ $json[\"Stage\"] }}",
        "rules": {
          "rules": [
            {
              "value2": "Phone Screen",
              "outputKey": "Phone Screen"
            },
            {
              "value2": "Interview",
              "