AutomationFlowsSlack & Telegram › Reconcile Daily Google Drive CSV Exports Into a Master File and Send a Slack…

Reconcile Daily Google Drive CSV Exports Into a Master File and Send a Slack…

Original n8n title: Reconcile Daily Google Drive CSV Exports Into a Master File and Send a Slack Recap

ByKevin Yu @exekyute on n8n.io

This workflow runs daily to reconcile multiple CSV exports from a Google Drive folder into a deduplicated master CSV, quarantine invalid or duplicate rows into a reject CSV, and post a run summary to a Slack channel. Runs every day at 06:00 (cron) on a schedule. Searches a…

Cron / scheduled trigger★★★★☆ complexity18 nodesGoogle DriveSlack
Slack & Telegram Trigger: Cron / scheduled Nodes: 18 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #16700 — we link there as the canonical source.

This workflow follows the Google Drive → Slack 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 →

Download .json
{
  "name": "Reconcile daily CSV exports in Google Drive into a deduped master with a reject lane and Slack recap",
  "tags": [],
  "nodes": [
    {
      "name": "Run on Schedule",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        0,
        192
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 6 * * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "name": "List CSV Files in Folder",
      "type": "n8n-nodes-base.googleDrive",
      "maxTries": 3,
      "position": [
        208,
        192
      ],
      "parameters": {
        "filter": {
          "folderId": {
            "__rl": true,
            "mode": "list",
            "value": ""
          },
          "whatToSearch": "files"
        },
        "options": {
          "fields": [
            "id",
            "name",
            "mimeType"
          ]
        },
        "resource": "fileFolder",
        "operation": "search",
        "returnAll": true,
        "queryString": "name contains '.csv' and not name contains 'reconciled-'",
        "searchMethod": "query",
        "authentication": "oAuth2"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 3,
      "waitBetweenTries": 5000
    },
    {
      "name": "Download Each CSV",
      "type": "n8n-nodes-base.googleDrive",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        416,
        192
      ],
      "parameters": {
        "fileId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.id }}"
        },
        "options": {
          "binaryPropertyName": "data"
        },
        "resource": "file",
        "operation": "download",
        "authentication": "oAuth2"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 3,
      "alwaysOutputData": true,
      "waitBetweenTries": 5000
    },
    {
      "name": "Parse CSV Rows",
      "type": "n8n-nodes-base.extractFromFile",
      "onError": "continueRegularOutput",
      "position": [
        624,
        192
      ],
      "parameters": {
        "options": {
          "headerRow": true
        },
        "operation": "csv",
        "binaryPropertyName": "data"
      },
      "typeVersion": 1.1
    },
    {
      "name": "Reconcile CSVs",
      "type": "n8n-nodes-base.code",
      "position": [
        992,
        224
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// =====================================================================\n// EDIT HERE: reconciliation rules (the only block most people change)\n// =====================================================================\n// Every row must have these columns present and non-empty. Change them to\n// match your export. The dedup key below must be one of these columns.\nconst REQUIRED_COLUMNS = ['order_id', 'branch', 'sku', 'qty', 'amount', 'order_date'];\n\n// Rows are deduplicated on this column. The first row wins; later rows with\n// the same value are sent to the reject file as duplicates.\nconst KEY_COLUMN = 'order_id';\n\n// Light format checks: 'integer', 'number', or 'date' (YYYY-MM-DD). A column\n// not listed here is only checked for being present and non-empty.\nconst COLUMN_TYPES = { qty: 'integer', amount: 'number', order_date: 'date' };\n\n// Output files are named <prefix>-master-YYYY-MM-DD.csv and\n// <prefix>-rejects-YYYY-MM-DD.csv. The List node skips this prefix, so the\n// outputs are never read back in on the next run.\nconst OUTPUT_PREFIX = 'reconciled';\n// =====================================================================\n// No need to edit below this line.\n// =====================================================================\n\nfunction pairedIndex(it) {\n  const p = it && it.pairedItem;\n  if (p == null) return null;\n  if (typeof p === 'number') return p;\n  if (Array.isArray(p)) return (p[0] && typeof p[0].item === 'number') ? p[0].item : null;\n  return (typeof p.item === 'number') ? p.item : null;\n}\nfunction fileNameFor(it, downloadItems) {\n  try {\n    const idx = pairedIndex(it);\n    if (idx == null) return 'unknown';\n    const d = downloadItems[idx];\n    const n = d && d.json && (d.json.name || d.json.fileName);\n    return n ? String(n) : 'unknown';\n  } catch (e) {\n    return 'unknown';\n  }\n}\nfunction isEmpty(v) {\n  return v === undefined || v === null || String(v).trim() === '';\n}\nfunction checkType(val, type) {\n  const s = String(val).trim();\n  if (type === 'integer') return /^-?\\d+$/.test(s);\n  if (type === 'number') return s !== '' && !isNaN(Number(s)) && isFinite(Number(s));\n  if (type === 'date') {\n    if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return false;\n    const d = new Date(s + 'T00:00:00Z');\n    return !isNaN(d.getTime());\n  }\n  return true;\n}\n// Required columns first (in config order), then any other original columns,\n// then the audit columns last so the reason sits at the end of each reject row.\nfunction orderedRow(row, extra) {\n  const out = {};\n  for (const c of REQUIRED_COLUMNS) out[c] = (row[c] !== undefined ? row[c] : '');\n  for (const k of Object.keys(row)) if (!(k in out)) out[k] = row[k];\n  if (extra) for (const k of Object.keys(extra)) out[k] = extra[k];\n  return out;\n}\n\nconst parsedItems = $input.all();\nconst downloadItems = $('Download Each CSV').all();\nconst fileCount = $('List CSV Files in Folder').all().length;\n\nconst now = new Date();\nconst runDate = now.getUTCFullYear() + '-' +\n  String(now.getUTCMonth() + 1).padStart(2, '0') + '-' +\n  String(now.getUTCDate()).padStart(2, '0');\n\nconst master = [];\nconst rejects = [];\nconst seen = new Map(); // key value -> source file of the first kept row\nlet rowsIn = 0;\nlet quarantined = 0;\nlet duplicatesDropped = 0;\nlet unreadableFiles = 0;\n\nfor (const it of parsedItems) {\n  const json = (it && it.json) ? it.json : {};\n  const keys = Object.keys(json);\n\n  // An Extract from File error item (routed back to the main lane) looks like\n  // { error: '<message>' } and nothing else: a file that could not be parsed.\n  const isErrorItem = (typeof json.error === 'string') && keys.length === 1;\n  if (isErrorItem) {\n    unreadableFiles++;\n    rejects.push(orderedRow({}, {\n      source_file: fileNameFor(it, downloadItems),\n      reject_type: 'unreadable_file',\n      reject_reason: 'File could not be parsed as CSV: ' + json.error,\n    }));\n    continue;\n  }\n\n  rowsIn++;\n  const srcFile = fileNameFor(it, downloadItems);\n\n  // ---- validation ----\n  const reasons = [];\n  for (const col of REQUIRED_COLUMNS) {\n    if (isEmpty(json[col])) reasons.push('missing or empty required column \"' + col + '\"');\n  }\n  if (isEmpty(json[KEY_COLUMN]) && !reasons.some(r => r.indexOf('\"' + KEY_COLUMN + '\"') !== -1)) {\n    reasons.push('empty key column \"' + KEY_COLUMN + '\"');\n  }\n  for (const col of Object.keys(COLUMN_TYPES)) {\n    if (!isEmpty(json[col]) && !checkType(json[col], COLUMN_TYPES[col])) {\n      reasons.push('column \"' + col + '\" is not a valid ' + COLUMN_TYPES[col] + ' (got \"' + String(json[col]) + '\")');\n    }\n  }\n\n  if (reasons.length) {\n    quarantined++;\n    rejects.push(orderedRow(json, {\n      source_file: srcFile,\n      reject_type: 'invalid',\n      reject_reason: reasons.join('; '),\n    }));\n    continue;\n  }\n\n  // ---- dedup on the key column, keeping the first occurrence ----\n  const keyVal = String(json[KEY_COLUMN]).trim();\n  if (seen.has(keyVal)) {\n    duplicatesDropped++;\n    rejects.push(orderedRow(json, {\n      source_file: srcFile,\n      reject_type: 'duplicate',\n      reject_reason: 'duplicate ' + KEY_COLUMN + ' \"' + keyVal + '\" (earlier row kept from ' + seen.get(keyVal) + ')',\n    }));\n    continue;\n  }\n  seen.set(keyVal, srcFile);\n  master.push(orderedRow(json, null));\n}\n\nconst merged = master.length;\nconst masterFileName = OUTPUT_PREFIX + '-master-' + runDate + '.csv';\nconst rejectFileName = OUTPUT_PREFIX + '-rejects-' + runDate + '.csv';\n\nconst lines = [];\nlines.push('CSV Folder Reconciler run (' + runDate + ')');\nlines.push('Files read: ' + fileCount);\nlines.push('Rows in: ' + rowsIn);\nlines.push('Merged to master: ' + merged);\nlines.push('Quarantined (invalid): ' + quarantined);\nlines.push('Duplicates dropped: ' + duplicatesDropped);\nif (unreadableFiles > 0) lines.push('Unreadable files: ' + unreadableFiles);\nlines.push(merged > 0\n  ? 'Master file: ' + masterFileName\n  : 'Master file: none written (no valid rows)');\nlines.push(rejects.length > 0\n  ? 'Reject file: ' + rejectFileName + ' (' + rejects.length + ' rows)'\n  : 'Reject file: none written (nothing quarantined)');\nconst recapText = lines.join('\\n');\n\nconst recap = {\n  runDate,\n  filesRead: fileCount,\n  rowsIn,\n  merged,\n  quarantined,\n  duplicatesDropped,\n  unreadableFiles,\n  rejectRows: rejects.length,\n  recapText,\n};\n\nreturn [{ json: { recap, master, rejects, masterFileName, rejectFileName, hasMaster: merged > 0, hasRejects: rejects.length > 0 } }];\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "name": "Master Rows",
      "type": "n8n-nodes-base.code",
      "position": [
        1392,
        144
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Expand the deduplicated master rows into one item per row for the CSV builder.\n// No items means no valid rows, so the master CSV and its upload are skipped.\nreturn ($input.first().json.master || []).map((json) => ({ json }));\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "name": "Build Master CSV",
      "type": "n8n-nodes-base.convertToFile",
      "position": [
        1568,
        144
      ],
      "parameters": {
        "options": {
          "fileName": "={{ $('Reconcile CSVs').first().json.masterFileName }}",
          "headerRow": true
        },
        "operation": "csv",
        "binaryPropertyName": "data"
      },
      "typeVersion": 1.1
    },
    {
      "name": "Upload Master CSV",
      "type": "n8n-nodes-base.googleDrive",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        1760,
        144
      ],
      "parameters": {
        "name": "={{ $('Reconcile CSVs').first().json.masterFileName }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "options": {},
        "folderId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "resource": "file",
        "operation": "upload",
        "authentication": "oAuth2",
        "inputDataFieldName": "data"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 3,
      "waitBetweenTries": 5000
    },
    {
      "name": "Reject Rows",
      "type": "n8n-nodes-base.code",
      "position": [
        1392,
        496
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Expand the quarantined rows (invalid, duplicate, and unreadable files) into\n// one item per row for the reject CSV builder. No items means nothing was\n// quarantined, so the reject CSV and its upload are skipped this run.\nreturn ($input.first().json.rejects || []).map((json) => ({ json }));\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "name": "Build Reject CSV",
      "type": "n8n-nodes-base.convertToFile",
      "position": [
        1568,
        496
      ],
      "parameters": {
        "options": {
          "fileName": "={{ $('Reconcile CSVs').first().json.rejectFileName }}",
          "headerRow": true
        },
        "operation": "csv",
        "binaryPropertyName": "data"
      },
      "typeVersion": 1.1
    },
    {
      "name": "Upload Reject CSV",
      "type": "n8n-nodes-base.googleDrive",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        1760,
        496
      ],
      "parameters": {
        "name": "={{ $('Reconcile CSVs').first().json.rejectFileName }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "options": {},
        "folderId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "resource": "file",
        "operation": "upload",
        "authentication": "oAuth2",
        "inputDataFieldName": "data"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 3,
      "waitBetweenTries": 5000
    },
    {
      "name": "Post Recap to Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        1392,
        688
      ],
      "parameters": {
        "text": "={{ $json.recap.recapText }}",
        "select": "channel",
        "resource": "message",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "operation": "post",
        "messageType": "text",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5
    },
    {
      "name": "Sticky Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -560,
        -160
      ],
      "parameters": {
        "color": 1,
        "width": 480,
        "height": 880,
        "content": "## CSV Folder Reconciler\n\nMerges the daily CSV exports that land in one Google Drive folder into a single deduplicated master file, quarantines every bad row to a dated reject file with a reason, and posts an auditable recap to Slack. Built for multi-branch retail or finance teams whose branches each drop a daily export. No AI, fully deterministic.\n\n### How it works\n1. A schedule fires once a day.\n2. Google Drive lists every CSV in the folder and downloads each one.\n3. Extract from File parses each CSV into rows.\n4. The Reconcile node merges all rows, validates each against your rules, deduplicates on a key, and splits good rows from bad.\n5. Convert to File writes a clean master CSV and a dated reject CSV, both uploaded back to Drive.\n6. Slack receives the rows in, merged, quarantined, and duplicates recap.\n\n### Setup\n1. Connect a Google Drive credential to the four Drive nodes and a Slack credential to the recap node.\n2. In \"List CSV Files in Folder\", pick the folder your exports land in.\n3. In \"Reconcile CSVs\", set the required columns, the dedup key, and the format checks.\n4. In the two upload nodes, pick the folder for the master and reject files.\n5. In \"Post Recap to Slack\", pick the channel.\n6. Run once, then activate."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Read",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -48,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 856,
        "height": 334,
        "content": "## 1. Read the folder\nLists every CSV in the folder (skipping the reconciled- prefix so outputs are never re-read), downloads each, and parses it into rows. A file that will not parse is logged and skipped."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Reconcile",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        832,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 452,
        "height": 366,
        "content": "## 2. Validate, dedup, quarantine\nEach row needs every required column filled, a non-empty key, and valid formats (integer, number, date). Bad rows are quarantined with a reason. Survivors are deduped on the key, keeping the first. Edit the rules at the top of this node."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Master",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1312,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 648,
        "height": 298,
        "content": "## 3a. Clean master CSV\nThe good, deduped rows become reconciled-master-YYYY-MM-DD.csv, uploaded to your output folder. Valid rows only, no audit columns added."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Rejects",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1312,
        304
      ],
      "parameters": {
        "color": 7,
        "width": 648,
        "height": 346,
        "content": "## 3b. Reject lane (quarantine)\nQuarantined rows become reconciled-rejects-YYYY-MM-DD.csv with source_file, reject_type (invalid, duplicate, unreadable_file), and reject_reason columns. Skipped when nothing is quarantined."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Folder Safety",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1312,
        -208
      ],
      "parameters": {
        "color": 5,
        "width": 640,
        "height": 188,
        "content": "## Keep outputs out of the input\nThe master and reject files use the reconciled- prefix and the List node skips that prefix, so writing them to the same folder is safe. To be extra safe, point the two upload nodes at a separate output folder."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Master Rows": {
      "main": [
        [
          {
            "node": "Build Master CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reject Rows": {
      "main": [
        [
          {
            "node": "Build Reject CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse CSV Rows": {
      "main": [
        [
          {
            "node": "Reconcile CSVs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reconcile CSVs": {
      "main": [
        [
          {
            "node": "Master Rows",
            "type": "main",
            "index": 0
          },
          {
            "node": "Reject Rows",
            "type": "main",
            "index": 0
          },
          {
            "node": "Post Recap to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run on Schedule": {
      "main": [
        [
          {
            "node": "List CSV Files in Folder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Master CSV": {
      "main": [
        [
          {
            "node": "Upload Master CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Reject CSV": {
      "main": [
        [
          {
            "node": "Upload Reject CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Each CSV": {
      "main": [
        [
          {
            "node": "Parse CSV Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List CSV Files in Folder": {
      "main": [
        [
          {
            "node": "Download Each CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow runs daily to reconcile multiple CSV exports from a Google Drive folder into a deduplicated master CSV, quarantine invalid or duplicate rows into a reject CSV, and post a run summary to a Slack channel. Runs every day at 06:00 (cron) on a schedule. Searches a…

Source: https://n8n.io/workflows/16700/ — original creator credit. Request a take-down →

More Slack & Telegram workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Slack & Telegram

Simplify financial oversight with this automated n8n workflow. Triggered daily, it fetches cash flow and expense data from a Google Sheet, analyzes inflows and outflows, validates records, and generat

HTTP Request, Google Sheets, Email Send +3
Slack & Telegram

Streamline your manufacturing quality control process with automated inspection tracking, compliance documentation, and real-time alerts. This workflow eliminates manual QC paperwork while ensuring IS

Google Sheets, Google Drive, Slack +2
Slack & Telegram

This workflow automates the process of tracking and reporting app nominations submitted to Apple for App Store featuring consideration. It connects to the App Store Connect API to fetch your list of a

Google Drive, Slack, Jwt +2
Slack & Telegram

Streamline your month-end accounting processes with this enterprise-grade automation designed to aggregate, validate, and merge fragmented financial documents into a single, professional reporting bun

N8N Nodes Htmlcsstopdf, Google Drive, Slack
Slack & Telegram

Optimize your cloud storage costs by using this automation to intelligently compress and migrate aging project documentation. This workflow allows you to achieve a professional data lifecycle policy b

Google Drive, N8N Nodes Htmlcsstopdf, S3 +1