DEV Community

Alex Kane
Alex Kane

Posted on

n8n for HR Tech & People Platform Companies: 5 Automations That Scale Talent Ops and Meet Compliance (Free Workflow JSON)

HR Tech and People Platform companies face a compliance paradox: you're selling HRIS, ATS, and L&D software to enterprises that require SOC2 certification, GDPR DPA agreements, and strict employee data handling — then running your own internal ops on Zapier, routing employee PII through a third-party cloud.

Employee data is GDPR Article 9 special category in many contexts (health, disability, religious beliefs, racial/ethnic origin) plus CCPA sensitive personal information in California. Routing performance reviews, salary data, or background check results through Zapier/Make means you're adding a new GDPR Article 28 sub-processor relationship every time you create an automation — and requiring your enterprise customers to trust that chain.

Self-hosted n8n eliminates the sub-processor problem entirely: workflows run inside your VPC, every workflow is version-controlled JSON (SOC2 change management evidence), and you pay per instance — not per task.

Here are 5 production-ready n8n workflows for HR Tech and People Platform companies — full import-ready JSON included.


1. New Employee Provisioning Pipeline

Triggered by a new hire row in your HRIS, this workflow orchestrates IT provisioning, access grants, welcome communications, and onboarding task assignment — removing the 2-3 day lag between hire confirmation and Day 1 readiness.

