This workflow corresponds to n8n.io template #16197 — we link there as the canonical source.
This workflow follows the Gmail → Google Sheets recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "E8OCyYmoqBjE3vOD",
"name": "Payroll Discrepancy Detector & HR Alert",
"tags": [
{
"id": "2V3HXFbv2wqNGm6s",
"name": "Dev",
"createdAt": "2025-06-17T05:42:41.949Z",
"updatedAt": "2025-06-17T05:42:41.949Z"
}
],
"nodes": [
{
"id": "06292133-7af0-4364-a2f2-a4f881d20695",
"name": "Monthly Pre-Payroll Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [
-16,
624
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 9 25 * *"
}
]
}
},
"typeVersion": 1.2
},
{
"id": "6b0e4b5e-de61-4a24-ae02-b2da25a6379e",
"name": "Get Current Pay Run",
"type": "n8n-nodes-base.googleSheets",
"position": [
208,
624
],
"parameters": {
"options": {},
"sheetName": {
"__rl": true,
"mode": "list",
"value": "CurrentPayRun"
},
"documentId": {
"__rl": true,
"mode": "id",
"value": "YOUR_GOOGLE_SHEET_ID"
}
},
"typeVersion": 4.5
},
{
"id": "c4d40258-4c30-48cc-bc8f-7dd1d7d75905",
"name": "Get Previous Pay Run",
"type": "n8n-nodes-base.googleSheets",
"position": [
432,
624
],
"parameters": {
"options": {},
"sheetName": {
"__rl": true,
"mode": "list",
"value": "PreviousPayRun"
},
"documentId": {
"__rl": true,
"mode": "id",
"value": "YOUR_GOOGLE_SHEET_ID"
}
},
"typeVersion": 4.5
},
{
"id": "8eaccc50-1f33-43f8-972d-4bbbd18d85ac",
"name": "Detect Discrepancies",
"type": "n8n-nodes-base.code",
"position": [
656,
624
],
"parameters": {
"jsCode": "// ================================================\n// PAYROLL DISCREPANCY ENGINE\n// Compares current pay run against previous period\n// Flags anomalies before money is disbursed\n// ================================================\n\nconst current = $('Get Current Pay Run').all().map(i => i.json);\nconst previous = $('Get Previous Pay Run').all().map(i => i.json);\n\n// Build a lookup of last period by employee id\nconst prevById = {};\nfor (const row of previous) {\n const id = String(row.employee_id || row.employeeId || '').trim();\n if (id) prevById[id] = row;\n}\n\nconst seenIds = new Set();\nconst results = [];\n\nfor (const row of current) {\n const empId = String(row.employee_id || row.employeeId || '').trim();\n const name = row.name || row.employee_name || 'Unknown';\n\n // Skip blank or disabled rows\n if (!empId || String(row.status || 'active').toLowerCase() === 'inactive') {\n continue;\n }\n\n if (seenIds.has(empId)) {\n results.push({\n json: {\n employeeId: empId,\n name,\n anomalyType: 'DUPLICATE_PAYMENT',\n severity: 'Critical',\n detail: 'Employee appears more than once in the current pay run.',\n currentNet: Number(row.net_pay || 0),\n previousNet: 0,\n deltaPct: 0,\n currentHours: Number(row.hours || 0),\n email: row.email || '',\n flagged: true\n }\n });\n continue;\n }\n seenIds.add(empId);\n\n const curNet = Number(row.net_pay || row.netPay || 0);\n const curHours = Number(row.hours || row.hours_worked || 0);\n const prev = prevById[empId];\n\n let anomalyType = '';\n let severity = '';\n let detail = '';\n let deltaPct = 0;\n\n if (!prev) {\n anomalyType = 'NEW_OR_UNMATCHED';\n severity = 'Medium';\n detail = 'No matching record in the previous pay run (new hire or ID mismatch).';\n } else {\n const prevNet = Number(prev.net_pay || prev.netPay || 0);\n deltaPct = prevNet > 0 ? Math.round(((curNet - prevNet) / prevNet) * 100) : 0;\n\n if (curHours === 0 && curNet > 0) {\n anomalyType = 'PAY_WITHOUT_HOURS';\n severity = 'High';\n detail = 'Net pay is greater than zero but logged hours are zero.';\n } else if (Math.abs(deltaPct) >= 40) {\n anomalyType = 'LARGE_PAY_SWING';\n severity = Math.abs(deltaPct) >= 80 ? 'Critical' : 'High';\n detail = 'Net pay changed by ' + deltaPct + ' percent versus last period.';\n } else if (Math.abs(deltaPct) >= 15) {\n anomalyType = 'MODERATE_PAY_CHANGE';\n severity = 'Medium';\n detail = 'Net pay changed by ' + deltaPct + ' percent versus last period.';\n }\n }\n\n if (anomalyType) {\n results.push({\n json: {\n employeeId: empId,\n name,\n anomalyType,\n severity,\n detail,\n currentNet: curNet,\n previousNet: prev ? Number(prev.net_pay || 0) : 0,\n deltaPct,\n currentHours: curHours,\n email: row.email || '',\n flagged: true\n }\n });\n }\n}\n\n// Sentinel so the IF node routes false when nothing is flagged\nif (results.length === 0) {\n return [{ json: { flagged: false, anomalyType: 'none', severity: 'none', name: '', employeeId: '' } }];\n}\n\nreturn results;"
},
"typeVersion": 2
},
{
"id": "642d6482-409a-415e-8372-cf1c3eb98997",
"name": "Has Anomalies",
"type": "n8n-nodes-base.if",
"position": [
880,
624
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "check-flagged",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.flagged }}",
"rightValue": true
}
]
}
},
"typeVersion": 2
},
{
"id": "3f2bdf9e-6f49-4fc7-b69d-e96448f68c50",
"name": "Classify Severity",
"type": "n8n-nodes-base.set",
"position": [
1104,
528
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "s-emoji",
"name": "severityEmoji",
"type": "string",
"value": "={{ $json.severity === 'Critical' ? 'RED' : $json.severity === 'High' ? 'ORANGE' : 'YELLOW' }}"
},
{
"id": "s-block",
"name": "blockPayment",
"type": "boolean",
"value": "={{ $json.severity === 'Critical' }}"
},
{
"id": "s-summary",
"name": "lineSummary",
"type": "string",
"value": "=[{{ $json.severity }}] {{ $json.name }} ({{ $json.employeeId }}) - {{ $json.anomalyType }}: {{ $json.detail }}"
},
{
"id": "s-ts",
"name": "detectedAt",
"type": "string",
"value": "={{ $now.toISO() }}"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "c3d9958f-82c1-48e6-9b6e-30b2630868b9",
"name": "Is Critical Block",
"type": "n8n-nodes-base.if",
"position": [
1328,
528
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "check-block",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.blockPayment }}",
"rightValue": true
}
]
}
},
"typeVersion": 2
},
{
"id": "d54b70b6-af2d-445d-af6c-934f07ee5426",
"name": "Slack Critical Hold",
"type": "n8n-nodes-base.slack",
"position": [
1552,
448
],
"parameters": {
"text": "=PAYROLL HOLD - CRITICAL DISCREPANCY\n\nA critical payroll anomaly was detected and this pay run should be held until reviewed.\n\nEmployee: {{ $json.name }} ({{ $json.employeeId }})\nType: {{ $json.anomalyType }}\nDetail: {{ $json.detail }}\nCurrent Net: ${{ $json.currentNet }}\nPrevious Net: ${{ $json.previousNet }}\nChange: {{ $json.deltaPct }} percent\nLogged Hours: {{ $json.currentHours }}\n\nDo NOT release payroll until Finance signs off.\nDetected: {{ $json.detectedAt }}",
"select": "channel",
"channelId": {
"__rl": true,
"mode": "id",
"value": "YOUR_SLACK_CHANNEL_ID"
},
"otherOptions": {
"includeLinkToWorkflow": false
}
},
"typeVersion": 2.2
},
{
"id": "f0721a4d-36a1-4bb6-9d42-db6f4aefcb1d",
"name": "Log Anomaly to Sheet",
"type": "n8n-nodes-base.googleSheets",
"position": [
1776,
528
],
"parameters": {
"columns": {
"value": {
"name": "={{ $json.name }}",
"detail": "={{ $json.detail }}",
"deltaPct": "={{ $json.deltaPct }}",
"severity": "={{ $json.severity }}",
"currentNet": "={{ $json.currentNet }}",
"detectedAt": "={{ $json.detectedAt }}",
"employeeId": "={{ $json.employeeId }}",
"anomalyType": "={{ $json.anomalyType }}",
"previousNet": "={{ $json.previousNet }}"
},
"mappingMode": "defineBelow"
},
"options": {},
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "list",
"value": "AnomalyLog"
},
"documentId": {
"__rl": true,
"mode": "id",
"value": "YOUR_GOOGLE_SHEET_ID"
}
},
"typeVersion": 4.5
},
{
"id": "1c423ce1-bf29-4477-aae6-0dbfe74562a0",
"name": "Email HR Review",
"type": "n8n-nodes-base.gmail",
"position": [
2000,
528
],
"parameters": {
"sendTo": "YOUR_HR_EMAIL_ADDRESS",
"message": "=Hello HR Team,\n\nA payroll discrepancy was detected during the pre-payroll review.\n\nEmployee: {{ $json.name }} ({{ $json.employeeId }})\nAnomaly Type: {{ $json.anomalyType }}\nSeverity: {{ $json.severity }}\nDetail: {{ $json.detail }}\n\nCurrent Net Pay: ${{ $json.currentNet }}\nPrevious Net Pay: ${{ $json.previousNet }}\nChange: {{ $json.deltaPct }} percent\nLogged Hours: {{ $json.currentHours }}\n\nPlease verify this record before the pay run is released.\n\nDetected: {{ $json.detectedAt }}\nAuto-generated by n8n.",
"options": {},
"subject": "=Payroll Discrepancy [{{ $json.severity }}] - {{ $json.name }} ({{ $json.employeeId }})",
"emailType": "text"
},
"typeVersion": 2.1
},
{
"id": "c11f86ac-b1bd-4ed0-875b-24985ca08cf9",
"name": "Build Run Digest",
"type": "n8n-nodes-base.code",
"position": [
2224,
528
],
"parameters": {
"jsCode": "// ================================================\n// RUN-LEVEL DIGEST BUILDER\n// Aggregates all flagged anomalies into one summary\n// ================================================\n\nconst items = $input.all().map(i => i.json);\n\nlet critical = 0, high = 0, medium = 0;\nconst lines = [];\n\nfor (const a of items) {\n if (a.severity === 'Critical') critical++;\n else if (a.severity === 'High') high++;\n else if (a.severity === 'Medium') medium++;\n if (a.lineSummary) lines.push('- ' + a.lineSummary);\n}\n\nconst total = critical + high + medium;\nconst recommendation = critical > 0\n ? 'HOLD payroll - critical anomalies present.'\n : (high > 0 ? 'Review high-severity items before release.' : 'Minor items only - safe to proceed after a quick check.');\n\nconst digest = 'PAYROLL DISCREPANCY DIGEST\\n\\n'\n + 'Total flagged: ' + total + '\\n'\n + 'Critical: ' + critical + ' | High: ' + high + ' | Medium: ' + medium + '\\n\\n'\n + 'Recommendation: ' + recommendation + '\\n\\n'\n + lines.join('\\n');\n\nreturn [{ json: { total, critical, high, medium, recommendation, digest } }];"
},
"typeVersion": 2
},
{
"id": "c68917de-2994-4a6d-bb20-7ed590afc507",
"name": "Slack Digest to Finance",
"type": "n8n-nodes-base.slack",
"position": [
2448,
528
],
"parameters": {
"text": "={{ $json.digest }}",
"select": "channel",
"channelId": {
"__rl": true,
"mode": "id",
"value": "YOUR_SLACK_CHANNEL_ID"
},
"otherOptions": {
"includeLinkToWorkflow": false
}
},
"typeVersion": 2.2
},
{
"id": "520b6dfa-05fa-45cd-b75d-2536c64a801e",
"name": "Slack All Clear",
"type": "n8n-nodes-base.slack",
"position": [
1104,
720
],
"parameters": {
"text": "=Payroll pre-check complete. No discrepancies detected for this pay run. Safe to proceed with disbursement.\n\nChecked: {{ $now.toISO() }}",
"select": "channel",
"channelId": {
"__rl": true,
"mode": "id",
"value": "YOUR_SLACK_CHANNEL_ID"
},
"otherOptions": {
"includeLinkToWorkflow": false
}
},
"typeVersion": 2.2
},
{
"id": "68091f6b-189c-4151-a10d-31c66308be3f",
"name": "Sticky Note Overview",
"type": "n8n-nodes-base.stickyNote",
"position": [
-608,
240
],
"parameters": {
"width": 496,
"height": 688,
"content": "## Payroll Discrepancy Detector & HR Alert\nThis workflow reviews each payroll run before money is disbursed. It compares the current pay run against the previous period for every active employee and flags anomalies such as unexpected pay swings, pay logged with zero hours, duplicate payments, and unmatched records. Critical issues trigger an immediate payroll hold, while all flagged items are logged, emailed to HR, and summarized for Finance. This prevents costly payroll errors and creates a clean audit trail.\n\n### How it Works\n\t- A monthly schedule starts the pre-payroll review before the pay date.\n\t- The current pay run and the previous pay run are pulled from Google Sheets.\n\t- A comparison engine checks every active employee for anomalies.\n\t- Each anomaly is scored as Medium, High, or Critical severity.\n\t- Critical anomalies raise an immediate payroll hold alert in Slack.\n\t- Every flagged record is appended to an audit log sheet.\n\t- HR receives a per-employee review email for each discrepancy.\n\t- A run-level digest is built and posted to the Finance channel.\n\t- If nothing is flagged, an all-clear message confirms it is safe to proceed.\n\n### Setup Steps\n\t1. Connect a Google Sheets account with CurrentPayRun, PreviousPayRun, and AnomalyLog tabs.\n\t2. Set the schedule to run a few days before your pay date.\n\t3. Connect a Slack account and set the alert channel ID.\n\t4. Connect a Gmail account and set the HR recipient address.\n\t5. Confirm your sheet columns include employee_id, name, net_pay, hours, status, and email.\n\t6. Adjust the percentage thresholds in the detection code if needed.\n\t7. Activate the workflow so it runs automatically each period."
},
"typeVersion": 1
},
{
"id": "be4e5a17-9f42-4665-af0b-f5206bab0ea5",
"name": "Sticky Note Section1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-96,
240
],
"parameters": {
"color": 7,
"width": 880,
"height": 688,
"content": "## Step 1: Collect Pay Run Data\n\nA monthly schedule kicks off the review before payday. The workflow then pulls the current pay run and the previous pay run from Google Sheets so the two periods can be compared side by side."
},
"typeVersion": 1
},
{
"id": "6c8c846f-0c3e-4bd2-8006-9e4f9d810b7b",
"name": "Sticky Note Section2",
"type": "n8n-nodes-base.stickyNote",
"position": [
800,
240
],
"parameters": {
"color": 7,
"width": 880,
"height": 688,
"content": "## Step 2: Detect & Classify Anomalies\n\nThe comparison engine checks every active employee for pay swings, pay without hours, duplicates, and unmatched records. Each flagged record is scored Medium, High, or Critical, and the workflow branches based on whether the run contains any anomalies at all."
},
"typeVersion": 1
},
{
"id": "342268c3-f26b-45fe-804b-354347a94e7f",
"name": "Sticky Note Section3",
"type": "n8n-nodes-base.stickyNote",
"position": [
1696,
240
],
"parameters": {
"color": 7,
"width": 960,
"height": 688,
"content": "## Step 3: Alert, Log & Report\n\nCritical issues raise an immediate payroll hold in Slack. Every anomaly is logged to an audit sheet and emailed to HR for review, then rolled up into a single digest for Finance. If nothing is flagged, an all-clear message confirms the run is safe to release."
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "9d4823ae-fd87-4c17-a34d-475e89a61b2c",
"connections": {
"Has Anomalies": {
"main": [
[
{
"node": "Classify Severity",
"type": "main",
"index": 0
}
],
[
{
"node": "Slack All Clear",
"type": "main",
"index": 0
}
]
]
},
"Email HR Review": {
"main": [
[
{
"node": "Build Run Digest",
"type": "main",
"index": 0
}
]
]
},
"Build Run Digest": {
"main": [
[
{
"node": "Slack Digest to Finance",
"type": "main",
"index": 0
}
]
]
},
"Classify Severity": {
"main": [
[
{
"node": "Is Critical Block",
"type": "main",
"index": 0
}
]
]
},
"Is Critical Block": {
"main": [
[
{
"node": "Slack Critical Hold",
"type": "main",
"index": 0
}
],
[
{
"node": "Log Anomaly to Sheet",
"type": "main",
"index": 0
}
]
]
},
"Get Current Pay Run": {
"main": [
[
{
"node": "Get Previous Pay Run",
"type": "main",
"index": 0
}
]
]
},
"Slack Critical Hold": {
"main": [
[
{
"node": "Log Anomaly to Sheet",
"type": "main",
"index": 0
}
]
]
},
"Detect Discrepancies": {
"main": [
[
{
"node": "Has Anomalies",
"type": "main",
"index": 0
}
]
]
},
"Get Previous Pay Run": {
"main": [
[
{
"node": "Detect Discrepancies",
"type": "main",
"index": 0
}
]
]
},
"Log Anomaly to Sheet": {
"main": [
[
{
"node": "Email HR Review",
"type": "main",
"index": 0
}
]
]
},
"Monthly Pre-Payroll Trigger": {
"main": [
[
{
"node": "Get Current Pay Run",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs monthly to compare current vs. previous payroll data in Google Sheets, detect pay anomalies, alert Finance in Slack, email HR via Gmail, and log discrepancies back to a Google Sheets audit tab. Runs on a monthly schedule (cron: 0 9 25 ) to start a pre-payroll…
Source: https://n8n.io/workflows/16197/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This workflow automatically scans AWS accounts for orphaned resources (unattached EBS volumes, old snapshots >90 days, unassociated Elastic IPs) that waste money. It calculates cost impact, validat
Streamline IT and operations change management by automating approval routing, Jira issue creation, audit logging, and real-time Slack alerts. This workflow ensures faster reviews, traceable approvals
Streamline IT and operations change management by automating approval routing, Jira issue creation, audit logging, and real-time Slack alerts. This workflow ensures faster reviews, traceable approvals
Automate your GoHighLevel (GHL) pipeline tracking and deal management process. This workflow fetches all opportunities, calculates the time spent in each stage, logs historical pipeline data in Google
Automatically consolidate Zendesk and Freshdesk ticket data into a unified performance dashboard with KPI calculations, Google Sheets logging, real-time Slack alerts, and weekly Gmail email reports. P