AutomationFlowsEmail & Gmail › Archive Hr and Legal Documents with Uploadtourl, Google Drive and Airtable

Archive Hr and Legal Documents with Uploadtourl, Google Drive and Airtable

ByJitesh Dugar @jiteshdugar on n8n.io

Eliminate the manual chaos of HR and legal document management. This workflow automates the transition from a raw document upload to a structured, audit-ready archive by combining UploadToURL for instant CDN hosting, Google Drive for long-term storage, and Airtable for status…

Webhook trigger★★★★★ complexity32 nodesAirtableN8N Nodes UploadtourlGoogle DriveGmail
Email & Gmail Trigger: Webhook Nodes: 32 Complexity: ★★★★★ Added:

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

This workflow follows the Airtable → Gmail 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
{
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "nodes": [
    {
      "id": "2b43c48f-48fd-449d-89d8-4350be2d3f4b",
      "name": "\ud83d\udccb Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -384,
        -1248
      ],
      "parameters": {
        "width": 640,
        "height": 1600,
        "content": "# \ud83d\uddc2\ufe0f Legal & Compliance Document Archiving\n\n**The problem this solves:**\nHR managers receive signed contracts via email, WhatsApp, or phone scans \u2014 then manually rename files, drag them into the right Drive folder, and tick a checkbox in their HR system. Files get lost, folders stay inconsistent, and audit trails are weak. This workflow automates the entire chain.\n\n---\n\n## \u2699\ufe0f How it works\n1. **Webhook** receives a document upload (binary scan or remote URL) + employee metadata\n2. **Validator** sanitises all inputs \u2014 normalises employee name, generates a structured filename with date stamp, resolves the correct Drive folder path\n3. **Upload to URL** instantly hosts the document and returns a clean public CDN link\n4. **Google Drive** uploads the file into a structured folder: `HR / Contracts / {Year} / {EmployeeName}/`  \u2014 creates the folder if it doesn't exist\n5. **Airtable** looks up the employee record, ticks `Contract Received`, logs the Drive URL, file name, and timestamp\n6. **Duplicate Check** flags if a contract for this employee already exists in Airtable\n7. **Notifier** sends a confirmation email to HR + the employee\n8. **Webhook Response** returns the full archiving summary\n\n---\n\n## \ud83d\udd10 Required Credentials\n| Credential | Node |\n|---|---|\n| `UploadToURL API` | Upload to URL nodes |\n| `Google Drive OAuth2` | Drive folder + file upload |\n| `Airtable API` | Record lookup + update |\n| `Gmail / SMTP` | Confirmation email |\n\n---\n\n## \ud83d\udd27 Setup Checklist\n- [ ] Install **Upload to URL** community node via npm\n- [ ] Set `UploadToURL API` credentials\n- [ ] Connect `Google Drive OAuth2` credentials\n- [ ] Set n8n variable `GDRIVE_ROOT_FOLDER_ID` to your HR root folder ID in Drive\n- [ ] Connect `Airtable API` token\n- [ ] Set n8n variable `AIRTABLE_BASE_ID` and `AIRTABLE_TABLE_NAME`\n- [ ] Connect `Gmail` or `SMTP` credentials for confirmation emails\n- [ ] Activate workflow and copy the webhook URL\n\n---\n\n## \ud83d\udcec Example Webhook Payload\n```json\n{\n  \"fileUrl\": \"https://cdn.example.com/contract-signed.pdf\",\n  \"filename\": \"contract-signed.pdf\",\n  \"employeeName\": \"Sarah Johnson\",\n  \"employeeId\": \"EMP-0042\",\n  \"employeeEmail\": \"sarah.johnson@company.com\",\n  \"contractType\": \"Full-Time Employment\",\n  \"department\": \"Engineering\",\n  \"effectiveDate\": \"2025-03-01\",\n  \"notifyEmployee\": true\n}\n```\n\n> \ud83d\udca1 **Tip:** Send binary PDF via `multipart/form-data` with field name `file` to skip the `fileUrl` requirement."
      },
      "typeVersion": 1
    },
    {
      "id": "56ea1858-57e2-40f4-8d80-de36f8fdd700",
      "name": "Entry & Duplicate Check",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        608,
        -128
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 502,
        "content": "## \ud83d\udeaa Entry, Validation & Duplicate Check\n**Nodes:** Webhook \u2192 Validate & Enrich \u2192 Airtable Duplicate Check \u2192 IF Duplicate?\n\n- Accepts `POST` with employee metadata + either `fileUrl` or binary PDF/image\n- Normalises employee name (title-case, strips special chars), generates a structured filename: `{EmployeeID}_{LastName}_{ContractType}_{YYYY-MM-DD}.pdf`\n- Builds the full Drive folder path: `HR/Contracts/{Year}/{Department}/{EmployeeName}/`\n- **Duplicate check runs first** \u2014 searches Airtable for an existing record with `Contract Received = true` for this employee before uploading anything, saving API quota and preventing double-filing\n- IF branch halts the workflow and returns a `409 Conflict` if a contract already exists"
      },
      "typeVersion": 1
    },
    {
      "id": "7adcde7a-ad34-4f2f-b26c-3e6deed5d9b6",
      "name": "Upload Chain",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1504,
        -208
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 780,
        "content": "## \u2601\ufe0f Upload to URL \u2192 Google Drive\n**Nodes:** Has Remote URL? \u2192 Upload to URL (\u00d72) \u2192 Extract Doc URL \u2192 Get/Create Drive Folder \u2192 Upload to Drive\n\n- Native **Upload to URL** node hosts the file first \u2014 this gives us a stable CDN URL to store in Airtable regardless of Drive sharing settings\n- `Get/Create Drive Folder` checks if the employee's subfolder already exists under the year/department path; creates it if not \u2014 no manual folder management needed\n- Drive upload uses the structured filename and sets `mimeType` correctly for PDF vs image scans\n- Both the CDN URL (UploadToURL) and the Drive file ID are passed downstream for full redundancy"
      },
      "typeVersion": 1
    },
    {
      "id": "c4495c99-6e3b-4753-a4ec-b8498d447661",
      "name": "Airtable Update",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3936,
        -192
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 770,
        "content": "## \ud83d\uddc3\ufe0f Airtable Update\n**Nodes:** Airtable - Find Employee \u2192 Airtable - Update Record\n\n- `Find Employee` searches by `Employee ID` field first, falls back to name match\n- `Update Record` ticks `Contract Received` checkbox, writes `Contract URL` (Drive link), `CDN Backup URL` (UploadToURL link), `Contract Type`, `Effective Date`, `Filed By`, and `Filed At` timestamp\n- If no Airtable record is found, a **new record is created** automatically so no employee needs to be pre-entered\n- Both Drive URL and the raw UploadToURL CDN link are stored \u2014 providing a redundant access path for auditors"
      },
      "typeVersion": 1
    },
    {
      "id": "fd814fd6-732a-407d-896f-c5953038a326",
      "name": "Notification & Response",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4480,
        -160
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 728,
        "content": "## \ud83d\udce7 Notification & Response\n**Nodes:** Send Confirmation Email \u2192 Build Final Response \u2192 Respond to Webhook\n\n- Sends a confirmation email to the HR manager (always) and optionally to the employee if `notifyEmployee: true`\n- Email includes: Drive folder link, CDN backup URL, structured filename, contract type, effective date\n- `Build Final Response` normalises the full archiving summary: Drive file ID, folder path, Airtable record ID, both URLs, and a `auditTrail` object\n- Returns `201 Created` with the complete record \u2014 ready to pipe into a broader HRIS or compliance dashboard"
      },
      "typeVersion": 1
    },
    {
      "id": "01cd1834-06a3-4431-bcac-56a665891e3a",
      "name": "Error & Conflict Handling",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1184,
        656
      ],
      "parameters": {
        "color": 7,
        "width": 460,
        "height": 415,
        "content": "## \u26a0\ufe0f Error & Conflict Handling\n**Nodes:** Duplicate Response (409) \u2192 Error Handler \u2192 Error Response (400)\n\n- `409 Conflict` is returned immediately if a contract already exists for the employee \u2014 prevents accidental overwrites during re-submissions\n- General `Error Handler` catches all other upstream failures (Drive API errors, Airtable timeouts, upload failures) and returns a structured `400` with the error message\n- Connect any node's **error output** to the Error Handler node"
      },
      "typeVersion": 1
    },
    {
      "id": "5891f4a4-2d75-4783-af17-f312874287ed",
      "name": "Webhook - Receive Contract",
      "type": "n8n-nodes-base.webhook",
      "position": [
        688,
        240
      ],
      "parameters": {
        "path": "hr-contract-archive",
        "options": {
          "allowedOrigins": "*"
        },
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "05264ff6-76ae-4def-aba1-e88cf91e62c8",
      "name": "Validate & Enrich Payload",
      "type": "n8n-nodes-base.code",
      "position": [
        912,
        240
      ],
      "parameters": {
        "jsCode": "const body = $input.first().json.body || $input.first().json;\n\n// \u2500\u2500 Required field guards \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif (!body.employeeName || body.employeeName.trim() === '') {\n  throw new Error('Missing required field: employeeName');\n}\nif (!body.fileUrl && !body.filename) {\n  throw new Error('Provide either fileUrl (remote document) or filename (binary upload).');\n}\n\n// \u2500\u2500 Name normalisation (Title Case, strip special chars) \u2500\u2500\u2500\u2500\u2500\u2500\nconst normaliseName = (raw) => {\n  return raw\n    .trim()\n    .replace(/[^a-zA-Z\\s\\-']/g, '')\n    .replace(/\\s+/g, ' ')\n    .split(' ')\n    .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())\n    .join(' ');\n};\nconst employeeName = normaliseName(body.employeeName);\nconst nameParts = employeeName.split(' ');\nconst lastName = nameParts[nameParts.length - 1];\nconst firstName = nameParts[0];\n\n// \u2500\u2500 Employee ID \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst employeeId = (body.employeeId || '').toUpperCase().replace(/[^A-Z0-9\\-]/g, '') || `EMP-${Date.now()}`;\n\n// \u2500\u2500 Contract type slug \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst contractType = body.contractType || 'Employment Contract';\nconst contractTypeSlug = contractType.replace(/\\s+/g, '-').replace(/[^a-zA-Z0-9\\-]/g, '').toUpperCase();\n\n// \u2500\u2500 Date handling \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst effectiveDate = body.effectiveDate || new Date().toISOString().split('T')[0];\nconst filedAt = new Date().toISOString();\nconst yearFolder = effectiveDate.split('-')[0];\n\n// \u2500\u2500 Filename \u2014 structured for audit trail \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst originalFilename = body.filename || body.fileUrl?.split('?')[0].split('/').pop() || 'document.pdf';\nconst ext = originalFilename.split('.').pop()?.toLowerCase() || 'pdf';\nconst allowedExts = ['pdf', 'jpg', 'jpeg', 'png', 'docx', 'tiff'];\nif (!allowedExts.includes(ext)) {\n  throw new Error(`File type .${ext} not allowed. Accepted: ${allowedExts.join(', ')}`);\n}\nconst structuredFilename = `${employeeId}_${lastName.toUpperCase()}_${contractTypeSlug}_${effectiveDate}.${ext}`;\n\n// \u2500\u2500 MIME type map \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst mimeMap = {\n  pdf: 'application/pdf',\n  jpg: 'image/jpeg', jpeg: 'image/jpeg',\n  png: 'image/png',\n  docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n  tiff: 'image/tiff'\n};\nconst mimeType = mimeMap[ext] || 'application/octet-stream';\n\n// \u2500\u2500 Drive folder path \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst department = body.department || 'General';\nconst driveFolderPath = `HR/Contracts/${yearFolder}/${department}/${employeeName}`;\n\nreturn [{\n  json: {\n    // Source\n    fileUrl: body.fileUrl || null,\n    originalFilename,\n    structuredFilename,\n    mimeType,\n    ext,\n    // Employee\n    employeeName,\n    firstName,\n    lastName,\n    employeeId,\n    employeeEmail: body.employeeEmail || '',\n    department,\n    // Contract\n    contractType,\n    contractTypeSlug,\n    effectiveDate,\n    // Drive\n    driveFolderPath,\n    yearFolder,\n    rootFolderId: $vars.GDRIVE_ROOT_FOLDER_ID || 'root',\n    // Flags\n    notifyEmployee: body.notifyEmployee !== false,\n    filedBy: body.filedBy || 'HR System',\n    filedAt\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "aa8b3b42-5bc3-4a02-ba8f-5e2d85642f3c",
      "name": "Airtable - Duplicate Check",
      "type": "n8n-nodes-base.airtable",
      "notes": "Searches for existing contract record before uploading. Prevents duplicate filings and saves Drive quota.",
      "position": [
        1120,
        240
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {
          "fields": [
            "Employee ID",
            "Employee Name",
            "Contract Received",
            "Contract URL",
            "Filed At"
          ]
        },
        "operation": "search",
        "filterByFormula": "=AND({Employee ID} = '{{ $json.employeeId }}', {Contract Received} = TRUE())"
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "51a5a374-b4d9-45ab-8166-55dea3242ee4",
      "name": "IF Duplicate Exists?",
      "type": "n8n-nodes-base.if",
      "position": [
        1344,
        240
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-duplicate",
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "leftValue": "={{ $json.records?.length ?? 0 }}",
              "rightValue": 0
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "ec542ad4-017f-4e4a-a5c5-093374be5b1f",
      "name": "Respond - 409 Duplicate",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        1552,
        128
      ],
      "parameters": {
        "options": {
          "responseCode": 409,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "json",
        "responseBody": "={\n  \"success\": false,\n  \"conflict\": true,\n  \"message\": \"A contract for employee {{ $('Validate & Enrich Payload').first().json.employeeName }} ({{ $('Validate & Enrich Payload').first().json.employeeId }}) is already filed. Existing record: {{ $json.records[0].fields['Contract URL'] }}\",\n  \"existingContractUrl\": \"{{ $json.records[0].fields['Contract URL'] }}\",\n  \"filedAt\": \"{{ $json.records[0].fields['Filed At'] }}\"\n}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "adbf5a1e-f466-412e-92a5-f55ded178cf8",
      "name": "Has Remote URL?",
      "type": "n8n-nodes-base.if",
      "position": [
        1552,
        352
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-fileurl",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $('Validate & Enrich Payload').first().json.fileUrl }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "2c1a9f2b-46a4-4491-8149-e8ff23b21881",
      "name": "Upload to URL - Remote",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        1760,
        224
      ],
      "parameters": {
        "operation": "uploadFile"
      },
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "7fbca76a-eda4-48b8-9c44-400214202fe4",
      "name": "Upload to URL - Binary",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        1760,
        464
      ],
      "parameters": {
        "operation": "uploadFile"
      },
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "334da8a8-b8b9-4c45-9a33-ab93f64a6ced",
      "name": "Extract CDN URL",
      "type": "n8n-nodes-base.code",
      "position": [
        1984,
        352
      ],
      "parameters": {
        "jsCode": "const uploadResp = $input.first().json;\nconst meta = $('Validate & Enrich Payload').first().json;\n\nconst cdnUrl =\n  uploadResp.url ||\n  uploadResp.link ||\n  uploadResp.data?.url ||\n  uploadResp.file?.url ||\n  uploadResp.shortUrl;\n\nif (!cdnUrl) {\n  throw new Error('Upload to URL returned no public URL. Response: ' + JSON.stringify(uploadResp));\n}\n\nreturn [{\n  json: {\n    ...meta,\n    cdnUrl: cdnUrl.replace(/^http:\\/\\//, 'https://'),\n    uploadId: uploadResp.id || uploadResp.data?.id || null,\n    fileSizeBytes: uploadResp.size || uploadResp.data?.size || null\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "e82aac5b-6551-4415-9e87-288205dd1e68",
      "name": "Drive - Find Employee Folder",
      "type": "n8n-nodes-base.googleDrive",
      "notes": "Searches for an existing employee subfolder. Result passed to the Create Folder or Use Existing branch.",
      "position": [
        2208,
        240
      ],
      "parameters": {
        "operation": "search"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "0ca63735-0c28-4ae3-a54c-dd0fe1dba37d",
      "name": "Resolve Folder ID",
      "type": "n8n-nodes-base.code",
      "position": [
        2432,
        240
      ],
      "parameters": {
        "jsCode": "// Decide whether to create a new folder or use the existing one\nconst searchResult = $input.first().json;\nconst meta = $('Extract CDN URL').first().json;\n\n// Google Drive search returns items array\nconst existingFolder = searchResult.files?.[0] || searchResult.items?.[0] || null;\nconst folderId = existingFolder?.id || null;\n\nreturn [{\n  json: {\n    ...meta,\n    existingFolderId: folderId,\n    needsNewFolder: !folderId\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "61329861-d0c7-415b-99b4-4b7a1cca5b41",
      "name": "Needs New Folder?",
      "type": "n8n-nodes-base.if",
      "position": [
        2640,
        240
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-newfolder",
              "operator": {
                "type": "boolean",
                "operation": "true"
              },
              "leftValue": "={{ $json.needsNewFolder }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "f82d1330-66de-4517-ac66-925df674384e",
      "name": "Drive - Create Employee Folder",
      "type": "n8n-nodes-base.googleDrive",
      "notes": "Creates the employee subfolder under HR/Contracts/{Year}/{Department}/. Only runs if folder doesn't already exist.",
      "position": [
        2864,
        128
      ],
      "parameters": {
        "operation": "createFolder"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "cae0e67c-2fd0-4c8a-a3b8-0ddc5a455d7f",
      "name": "Merge Folder ID",
      "type": "n8n-nodes-base.code",
      "position": [
        3088,
        240
      ],
      "parameters": {
        "jsCode": "// Merge the folder ID from either path (new or existing)\nconst input = $input.first().json;\nconst meta = $('Resolve Folder ID').first().json;\n\n// If we just created a folder, the response has the new folder's id\nconst targetFolderId =\n  input.id ||               // newly created folder\n  meta.existingFolderId;    // pre-existing folder\n\nif (!targetFolderId) {\n  throw new Error('Could not resolve a Google Drive folder ID for upload.');\n}\n\nreturn [{\n  json: {\n    ...meta,\n    targetFolderId\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "cf175356-ab80-4d3e-a24e-5ca30310f6c2",
      "name": "Drive - Upload Document",
      "type": "n8n-nodes-base.googleDrive",
      "notes": "Uploads the document to the resolved employee folder using the structured filename. Sets correct MIME type for PDF vs image.",
      "position": [
        3312,
        240
      ],
      "parameters": {
        "name": "={{ $json.structuredFilename }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "MyDrive"
        },
        "options": {
          "ocrLanguage": ""
        },
        "folderId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.targetFolderId }}"
        }
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "ce49884a-6f07-41a5-b1ff-4ff323160002",
      "name": "Airtable - Find Employee Record",
      "type": "n8n-nodes-base.airtable",
      "position": [
        3520,
        240
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {
          "fields": [
            "Employee ID",
            "Employee Name",
            "Department",
            "Contract Received"
          ]
        },
        "operation": "search",
        "filterByFormula": "={Employee ID} = '{{ $('Merge Folder ID').first().json.employeeId }}'"
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "9cca6163-dc9d-46ca-9531-fdfe6fc031fe",
      "name": "Prepare Airtable Update",
      "type": "n8n-nodes-base.code",
      "position": [
        3744,
        240
      ],
      "parameters": {
        "jsCode": "const airtableResp = $input.first().json;\nconst meta = $('Merge Folder ID').first().json;\nconst driveResp = $('Drive - Upload Document').first().json;\n\n// Build Drive shareable URL from file ID\nconst driveFileId = driveResp.id;\nconst driveUrl = driveFileId\n  ? `https://drive.google.com/file/d/${driveFileId}/view`\n  : null;\n\nconst existingRecord = airtableResp.records?.[0] || null;\n\nreturn [{\n  json: {\n    ...meta,\n    driveFileId,\n    driveUrl,\n    airtableRecordId: existingRecord?.id || null,\n    airtableRecordExists: !!existingRecord\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "90abc179-346a-4a82-97e7-6c2bc7c9fdbc",
      "name": "Airtable Record Exists?",
      "type": "n8n-nodes-base.if",
      "position": [
        3968,
        240
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-record-exists",
              "operator": {
                "type": "boolean",
                "operation": "true"
              },
              "leftValue": "={{ $json.airtableRecordExists }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "57d9299a-20e1-4075-b2d2-2d31f3102ca7",
      "name": "Airtable - Update Existing Record",
      "type": "n8n-nodes-base.airtable",
      "notes": "Updates the existing employee record: ticks Contract Received, writes Drive URL, CDN backup URL, filename, effective date, and filing metadata.",
      "position": [
        4192,
        128
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "value": {
            "Filed At": "={{ $json.filedAt }}",
            "Filed By": "={{ $json.filedBy }}",
            "Contract URL": "={{ $json.driveUrl }}",
            "Contract Type": "={{ $json.contractType }}",
            "CDN Backup URL": "={{ $json.cdnUrl }}",
            "Effective Date": "={{ $json.effectiveDate }}",
            "Contract Received": true,
            "Drive Folder Path": "={{ $json.driveFolderPath }}",
            "Structured Filename": "={{ $json.structuredFilename }}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "update"
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "30b1753c-763c-4b50-9905-2756f6e85bba",
      "name": "Airtable - Create New Record",
      "type": "n8n-nodes-base.airtable",
      "notes": "Creates a brand new Airtable record if the employee wasn't pre-entered. No manual Airtable setup needed before first use.",
      "position": [
        4192,
        368
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "value": {
            "Filed At": "={{ $json.filedAt }}",
            "Filed By": "={{ $json.filedBy }}",
            "Department": "={{ $json.department }}",
            "Employee ID": "={{ $json.employeeId }}",
            "Contract URL": "={{ $json.driveUrl }}",
            "Contract Type": "={{ $json.contractType }}",
            "Employee Name": "={{ $json.employeeName }}",
            "CDN Backup URL": "={{ $json.cdnUrl }}",
            "Effective Date": "={{ $json.effectiveDate }}",
            "Contract Received": true,
            "Drive Folder Path": "={{ $json.driveFolderPath }}",
            "Structured Filename": "={{ $json.structuredFilename }}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "create"
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "c92fc87d-1dc2-423e-971e-3b3d6b548759",
      "name": "Merge Airtable Result",
      "type": "n8n-nodes-base.code",
      "position": [
        4400,
        240
      ],
      "parameters": {
        "jsCode": "// Normalise output from both Airtable branches\nconst airtableResp = $input.first().json;\nconst meta = $('Prepare Airtable Update').first().json;\n\nconst airtableRecordId = airtableResp.id || meta.airtableRecordId;\n\nreturn [{\n  json: {\n    ...meta,\n    airtableRecordId,\n    airtableUpdated: true\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "c69bd238-b672-4de2-80e7-64548b2f1fba",
      "name": "Send Confirmation Email",
      "type": "n8n-nodes-base.gmail",
      "notes": "Sends a formatted HTML confirmation to HR (always) and optionally to the employee. Includes Drive link, CDN backup, structured filename, and filing metadata.",
      "position": [
        4624,
        240
      ],
      "parameters": {
        "operation": "sendEmail"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "44c9e268-f8f9-4e76-93c1-f3e92324145f",
      "name": "Build Final Response",
      "type": "n8n-nodes-base.code",
      "position": [
        4848,
        240
      ],
      "parameters": {
        "jsCode": "const emailResp = $input.first().json;\nconst data = $('Merge Airtable Result').first().json;\n\nreturn [{\n  json: {\n    success: true,\n    message: `Contract for ${data.employeeName} successfully archived.`,\n    // Core identifiers\n    employeeId: data.employeeId,\n    employeeName: data.employeeName,\n    department: data.department,\n    // Document\n    structuredFilename: data.structuredFilename,\n    contractType: data.contractType,\n    effectiveDate: data.effectiveDate,\n    // Storage\n    driveFileId: data.driveFileId,\n    driveUrl: data.driveUrl,\n    driveFolderPath: data.driveFolderPath,\n    cdnBackupUrl: data.cdnUrl,\n    // Airtable\n    airtableRecordId: data.airtableRecordId,\n    // Audit trail\n    auditTrail: {\n      filedBy: data.filedBy,\n      filedAt: data.filedAt,\n      uploadId: data.uploadId,\n      fileSizeBytes: data.fileSizeBytes\n    }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "42c588d7-d455-4357-9fc1-9d16ac39a101",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        5072,
        240
      ],
      "parameters": {
        "options": {
          "responseCode": 201,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "json",
        "responseBody": "={{ $json }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "5de2d9b8-930f-4837-8d83-34c75eabf316",
      "name": "Error Handler",
      "type": "n8n-nodes-base.code",
      "position": [
        1248,
        928
      ],
      "parameters": {
        "jsCode": "const err = $input.first();\nconst msg = err.json?.message || err.error?.message || err.json?.error || 'Unexpected error in document archiving workflow';\nconsole.error('[HR Archive] Error:', msg);\nreturn [{ json: { success: false, error: msg, timestamp: new Date().toISOString() } }];"
      },
      "typeVersion": 2,
      "alwaysOutputData": true
    },
    {
      "id": "6dddfd8d-2de7-4a97-8806-e8a16c5e262b",
      "name": "Respond with Error",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        1456,
        928
      ],
      "parameters": {
        "options": {
          "responseCode": 400,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "json",
        "responseBody": "={{ $json }}"
      },
      "typeVersion": 1.1
    }
  ],
  "connections": {
    "Error Handler": {
      "main": [
        [
          {
            "node": "Respond with Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract CDN URL": {
      "main": [
        [
          {
            "node": "Drive - Find Employee Folder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Remote URL?": {
      "main": [
        [
          {
            "node": "Upload to URL - Remote",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Upload to URL - Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Folder ID": {
      "main": [
        [
          {
            "node": "Drive - Upload Document",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Needs New Folder?": {
      "main": [
        [
          {
            "node": "Drive - Create Employee Folder",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Merge Folder ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resolve Folder ID": {
      "main": [
        [
          {
            "node": "Needs New Folder?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Final Response": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Duplicate Exists?": {
      "main": [
        [
          {
            "node": "Respond - 409 Duplicate",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Has Remote URL?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Airtable Result": {
      "main": [
        [
          {
            "node": "Send Confirmation Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to URL - Binary": {
      "main": [
        [
          {
            "node": "Extract CDN URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to URL - Remote": {
      "main": [
        [
          {
            "node": "Extract CDN URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable Record Exists?": {
      "main": [
        [
          {
            "node": "Airtable - Update Existing Record",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Airtable - Create New Record",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drive - Upload Document": {
      "main": [
        [
          {
            "node": "Airtable - Find Employee Record",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Airtable Update": {
      "main": [
        [
          {
            "node": "Airtable Record Exists?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Confirmation Email": {
      "main": [
        [
          {
            "node": "Build Final Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate & Enrich Payload": {
      "main": [
        [
          {
            "node": "Airtable - Duplicate Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable - Duplicate Check": {
      "main": [
        [
          {
            "node": "IF Duplicate Exists?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook - Receive Contract": {
      "main": [
        [
          {
            "node": "Validate & Enrich Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable - Create New Record": {
      "main": [
        [
          {
            "node": "Merge Airtable Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drive - Find Employee Folder": {
      "main": [
        [
          {
            "node": "Resolve Folder ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drive - Create Employee Folder": {
      "main": [
        [
          {
            "node": "Merge Folder ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable - Find Employee Record": {
      "main": [
        [
          {
            "node": "Prepare Airtable Update",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable - Update Existing Record": {
      "main": [
        [
          {
            "node": "Merge Airtable Result",
            "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

Eliminate the manual chaos of HR and legal document management. This workflow automates the transition from a raw document upload to a structured, audit-ready archive by combining UploadToURL for instant CDN hosting, Google Drive for long-term storage, and Airtable for status…

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

More Email & Gmail workflows → · Browse all categories →

Related workflows

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

Email & Gmail

Recruiting agency. Uses typeformTrigger, airtable, httpRequest, googleDrive. Event-driven trigger; 36 nodes.

Typeform Trigger, Airtable, HTTP Request +4
Email & Gmail

Streamline and standardize your entire client onboarding process with a single end-to-end automation. 🚀📋 This workflow captures detailed client intake data via webhook, automatically creates a fully s

Slack, Asana, HTTP Request +4
Email & Gmail

Automate your GoHighLevel (GHL) client onboarding process from the moment a deal is marked as “Won.” This workflow seamlessly generates client folders in Google Drive, duplicates contract and kickoff

Google Drive, Slack, Gmail +2
Email & Gmail

Invoice_Workflow. Uses googleSheets, googleDrive, googleDocs, gmail. Webhook trigger; 19 nodes.

Google Sheets, Google Drive, Google Docs +4
Email & Gmail

Creating and sending invoices manually is a major administrative bottleneck. It's not only slow but also prone to human error, such as creating duplicate invoice numbers or sending sensitive financial

Google Sheets, @Pdfgeneratorapi/N8N Nodes Pdf Generator Api, Google Drive +1