{
  "name": "New Employee Provisioning Pipeline",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "new-hire",
        "responseMode": "responseNode"
      },
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const hire = $input.first().json;\nconst startDate = new Date(hire.start_date);\nconst today = new Date();\nconst daysUntilStart = Math.ceil((startDate - today) / 86400000);\nconst provisioning_tier = hire.department === 'Engineering' ? 'ENGINEER' : hire.department === 'Sales' ? 'SALES' : 'STANDARD';\nconst access_groups = {\n  ENGINEER: ['github-org', 'aws-console', 'datadog', 'linear', 'notion'],\n  SALES: ['salesforce', 'outreach', 'gong', 'notion', 'slack-sales'],\n  STANDARD: ['notion', 'slack-general', 'google-workspace']\n};\nreturn [{ json: { ...hire, daysUntilStart, provisioning_tier, access_groups: access_groups[provisioning_tier] } }];"
      },
      "name": "Classify Provisioning",
      "type": "n8n-nodes-base.code",
      "position": [
        460,
        300
      ]
    },
    {
      "parameters": {
        "url": "=https://api.yourplatform.com/provisioning/provision",
        "method": "POST",
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "employee_id",
              "value": "={{$json.employee_id}}"
            },
            {
              "name": "access_groups",
              "value": "={{$json.access_groups}}"
            },
            {
              "name": "email",
              "value": "={{$json.work_email}}"
            }
          ]
        }
      },
      "name": "Provision Access",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        680,
        300
      ]
    },
    {
      "parameters": {
        "toEmail": "={{$json.work_email}}",
        "subject": "Welcome to [Company] — your Day 1 guide",
        "message": "=Hi {{$json.first_name}},\n\nWelcome! We're excited to have you join us on {{$json.start_date}}.\n\nYour accounts are being set up now. By Day 1 you'll have access to:\n{{$json.access_groups.join('\\n')}}\n\nYour manager is {{$json.manager_name}} ({{$json.manager_email}}).\nYour onboarding buddy is {{$json.buddy_name}}.\n\nFirst day logistics:\n- Start time: 9:00 AM\n- Meet {{$json.manager_name}} in Slack #general\n- Your calendar has been seeded with onboarding sessions\n\nSee you soon!\n[HR Team]"
      },
      "name": "Welcome Email",
      "type": "n8n-nodes-base.gmail",
      "position": [
        900,
        220
      ]
    },
    {
      "parameters": {
        "channel": "#hr-ops",
        "text": "=👤 New hire provisioning started: *{{$json.first_name}} {{$json.last_name}}*\nRole: {{$json.job_title}} | Dept: {{$json.department}} | Start: {{$json.start_date}}\nTier: {{$json.provisioning_tier}} | Access groups: {{$json.access_groups.join(', ')}}\nManager: {{$json.manager_name}}"
      },
      "name": "Slack HR Ops",
      "type": "n8n-nodes-base.slack",
      "position": [
        900,
        380
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={\"received\": true, \"employee_id\": \"{{$json.employee_id}}\", \"provisioning_tier\": \"{{$json.provisioning_tier}}\"}"
      },
      "name": "Respond",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        680,
        460
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

What it does: HRIS webhook fires on new hire → classifies provisioning tier by department → calls provisioning API to grant access to the right tools → sends personalized welcome email → notifies #hr-ops in Slack.


2. Candidate Pipeline Health Monitor

Keeps talent acquisition teams proactive: detects stalled candidates, aging requisitions, and sourcing gaps before they turn into missed hiring targets.

{
  "name": "Candidate Pipeline Health Monitor",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 * * 1-5"
            }
          ]
        }
      },
      "name": "Daily 8AM Weekdays",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT r.req_id, r.title, r.department, r.hiring_manager, r.target_start_date,\n  COUNT(CASE WHEN c.stage = 'application_review' THEN 1 END) as in_review,\n  COUNT(CASE WHEN c.stage = 'phone_screen' THEN 1 END) as phone_screen,\n  COUNT(CASE WHEN c.stage = 'interview' THEN 1 END) as interviewing,\n  COUNT(CASE WHEN c.stage = 'offer' THEN 1 END) as offer_stage,\n  MAX(c.last_activity_date) as latest_activity,\n  EXTRACT(day FROM NOW() - MAX(c.last_activity_date)) as days_since_activity,\n  EXTRACT(day FROM r.target_start_date - NOW()) as days_to_target\nFROM requisitions r LEFT JOIN candidates c ON r.req_id = c.req_id\nWHERE r.status = 'open' GROUP BY r.req_id, r.title, r.department, r.hiring_manager, r.target_start_date\nHAVING days_to_target <= 60 OR days_since_activity >= 7"
      },
      "name": "Get Pipeline Data",
      "type": "n8n-nodes-base.postgres",
      "position": [
        460,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const results = [];\nfor (const row of $input.all()) {\n  const d = row.json;\n  let urgency = 'HEALTHY';\n  const issues = [];\n  if (d.days_since_activity >= 14) { urgency = 'STALLED'; issues.push(`No activity ${d.days_since_activity}d`); }\n  else if (d.days_since_activity >= 7) { urgency = 'SLOW'; issues.push(`Slow ${d.days_since_activity}d`); }\n  if (d.days_to_target <= 14 && (d.offer_stage || 0) === 0) { urgency = 'AT_RISK'; issues.push('Target date <14d, no offer'); }\n  if (d.in_review === 0 && d.phone_screen === 0 && d.interviewing === 0 && d.offer_stage === 0) { issues.push('Empty pipeline'); }\n  if (urgency !== 'HEALTHY') results.push({ json: { ...d, urgency, issues: issues.join('; ') } });\n}\nreturn results.length ? results : [{ json: { skip: true } }];"
      },
      "name": "Flag Issues",
      "type": "n8n-nodes-base.code",
      "position": [
        680,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{$json.skip}}",
              "value2": true
            }
          ]
        }
      },
      "name": "Skip if none",
      "type": "n8n-nodes-base.if",
      "position": [
        900,
        300
      ]
    },
    {
      "parameters": {
        "channel": "#talent-ops",
        "text": "=⚠️ Pipeline Alert — *{{$json.urgency}}*: {{$json.title}} ({{$json.department}})\nIssues: {{$json.issues}}\nFunnel: {{$json.in_review}} review → {{$json.phone_screen}} screen → {{$json.interviewing}} interview → {{$json.offer_stage}} offer\nTarget start: {{$json.target_start_date}} ({{$json.days_to_target}} days) | Last activity: {{$json.days_since_activity}}d ago\nHiring manager: {{$json.hiring_manager}}"
      },
      "name": "Slack Alert",
      "type": "n8n-nodes-base.slack",
      "position": [
        1120,
        220
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

What it does: Runs daily weekday mornings → queries ATS Postgres for open reqs with activity in last 60 days → flags STALLED (14+ days no activity), SLOW (7+ days), or AT_RISK (target date <14 days, no offer stage) → posts structured alert to #talent-ops.


3. Employee Data Subject Request Handler

GDPR Article 15-21 gives employees the right to access, correct, erase, or restrict processing of their personal data. This workflow handles the intake, classification, and SLA tracking of employee DSRs — critical for any HR platform serving EU users.

{
  "name": "Employee DSR Handler",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "dsr-intake",
        "responseMode": "responseNode"
      },
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const req = $input.first().json;\nconst typeMap = {\n  'access': { label: 'Subject Access Request (Art.15)', sla_days: 30, team: 'privacy-ops' },\n  'rectification': { label: 'Rectification (Art.16)', sla_days: 30, team: 'hr-data' },\n  'erasure': { label: 'Right to Erasure (Art.17)', sla_days: 30, team: 'privacy-ops' },\n  'restriction': { label: 'Restriction of Processing (Art.18)', sla_days: 30, team: 'privacy-ops' },\n  'portability': { label: 'Data Portability (Art.20)', sla_days: 30, team: 'hr-data' },\n  'objection': { label: 'Right to Object (Art.21)', sla_days: 30, team: 'privacy-ops' }\n};\nconst meta = typeMap[req.request_type] || { label: req.request_type, sla_days: 30, team: 'privacy-ops' };\nconst deadline = new Date(Date.now() + meta.sla_days * 86400000);\nconst ticket_id = `DSR-${Date.now()}`;\nreturn [{ json: { ...req, ...meta, deadline: deadline.toISOString().split('T')[0], ticket_id } }];"
      },
      "name": "Classify DSR",
      "type": "n8n-nodes-base.code",
      "position": [
        460,
        300
      ]
    },
    {
      "parameters": {
        "channel": "=#{{$json.team}}",
        "text": "=🔒 New DSR received: *{{$json.label}}*\nTicket: {{$json.ticket_id}} | Employee: {{$json.employee_name}} ({{$json.employee_email}})\nRequest: {{$json.request_details}}\nSLA Deadline: {{$json.deadline}} ({{$json.sla_days}} days)\nAssign to: @{{$json.team}} for review and response"
      },
      "name": "Slack Privacy Team",
      "type": "n8n-nodes-base.slack",
      "position": [
        680,
        220
      ]
    },
    {
      "parameters": {
        "toEmail": "={{$json.employee_email}}",
        "subject": "=Your data request has been received — Ref: {{$json.ticket_id}}",
        "message": "=Dear {{$json.employee_name}},\n\nWe have received your request regarding your personal data.\n\nRequest type: {{$json.label}}\nReference number: {{$json.ticket_id}}\nRequest received: {{$now.format('YYYY-MM-DD')}}\nResponse deadline: {{$json.deadline}}\n\nWe will respond within 30 days as required by GDPR Article 12. If additional time is required (up to 60 days total), we will notify you in advance with the reason.\n\nIf you have questions, please reference ticket number {{$json.ticket_id}}.\n\nRegards,\n[Company] Privacy Team"
      },
      "name": "ACK Email",
      "type": "n8n-nodes-base.gmail",
      "position": [
        680,
        380
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "sheetId": "YOUR_SHEET_ID",
        "sheetName": "DSR Log",
        "columns": {
          "mappingMode": "autoMapInputData"
        }
      },
      "name": "Log DSR",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        680,
        500
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={\"received\": true, \"ticket_id\": \"{{$json.ticket_id}}\", \"deadline\": \"{{$json.deadline}}\"}"
      },
      "name": "Respond",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        460,
        500
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

What it does: Webhook receives DSR submission → classifies by GDPR article (access/rectification/erasure/restriction/portability/objection) → sets 30-day SLA deadline → notifies correct team in Slack → sends employee an ACK email with ticket ID and deadline → logs to Sheets for compliance audit trail.


4. Performance Review Cycle Orchestrator

Automates the reminders, escalations, and deadline tracking for your performance review cycles — eliminating the flood of 'have you submitted yet?' emails from HR.

{
  "name": "Performance Review Cycle Orchestrator",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 * * 1-5"
            }
          ]
        }
      },
      "name": "Daily 8AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "operation": "getAll",
        "sheetId": "YOUR_SHEET_ID",
        "sheetName": "Review Schedule",
        "filters": {
          "conditions": [
            {
              "leftValue": "={{$json.cycle_status}}",
              "rightValue": "active",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ]
        }
      },
      "name": "Get Active Cycles",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        460,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const now = new Date();\nconst results = [];\nfor (const row of $input.all()) {\n  const d = row.json;\n  const deadline = new Date(d.submission_deadline);\n  const daysLeft = Math.ceil((deadline - now) / 86400000);\n  let tier = null;\n  if (daysLeft < 0) tier = 'OVERDUE';\n  else if (daysLeft <= 2) tier = 'FINAL_REMINDER';\n  else if (daysLeft <= 7) tier = 'DUE_SOON';\n  else if (daysLeft <= 14) tier = 'UPCOMING';\n  if (tier && d.completion_pct < 100) {\n    results.push({ json: { ...d, daysLeft, tier, incomplete: Math.round((1 - d.completion_pct/100) * d.total_reviewers) } });\n  }\n}\nreturn results.length ? results : [{ json: { skip: true } }];"
      },
      "name": "Classify Deadlines",
      "type": "n8n-nodes-base.code",
      "position": [
        680,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{$json.skip}}",
              "value2": true
            }
          ]
        }
      },
      "name": "Skip if none",
      "type": "n8n-nodes-base.if",
      "position": [
        900,
        300
      ]
    },
    {
      "parameters": {
        "channel": "#hrbp",
        "text": "=📋 Review Cycle *{{$json.tier}}*: {{$json.cycle_name}} ({{$json.department}})\nDeadline: {{$json.submission_deadline}} ({{$json.daysLeft}} days) | Completion: {{$json.completion_pct}}% ({{$json.incomplete}} reviews pending)\nCycle owner: {{$json.hrbp_name}} | Action required if OVERDUE"
      },
      "name": "Slack HRBP",
      "type": "n8n-nodes-base.slack",
      "position": [
        1120,
        220
      ]
    },
    {
      "parameters": {
        "toEmail": "={{$json.hrbp_email}}",
        "subject": "=[Review Cycle {{$json.tier}}] {{$json.cycle_name}} — {{$json.daysLeft}} days remaining",
        "message": "=Hi {{$json.hrbp_name}},\n\nAction needed: {{$json.cycle_name}} has {{$json.incomplete}} reviews still pending.\n\nDeadline: {{$json.submission_deadline}} ({{$json.daysLeft}} days)\nCompletion: {{$json.completion_pct}}%\nPending reviews: {{$json.incomplete}} of {{$json.total_reviewers}}\n\nPlease follow up with managers who haven't submitted.\nReview tracker: https://app.yourplatform.com/cycles/{{$json.cycle_id}}"
      },
      "name": "Email HRBP",
      "type": "n8n-nodes-base.gmail",
      "position": [
        1120,
        380
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

What it does: Runs daily on weekdays → reads active review cycles from Sheets → classifies OVERDUE/FINAL_REMINDER/DUE_SOON/UPCOMING by days left + completion % → sends Slack alert to #hrbp + email to cycle owner with pending count.


5. Weekly People Platform KPI Dashboard

Delivers weekly people analytics to your CPO and leadership team — active users, new signups, feature engagement, and review completion rates with week-over-week trends.

{
  "name": "Weekly People Platform KPI Dashboard",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 * * 1"
            }
          ]
        }
      },
      "name": "Monday 8AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT\n  COUNT(DISTINCT user_id) FILTER (WHERE last_active >= NOW() - INTERVAL '30 days') as mau,\n  COUNT(DISTINCT user_id) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days') as new_users_7d,\n  COUNT(DISTINCT account_id) FILTER (WHERE last_active >= NOW() - INTERVAL '30 days') as active_accounts,\n  COUNT(DISTINCT account_id) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days') as new_accounts_7d,\n  ROUND(AVG(CASE WHEN feature_name = 'performance_review' THEN engagement_score END), 1) as avg_review_engagement\nFROM user_activity"
      },
      "name": "Get User Metrics",
      "type": "n8n-nodes-base.postgres",
      "position": [
        460,
        300
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT\n  COUNT(*) FILTER (WHERE cycle_status = 'active') as active_cycles,\n  ROUND(AVG(completion_pct) FILTER (WHERE cycle_status = 'active'), 1) as avg_completion_pct,\n  COUNT(*) FILTER (WHERE cycle_status = 'completed' AND updated_at >= NOW() - INTERVAL '7 days') as cycles_completed_7d\nFROM review_cycles"
      },
      "name": "Get Review Metrics",
      "type": "n8n-nodes-base.postgres",
      "position": [
        460,
        460
      ]
    },
    {
      "parameters": {
        "mode": "multiplex"
      },
      "name": "Merge",
      "type": "n8n-nodes-base.merge",
      "position": [
        680,
        380
      ]
    },
    {
      "parameters": {
        "jsCode": "const d = $input.first().json;\nconst prev = $getWorkflowStaticData('global');\nconst pMAU = prev.mau || d.mau;\nconst pAcc = prev.active_accounts || d.active_accounts;\nconst mauWoW = ((d.mau - pMAU) / (pMAU || 1) * 100).toFixed(1);\nconst accWoW = ((d.active_accounts - pAcc) / (pAcc || 1) * 100).toFixed(1);\n$getWorkflowStaticData('global').mau = d.mau;\n$getWorkflowStaticData('global').active_accounts = d.active_accounts;\nconst html = `<h2>Weekly People Platform Report</h2><table border='1' cellpadding='6'><tr><th>Metric</th><th>Value</th><th>WoW</th></tr><tr><td>Monthly Active Users</td><td>${d.mau}</td><td>${mauWoW}%</td></tr><tr><td>Active Accounts</td><td>${d.active_accounts}</td><td>${accWoW}%</td></tr><tr><td>New Users (7d)</td><td>${d.new_users_7d}</td><td>—</td></tr><tr><td>New Accounts (7d)</td><td>${d.new_accounts_7d}</td><td>—</td></tr><tr><td>Active Review Cycles</td><td>${d.active_cycles}</td><td>—</td></tr><tr><td>Avg Review Completion</td><td>${d.avg_completion_pct}%</td><td>—</td></tr></table>`;\nreturn [{ json: { ...d, mauWoW, accWoW, html } }];"
      },
      "name": "Build Report",
      "type": "n8n-nodes-base.code",
      "position": [
        900,
        380
      ]
    },
    {
      "parameters": {
        "toEmail": "cpo@yourplatform.com",
        "subject": "=Weekly People Platform Report — {{$now.format('MMM D, YYYY')}}",
        "message": "={{$json.html}}",
        "options": {
          "htmlBody": true
        }
      },
      "name": "Email CPO",
      "type": "n8n-nodes-base.gmail",
      "position": [
        1120,
        300
      ]
    },
    {
      "parameters": {
        "channel": "#platform-metrics",
        "text": "=👥 Weekly KPI: {{$json.mau}} MAU ({{$json.mauWoW}}% WoW), {{$json.active_accounts}} active accounts ({{$json.accWoW}}% WoW), {{$json.new_accounts_7d}} new accounts, {{$json.avg_completion_pct}}% avg review completion"
      },
      "name": "Slack Metrics",
      "type": "n8n-nodes-base.slack",
      "position": [
        1120,
        460
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

What it does: Runs Monday 8AM → queries Postgres for MAU, active accounts, new signups, review cycle metrics → computes WoW% using $getWorkflowStaticData → sends HTML report to CPO + Slack one-liner to #platform-metrics.


Why HR Tech companies self-host n8n

Concern Zapier/Make Self-hosted n8n
Employee PII (GDPR Art. 9) Transits third-party cloud Stays inside your VPC
GDPR Art. 28 sub-processors Zapier/Make = new DPA required for each Self-hosted = no new sub-processor
CCPA sensitive personal info Salary, health, performance data egresses Never leaves your infra
SOC2 HR module (CC6.1) API calls opaque, third-party access Every workflow is a git-committed JSON
Salary data confidentiality Transmitted to Zapier servers Stays in your Postgres
Cost at scale (100K+ employees) Per-task pricing = thousands/month One instance, unlimited runs

Get these workflows + 10 more

I've packaged these (and 10 additional n8n templates covering SaaS ops, customer success, and AI agent workflows) into a ready-to-import collection:

FlowKit — n8n Automation Templates

Individual templates $12–$29. Complete bundle $97 (15 templates).

Questions about any of these? Drop them in the comments.

Top comments (0)