AutomationFlowsEmail & Gmail › Fast-track Expense Reimbursements with Uploadtourl, Mindee Ocr, Expensify,…

Fast-track Expense Reimbursements with Uploadtourl, Mindee Ocr, Expensify,…

Original n8n title: Fast-track Expense Reimbursements with Uploadtourl, Mindee Ocr, Expensify, and Slack

ByJitesh Dugar @jiteshdugar on n8n.io

Stop chasing blurry receipts and manually typing expense data. This workflow creates an intelligent, "snap-and-submit" reimbursement pipeline that hosts photos via UploadToURL, extracts deep data via Mindee OCR, and utilizes a confidence-based gate to auto-approve low-risk…

Event trigger★★★★☆ complexity22 nodesForm TriggerN8N Nodes UploadtourlHTTP RequestSlackGoogle SheetsGmail
Email & Gmail Trigger: Event Nodes: 22 Complexity: ★★★★☆ Added:

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

This workflow follows the Form Trigger → 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
{
  "nodes": [
    {
      "id": "d78dd4ad-7316-454a-b8c9-7e7d7882d8e3",
      "name": "\ud83d\udccb Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        0
      ],
      "parameters": {
        "width": 560,
        "height": 648,
        "content": "## Fast-track expense reimbursements with UploadToURL, Mindee OCR, and Expensify\nThe Problem: Employees hate manual expense forms, and finance teams waste hours chasing blurry attachments and missing data.\nThe Solution: A \"snap-and-submit\" pipeline that hosts receipt photos via UploadToURL, extracts data via Mindee OCR, and handles auto-approvals via Slack.\n\n\u2699\ufe0f How it Works\nSubmission: Employee uploads a receipt photo via mobile form or Webhook.\n\nUploadToURL: Instantly hosts the photo and returns a permanent CDN link for the audit trail.\n\nMindee OCR: Automatically extracts merchant, total, date, and tax with high precision.\n\nSmart Approval: Low-value items are auto-approved; high-value items trigger a 1-click Slack approval for managers.\n\nLogging: Final data is synced to Expensify and Google Sheets.\n\n\ud83d\udd10 Credentials & Setup\nNode: Install n8n-nodes-uploadtourl via Community Nodes.\n\nAPIs: UploadToURL, Mindee, Expensify, and Slack.\n\nVariables: Set AUTO_APPROVE_THRESHOLD and EXPENSIFY_POLICY_ID"
      },
      "typeVersion": 1
    },
    {
      "id": "8bc2b0d8-6cb8-4f6b-98be-34b9d43ab3c1",
      "name": "Section 1 \u2014 Upload",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        672,
        688
      ],
      "parameters": {
        "color": 7,
        "width": 776,
        "height": 578,
        "content": "## 1 \u2014 Form intake & upload\n\n**Form Trigger \u2192 Validate \u2192 Has URL? \u2192 Upload to URL (\u00d72) \u2192 Extract CDN URL**\n\nMobile-friendly form with fields for employee name, email, manager email, and expense category. Validates email format and file extension (`jpg`, `jpeg`, `png`, `pdf`, `heic`). UploadToURL hosts via the native community node. Filename is structured as `EXP-{timestamp}-{employeeSlug}.ext` for audit traceability."
      },
      "typeVersion": 1
    },
    {
      "id": "88215ffc-3680-4cac-a05d-3235fbee621a",
      "name": "Section 2 \u2014 OCR & Gate",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1504,
        720
      ],
      "parameters": {
        "color": 7,
        "width": 440,
        "height": 519,
        "content": "## 2 \u2014 Mindee OCR & confidence gate\n\n**Mindee Receipt API \u2192 Parse & Score \u2192 Confidence Gate (IF)**\n\nMindee's `/expense_receipts/v5/predict` endpoint returns structured fields with per-field confidence scores. The parse node computes a composite confidence score, flags missing required fields, and evaluates the auto-approve rule: `confidence \u2265 0.85 AND total \u2264 AUTO_APPROVE_THRESHOLD`. Below that threshold routes to auto-approve; above routes to manager."
      },
      "typeVersion": 1
    },
    {
      "id": "924cd41e-9f8b-4d9e-b572-a4b3db687b0d",
      "name": "Section 3 \u2014 Approval",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2064,
        672
      ],
      "parameters": {
        "color": 7,
        "width": 840,
        "height": 631,
        "content": "## 3 \u2014 Dual-path approval\n\n**Auto-approve branch:** Expensify entry created immediately, status set to `approved`, employee emailed with ETA.\n\n**Manager branch:** Slack posts an interactive message with receipt CDN link, extracted data, and Accept/Reject buttons to `MANAGER_SLACK_USER_ID`. On response the Expensify entry is created with the manager's decision. Both paths converge at the Sheets audit log node."
      },
      "typeVersion": 1
    },
    {
      "id": "949db194-c812-4032-868f-ab68d03d00e9",
      "name": "Section 4 \u2014 Audit & Email",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3056,
        704
      ],
      "parameters": {
        "color": 7,
        "width": 548,
        "height": 483,
        "content": "## 4 \u2014 Audit log & confirmation\n\n**Sheets - Append Audit Row \u2192 Gmail - Employee Confirmation**\n\nEvery expense \u2014 regardless of approval path \u2014 is written to the Google Sheet with full OCR data, confidence score, CDN URL, Expensify ID, and resolution timestamp. The employee receives a plain-text email confirming approval or review status and their reimbursement timeline."
      },
      "typeVersion": 1
    },
    {
      "id": "4ac61781-ff01-43b6-873b-b3ca4f72ad75",
      "name": "Form Trigger - Submit Receipt",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        688,
        1008
      ],
      "parameters": {
        "options": {},
        "formTitle": "Expense Reimbursement \u2014 Submit Receipt",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Your Full Name",
              "requiredField": true
            },
            {
              "fieldType": "email",
              "fieldLabel": "Your Email",
              "requiredField": true
            },
            {
              "fieldType": "email",
              "fieldLabel": "Manager Email",
              "requiredField": true
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "Expense Category",
              "fieldOptions": {
                "values": [
                  {
                    "option": "Meals & Entertainment"
                  },
                  {
                    "option": "Travel & Transport"
                  },
                  {
                    "option": "Accommodation"
                  },
                  {
                    "option": "Office Supplies"
                  },
                  {
                    "option": "Software & Subscriptions"
                  },
                  {
                    "option": "Client Gifts"
                  },
                  {
                    "option": "Other"
                  }
                ]
              },
              "requiredField": true
            },
            {
              "fieldLabel": "Receipt Photo or PDF URL",
              "placeholder": "https://... or leave blank and upload below",
              "requiredField": true
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "Business Purpose",
              "placeholder": "e.g. Team lunch with client Acme Corp",
              "requiredField": true
            },
            {
              "fieldLabel": "Project Code",
              "placeholder": "e.g. PROJ-204"
            }
          ]
        },
        "responseMode": "responseNode",
        "formDescription": "Upload your receipt photo or PDF. Expenses under $50 with a clear receipt are approved automatically."
      },
      "typeVersion": 2.2
    },
    {
      "id": "dba8fc24-bf9f-410c-bc78-396fdd5d935e",
      "name": "Validate & Build Expense Record",
      "type": "n8n-nodes-base.code",
      "position": [
        832,
        1040
      ],
      "parameters": {
        "jsCode": "const body = $input.first().json;\n\n// \u2500\u2500 Pull form fields \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 employeeName = String(body['Your Full Name'] || body.employeeName || '').trim();\nconst employeeEmail = String(body['Your Email'] || body.employeeEmail || '').trim().toLowerCase();\nconst managerEmail = String(body['Manager Email'] || body.managerEmail || '').trim().toLowerCase();\nconst category = String(body['Expense Category'] || body.category || 'Other').trim();\nconst fileUrl = String(body['Receipt Photo or PDF URL'] || body.fileUrl || '').trim();\nconst businessPurpose = String(body['Business Purpose'] || body.businessPurpose || '').trim();\nconst projectCode = String(body['Project Code'] || body.projectCode || '').trim();\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 (!employeeName) throw new Error('Employee name is required.');\nif (!employeeEmail) throw new Error('Employee email is required.');\nif (!managerEmail) throw new Error('Manager email is required.');\nif (!businessPurpose) throw new Error('Business purpose is required.');\nif (!fileUrl && !body.filename) throw new Error('Provide a receipt file URL or binary upload.');\n\n// \u2500\u2500 Email format validation \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 emailRx = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nif (!emailRx.test(employeeEmail)) throw new Error(`Invalid employee email: ${employeeEmail}`);\nif (!emailRx.test(managerEmail)) throw new Error(`Invalid manager email: ${managerEmail}`);\nif (employeeEmail === managerEmail) throw new Error('Employee and manager email must be different.');\n\n// \u2500\u2500 Filename & extension \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 filename = body.filename ||\n  fileUrl.split('?')[0].split('/').pop() ||\n  'receipt.jpg';\nconst ext = filename.split('.').pop()?.toLowerCase() || 'jpg';\nconst allowedExts = ['jpg', 'jpeg', 'png', 'pdf', 'heic', 'webp'];\nif (!allowedExts.includes(ext)) {\n  throw new Error(`File type .${ext} not supported. Accepted: ${allowedExts.join(', ')}`);\n}\n\n// \u2500\u2500 Generate expense 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\nconst expenseId = `EXP-${Date.now()}`;\nconst employeeSlug = employeeName.toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 20);\nconst structuredFilename = `${expenseId}-${employeeSlug}.${ext}`;\n\n// \u2500\u2500 Auto-approve threshold from env var \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst autoApproveThreshold = parseFloat($vars.AUTO_APPROVE_THRESHOLD || '50');\n\nreturn [{\n  json: {\n    expenseId,\n    employeeName,\n    employeeEmail,\n    managerEmail,\n    category,\n    businessPurpose,\n    projectCode: projectCode || null,\n    fileUrl: fileUrl || null,\n    filename,\n    structuredFilename,\n    ext,\n    autoApproveThreshold,\n    submittedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "e79c9f4b-d787-4170-8bde-fa08154ca8ff",
      "name": "Has Remote URL?",
      "type": "n8n-nodes-base.if",
      "position": [
        976,
        1024
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-url",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $json.fileUrl }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "fa8f26ea-dfb7-4e88-af4e-d240a08b89a1",
      "name": "Upload to URL - Remote",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        1168,
        944
      ],
      "parameters": {
        "operation": "uploadFile"
      },
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "afa9624e-6b5b-42db-9900-4f6ff8efa915",
      "name": "Upload to URL - Binary",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        1168,
        1104
      ],
      "parameters": {
        "operation": "uploadFile"
      },
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "0831a344-8c30-4c86-b932-90e19dec8286",
      "name": "Extract CDN URL",
      "type": "n8n-nodes-base.code",
      "position": [
        1344,
        1008
      ],
      "parameters": {
        "jsCode": "const uploadResp = $input.first().json;\nconst meta = $('Validate & Build Expense Record').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('UploadToURL returned no public URL. Raw: ' + JSON.stringify(uploadResp).slice(0, 300));\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": "8ec4dc59-b870-44bb-b6e0-928da07576ba",
      "name": "Mindee - Extract Receipt Data",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Calls Mindee's expense_receipts/v5/predict endpoint with the CDN URL. Returns structured fields with per-field confidence scores: total amount, tax, merchant name, date, currency, and category.",
      "position": [
        1584,
        1008
      ],
      "parameters": {
        "url": "https://api.mindee.net/v1/products/mindee/expense_receipts/v5/predict",
        "method": "POST",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "contentType": "multipart-form-data",
        "sendHeaders": true,
        "authentication": "genericCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "document",
              "parameterType": "formBinaryData"
            }
          ]
        },
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Token {{ $credentials.value }}"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "3bc45e41-5a93-46e8-ac20-8f74c950f8cb",
      "name": "Parse OCR & Score Confidence",
      "type": "n8n-nodes-base.code",
      "notes": "Extracts Mindee fields with per-field confidence. Composite score weights: total 40%, merchant 30%, date 20%, tax 10%. Auto-approve fires only when score \u2265 0.85 AND total \u2264 threshold AND no missing fields.",
      "position": [
        1760,
        1008
      ],
      "parameters": {
        "jsCode": "const mindeeResp = $input.first().json;\nconst meta = $('Extract CDN URL').first().json;\n\n// \u2500\u2500 Navigate Mindee v5 response structure \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst prediction = mindeeResp.document?.inference?.prediction || {};\n\n// \u2500\u2500 Helper: extract value + confidence from Mindee field \u2500\u2500\u2500\u2500\u2500\u2500\nconst getField = (field) => ({\n  value: field?.value ?? null,\n  confidence: field?.confidence ?? 0\n});\n\n// \u2500\u2500 Extract core fields \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 totalField     = getField(prediction.total_amount);\nconst taxField       = getField(prediction.total_tax);\nconst merchantField  = getField(prediction.supplier_name);\nconst dateField      = getField(prediction.date);\nconst currencyField  = getField(prediction.locale?.currency || { value: 'USD', confidence: 0.5 });\nconst categoryField  = getField(prediction.category);\nconst receiptNumField= getField(prediction.receipt_number);\n\n// \u2500\u2500 Normalise values \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 total    = parseFloat(totalField.value) || null;\nconst tax      = parseFloat(taxField.value) || null;\nconst merchant = String(merchantField.value || 'Unknown Merchant').trim();\nconst date     = dateField.value\n  ? new Date(dateField.value).toISOString().split('T')[0]\n  : new Date().toISOString().split('T')[0];\nconst currency = String(currencyField.value || 'USD').toUpperCase().slice(0, 3);\nconst detectedCategory = categoryField.value || meta.category;\nconst receiptNumber = receiptNumField.value || null;\n\n// \u2500\u2500 Composite confidence score \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\n// Weight by importance: total (40%), merchant (30%), date (20%), tax (10%)\nconst compositeConfidence = (\n  totalField.confidence * 0.40 +\n  merchantField.confidence * 0.30 +\n  dateField.confidence * 0.20 +\n  taxField.confidence * 0.10\n);\nconst confidenceScore = Math.round(compositeConfidence * 100) / 100;\n\n// \u2500\u2500 Flag missing required fields \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 missingFields = [];\nif (total === null) missingFields.push('total');\nif (merchantField.confidence < 0.5) missingFields.push('merchant (low confidence)');\nif (!dateField.value) missingFields.push('date');\n\n// \u2500\u2500 Auto-approve logic \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\n// Auto-approve only if: high confidence AND total within threshold AND no missing fields\nconst meetsAutoApprove =\n  confidenceScore >= 0.85 &&\n  total !== null &&\n  total <= meta.autoApproveThreshold &&\n  missingFields.length === 0;\n\nconst approvalRoute = meetsAutoApprove ? 'auto' : 'manager';\n\n// \u2500\u2500 Format for display \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 totalFormatted = total !== null\n  ? `${currency} ${total.toFixed(2)}`\n  : 'Not detected';\n\nreturn [{\n  json: {\n    ...meta,\n    // Extracted\n    merchant,\n    total,\n    tax,\n    currency,\n    date,\n    detectedCategory,\n    receiptNumber,\n    // Confidence\n    confidenceScore,\n    missingFields,\n    perFieldConfidence: {\n      total: totalField.confidence,\n      merchant: merchantField.confidence,\n      date: dateField.confidence,\n      tax: taxField.confidence\n    },\n    // Routing\n    approvalRoute,\n    meetsAutoApprove,\n    // Display\n    totalFormatted,\n    ocrStatus: missingFields.length === 0 ? 'complete' : 'partial'\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "9ab3672d-fda3-4ee0-9b98-3a11068ee8f7",
      "name": "Confidence Gate \u2014 Auto or Manager?",
      "type": "n8n-nodes-base.if",
      "position": [
        2080,
        1008
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-auto",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.approvalRoute }}",
              "rightValue": "auto"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "5cbb6bda-d4b3-4577-bab1-54e088f705a8",
      "name": "Mark Auto-Approved",
      "type": "n8n-nodes-base.code",
      "notes": "Marks the expense as auto-approved. Reimbursement ETA is 3 days for amounts under $25, 5 days otherwise. No manager interaction required.",
      "position": [
        2256,
        912
      ],
      "parameters": {
        "jsCode": "// Auto-approve path \u2014 mark as approved immediately\nconst data = $input.first().json;\nconst reimbursementDays = data.total <= 25 ? 3 : 5;\n\nreturn [{\n  json: {\n    ...data,\n    approvalStatus: 'approved',\n    approvedBy: 'Auto-approval system',\n    approvedAt: new Date().toISOString(),\n    rejectionReason: null,\n    reimbursementEta: `${reimbursementDays} business days`,\n    statusEmoji: '\u2705'\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "3ceb1d1d-13da-4b67-bb29-ea9495d0579f",
      "name": "Slack - Manager Approval Request",
      "type": "n8n-nodes-base.slack",
      "notes": "Sends a direct Slack message to the manager's user ID with approve/reject buttons. The button values carry expenseId and employeeEmail for the webhook handler to process.",
      "position": [
        2240,
        1088
      ],
      "parameters": {
        "text": "=\ud83d\udcb3 *Expense Approval Request*\n\n\ud83d\udc64 *{{ $json.employeeName }}* (`{{ $json.employeeEmail }}`)\n\ud83c\udfea Merchant: *{{ $json.merchant }}*\n\ud83d\udcb0 Total: *{{ $json.totalFormatted }}*{{ $json.tax ? '  |  Tax: ' + $json.currency + ' ' + $json.tax.toFixed(2) : '' }}\n\ud83d\udcc5 Date: {{ $json.date }}\n\ud83d\uddc2 Category: {{ $json.detectedCategory }}\n\ud83d\udccb Purpose: _{{ $json.businessPurpose }}_\n{{ $json.projectCode ? '\ud83d\udd16 Project: ' + $json.projectCode : '' }}\n\n\ud83d\udd0d OCR Confidence: {{ Math.round($json.confidenceScore * 100) }}%{{ $json.missingFields.length > 0 ? '  \u26a0\ufe0f Missing: ' + $json.missingFields.join(', ') : '' }}\n\n\ud83e\uddfe <{{ $json.cdnUrl }}|View Receipt>\n\nPlease approve or reject:",
        "otherOptions": {},
        "authentication": "oAuth2"
      },
      "typeVersion": 2.2
    },
    {
      "id": "28c39db4-8c0d-41b7-8490-643df3f02cc8",
      "name": "Mark Pending Manager Review",
      "type": "n8n-nodes-base.code",
      "notes": "Sets status to pending_manager. A separate n8n workflow (triggered by Slack's interactivity webhook) handles the button click response and completes the approval loop.",
      "position": [
        2400,
        1088
      ],
      "parameters": {
        "jsCode": "// Manager path \u2014 pending until Slack response received\n// In production: a separate Slack webhook workflow handles the button click\n// and updates the expense status via the Expensify/Sheets nodes\nconst data = $input.first().json;\nconst slackResp = $input.first().json;\n\nreturn [{\n  json: {\n    ...data,\n    approvalStatus: 'pending_manager',\n    approvedBy: null,\n    approvedAt: null,\n    rejectionReason: null,\n    reimbursementEta: 'Awaiting manager approval',\n    statusEmoji: '\u23f3',\n    slackMessageTs: slackResp.ts || null\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "23228806-6525-4c57-b30b-d3a3beb3db4d",
      "name": "Expensify - Create Expense Entry",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Creates an Expensify expense entry on both approval paths. Amount is sent in cents (\u00d7100). The CDN URL is attached as receipt proof \u2014 no re-upload needed. Fires regardless of auto-approve or manager path.",
      "position": [
        2592,
        944
      ],
      "parameters": {
        "url": "https://integrations.expensify.com/Integration-Server/ExpensifyIntegrations",
        "method": "POST",
        "options": {
          "timeout": 20000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "contentType": "form-urlencoded",
        "authentication": "genericCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "requestJobDescription",
              "value": "={\n  \"type\": \"create\",\n  \"credentials\": {\n    \"partnerUserID\": \"{{ $credentials.username }}\",\n    \"partnerUserSecret\": \"{{ $credentials.password }}\"\n  },\n  \"inputSettings\": {\n    \"type\": \"expenses\",\n    \"policyID\": \"{{ $vars.EXPENSIFY_POLICY_ID }}\",\n    \"employeeEmail\": \"{{ $json.employeeEmail }}\",\n    \"expenses\": [{\n      \"date\": \"{{ $json.date }}\",\n      \"currency\": \"{{ $json.currency }}\",\n      \"amount\": {{ Math.round(($json.total || 0) * 100) }},\n      \"merchant\": \"{{ $json.merchant }}\",\n      \"tag\": \"{{ $json.detectedCategory }}\",\n      \"billable\": false,\n      \"reimbursable\": true,\n      \"comment\": \"{{ $json.businessPurpose }}{{ $json.projectCode ? ' | Project: ' + $json.projectCode : '' }} | ExpenseID: {{ $json.expenseId }}\",\n      \"receipt\": {\n        \"url\": \"{{ $json.cdnUrl }}\",\n        \"filename\": \"{{ $json.structuredFilename }}\"\n      }\n    }]\n  }\n}"
            }
          ]
        },
        "genericAuthType": "httpBasicAuth"
      },
      "typeVersion": 4.2
    },
    {
      "id": "0168f1e5-f9f1-4b15-ba87-7a6895317a10",
      "name": "Merge Approval + Expensify Result",
      "type": "n8n-nodes-base.code",
      "position": [
        2784,
        944
      ],
      "parameters": {
        "jsCode": "const expensifyResp = $input.first().json;\n// Get data from whichever approval branch ran\nconst expData =\n  $('Mark Auto-Approved').first()?.json ||\n  $('Mark Pending Manager Review').first()?.json;\n\nconst expensifyId =\n  expensifyResp.reportID ||\n  expensifyResp.transactionID ||\n  expensifyResp.responseMessage ||\n  null;\n\nreturn [{\n  json: {\n    ...expData,\n    expensifyId: String(expensifyId || ''),\n    resolvedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "52b837a4-2156-4a96-b1e1-cac1a26151e6",
      "name": "Sheets - Append Audit Row",
      "type": "n8n-nodes-base.googleSheets",
      "notes": "Appends every expense to the audit log regardless of approval path. Includes full OCR data, confidence score, CDN URL, Expensify ID, and timestamps \u2014 complete audit trail for finance.",
      "position": [
        3136,
        976
      ],
      "parameters": {
        "columns": {
          "value": {
            "Tax": "={{ $json.tax }}",
            "Date": "={{ $json.date }}",
            "Total": "={{ $json.total }}",
            "Status": "={{ $json.approvalStatus }}",
            "CDN URL": "={{ $json.cdnUrl }}",
            "Category": "={{ $json.detectedCategory }}",
            "Currency": "={{ $json.currency }}",
            "Employee": "={{ $json.employeeName }}",
            "Merchant": "={{ $json.merchant }}",
            "Expense ID": "={{ $json.expenseId }}",
            "OCR Status": "={{ $json.ocrStatus }}",
            "Approved By": "={{ $json.approvedBy }}",
            "Resolved At": "={{ $json.resolvedAt }}",
            "Expensify ID": "={{ $json.expensifyId }}",
            "Project Code": "={{ $json.projectCode }}",
            "Submitted At": "={{ $json.submittedAt }}",
            "Employee Email": "={{ $json.employeeEmail }}",
            "Missing Fields": "={{ $json.missingFields.join(', ') }}",
            "OCR Confidence": "={{ $json.confidenceScore }}",
            "Business Purpose": "={{ $json.businessPurpose }}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Expense Audit Log"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $vars.GSHEET_SPREADSHEET_ID }}"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "28349e90-3589-453b-81db-b797a1135ec9",
      "name": "Gmail - Employee Confirmation",
      "type": "n8n-nodes-base.gmail",
      "notes": "Sends the employee an HTML confirmation email. Auto-approved expenses show green status and reimbursement ETA. Manager-pending expenses show amber status with next-step instructions.",
      "position": [
        3296,
        992
      ],
      "parameters": {
        "sendTo": "={{ $json.employeeEmail }}",
        "message": "=<!DOCTYPE html>\n<html>\n<body style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; padding: 32px; color: #1a1a1a;\">\n  <h2 style=\"color: {{ $json.approvalStatus === 'approved' ? '#16a34a' : '#d97706' }};\">{{ $json.statusEmoji }} Expense {{ $json.approvalStatus === 'approved' ? 'Approved' : 'Received \u2014 Pending Review' }}</h2>\n  <p>Hi {{ $json.employeeName }},</p>\n  <p>{{ $json.approvalStatus === 'approved' ? 'Your expense has been automatically approved and submitted to Expensify.' : 'Your expense has been received and sent to your manager for approval.' }}</p>\n  <table style=\"width:100%;border-collapse:collapse;font-size:14px;margin:16px 0;\">\n    <tr style=\"border-bottom:1px solid #e5e7eb\"><td style=\"padding:8px;color:#6b7280\">Expense ID</td><td style=\"padding:8px;font-weight:600\">{{ $json.expenseId }}</td></tr>\n    <tr style=\"border-bottom:1px solid #e5e7eb\"><td style=\"padding:8px;color:#6b7280\">Merchant</td><td style=\"padding:8px\">{{ $json.merchant }}</td></tr>\n    <tr style=\"border-bottom:1px solid #e5e7eb\"><td style=\"padding:8px;color:#6b7280\">Total</td><td style=\"padding:8px;font-weight:600\">{{ $json.totalFormatted }}</td></tr>\n    <tr style=\"border-bottom:1px solid #e5e7eb\"><td style=\"padding:8px;color:#6b7280\">Date</td><td style=\"padding:8px\">{{ $json.date }}</td></tr>\n    <tr style=\"border-bottom:1px solid #e5e7eb\"><td style=\"padding:8px;color:#6b7280\">Category</td><td style=\"padding:8px\">{{ $json.detectedCategory }}</td></tr>\n    <tr style=\"border-bottom:1px solid #e5e7eb\"><td style=\"padding:8px;color:#6b7280\">Status</td><td style=\"padding:8px\">{{ $json.approvalStatus }}</td></tr>\n    <tr><td style=\"padding:8px;color:#6b7280\">Reimbursement ETA</td><td style=\"padding:8px;font-weight:600;color:#16a34a\">{{ $json.reimbursementEta }}</td></tr>\n  </table>\n  <p><a href=\"{{ $json.cdnUrl }}\" style=\"color:#4F46E5\">View your receipt</a></p>\n  {{ $json.missingFields.length > 0 ? '<p style=\"color:#d97706;font-size:13px;\">\u26a0\ufe0f Note: Some receipt fields could not be read clearly (' + $json.missingFields.join(', ') + '). Your manager may follow up.</p>' : '' }}\n  <p style=\"font-size:13px;color:#9ca3af;margin-top:24px;\">Ref: {{ $json.expenseId }} \u00b7 Expensify: {{ $json.expensifyId }}</p>\n</body>\n</html>",
        "options": {
          "senderName": "={{ 'Expense System \u2014 ' + $json.employeeName.split(' ')[0] }}"
        },
        "subject": "={{ $json.approvalStatus === 'approved' ? '\u2705 Expense approved \u2014 ' + $json.totalFormatted + ' at ' + $json.merchant : '\u23f3 Expense received \u2014 ' + $json.totalFormatted + ' at ' + $json.merchant }}"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "8f7e0add-6165-483b-bcb1-978c63c76711",
      "name": "Respond to Form",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        3472,
        976
      ],
      "parameters": {
        "options": {
          "responseCode": 201
        },
        "respondWith": "text",
        "responseBody": "={{ $json.approvalStatus === 'approved' \n  ? '\u2705 Expense approved! ' + $json.totalFormatted + ' at ' + $json.merchant + ' \u2014 reimbursement in ' + $json.reimbursementEta + '. Ref: ' + $json.expenseId\n  : '\u23f3 Expense received! ' + $json.totalFormatted + ' at ' + $json.merchant + ' \u2014 sent to your manager for approval. Ref: ' + $json.expenseId }}"
      },
      "typeVersion": 1.1
    }
  ],
  "connections": {
    "Extract CDN URL": {
      "main": [
        [
          {
            "node": "Mindee - Extract Receipt Data",
            "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
          }
        ]
      ]
    },
    "Mark Auto-Approved": {
      "main": [
        [
          {
            "node": "Expensify - Create Expense Entry",
            "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
          }
        ]
      ]
    },
    "Sheets - Append Audit Row": {
      "main": [
        [
          {
            "node": "Gmail - Employee Confirmation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Pending Manager Review": {
      "main": [
        [
          {
            "node": "Expensify - Create Expense Entry",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse OCR & Score Confidence": {
      "main": [
        [
          {
            "node": "Confidence Gate \u2014 Auto or Manager?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Form Trigger - Submit Receipt": {
      "main": [
        [
          {
            "node": "Validate & Build Expense Record",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gmail - Employee Confirmation": {
      "main": [
        [
          {
            "node": "Respond to Form",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mindee - Extract Receipt Data": {
      "main": [
        [
          {
            "node": "Parse OCR & Score Confidence",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate & Build Expense Record": {
      "main": [
        [
          {
            "node": "Has Remote URL?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Expensify - Create Expense Entry": {
      "main": [
        [
          {
            "node": "Merge Approval + Expensify Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack - Manager Approval Request": {
      "main": [
        [
          {
            "node": "Mark Pending Manager Review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Approval + Expensify Result": {
      "main": [
        [
          {
            "node": "Sheets - Append Audit Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Confidence Gate \u2014 Auto or Manager?": {
      "main": [
        [
          {
            "node": "Mark Auto-Approved",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack - Manager Approval Request",
            "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

Stop chasing blurry receipts and manually typing expense data. This workflow creates an intelligent, "snap-and-submit" reimbursement pipeline that hosts photos via UploadToURL, extracts deep data via Mindee OCR, and utilizes a confidence-based gate to auto-approve low-risk…

Source: https://n8n.io/workflows/13636/ — 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

Deliver your product updates in a modern, accessible format. This workflow automatically transforms GitHub releases into podcast-style audio announcements and distributes them via email and Slack.

Github Trigger, Notion, OpenAI +5
Email & Gmail

Streamline your content pipeline by bridging Notion and Instagram with a professional "review-before-publish" safeguard. This workflow allows team members to submit content via a simple form, generate

Form Trigger, HTTP Request, N8N Nodes Uploadtourl +1
Email & Gmail

Submit any YouTube, Vimeo, or Zoom webinar URL using a simple form and the workflow handles everything from there. It runs a two-phase pipeline: first identifying the top viral moments in your video w

Form Trigger, HTTP Request, Google Sheets +1
Email & Gmail

This template automates internal equipment and supply purchase requests for operations, HR, and IT teams. Requests are submitted via a built-in n8n form, automatically approved for small amounts, and

Form Trigger, Google Sheets, Slack +1
Email & Gmail

Shopify and E-Commerce store owners often struggle to create engaging 3D videos from static product images. This workflow automates that entire process—from image upload to video delivery—so store own

Form Trigger, Google Drive, HTTP Request +2