AutomationFlowsSlack & Telegram › Verify Hotel Receipts Against Bookings with Gpt-4o Vision, Slack, and Sheets

Verify Hotel Receipts Against Bookings with Gpt-4o Vision, Slack, and Sheets

ByRedowan Ahmed Farhan @redowanfarhan on n8n.io

This workflow receives receipt or invoice uploads via a webhook, uses OpenAI GPT-4o vision to extract structured fields, validates totals and currency against expected booking values, logs the result to Google Sheets, and routes mismatches or failures to Slack for review and…

Webhook trigger★★★★☆ complexity17 nodesHTTP RequestSlackGoogle SheetsError Trigger
Slack & Telegram Trigger: Webhook Nodes: 17 Complexity: ★★★★☆ Added:

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

This workflow follows the Error Trigger → Google Sheets recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "dd2mK6aG4p6XKh09",
  "name": "Vision AI Receipt & Document Verification",
  "tags": [],
  "nodes": [
    {
      "id": "a0b4d2c1-4219-4a11-aa94-caccadeba04a",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1648,
        -16
      ],
      "parameters": {
        "color": 7,
        "width": 560,
        "height": 1268,
        "content": "## \ud83e\uddfe Vision AI Receipt & Document Verification\n\nVerifies uploaded receipts and invoices by extracting structured data with GPT-4o vision, checking it against the expected booking amount/currency, and routing mismatches to a human reviewer.\n\n**Perfect for:** Hotel finance and ops teams reconciling guest receipts against bookings.\n\n---\n\n## How it works\n\n1. **Document Upload Webhook** \u2014 Receives a POST with document_id, guest_id, booking_id, image_url, doc_type, expected_amount, and currency.\n2. **Normalize Input** \u2014 Defaults and normalizes the incoming fields into a consistent record.\n3. **GPT Vision Extract** \u2014 Sends the image to GPT-4o (vision) and extracts vendor, date, total, currency, line items, and a confidence score. Retries twice on failure.\n4. **Parse Extraction** \u2014 Parses the model's JSON reply and merges it with the original booking context.\n5. **Validate Against Booking** \u2014 Flags low confidence, missing total, currency mismatch, or amount outside a 2% tolerance; assigns verdict APPROVED, REVIEW, or REJECTED.\n6. **Auto Approve?** \u2014 Routes APPROVED straight to logging; anything else goes through review flagging first.\n7. **Flag For Review** *(when not auto-approved)* \u2014 Posts the mismatch reasons to Slack #finance-review.\n8. **Build Error Verdict** *(error path)* \u2014 Runs when GPT Vision Extract fails after retries; produces an ERROR verdict so the record still gets logged.\n9. **Record Result** \u2014 Appends the final verdict and extracted data to a Google Sheet.\n10. **Respond** \u2014 Returns {documentId, verdict, reasons} as JSON to the caller.\n11. **Error Trigger** *(workflow-level)* \u2014 Catches any unhandled error in the workflow.\n12. **Alert Engineering** \u2014 Posts the failing node and error message to Slack #automation-alerts.\n\n---\n\n## Setup (~10 minutes)\n\n1. **OpenAI API** \u2014 Add your key in the *GPT Vision Extract* node.\n2. **Slack** \u2014 Connect your workspace in the *Flag For Review* and *Alert Engineering* nodes; confirm the #finance-review and #automation-alerts channels exist.\n3. **Google Sheets** \u2014 Connect your account in the *Record Result* node and set the spreadsheet ID and 'Verifications' sheet name.\n4. **Webhook** \u2014 Point your document-upload flow at the *Document Upload Webhook* path (`receipt-verify`).\n> GPT-4o vision calls cost per-request OpenAI usage (~$0.01-0.03/receipt). Image URLs must be publicly reachable by OpenAI."
      },
      "typeVersion": 1
    },
    {
      "id": "5386614e-92b5-4549-9e9c-42368cfdd36f",
      "name": "Section 1 - Trigger & Intake",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1024,
        112
      ],
      "parameters": {
        "color": 5,
        "width": 696,
        "height": 456,
        "content": "## 1\ufe0f\u20e3 Trigger & Intake\n\nThe **Document Upload Webhook** receives the incoming receipt or invoice submission as a POST request. **Normalize Input** then defaults and reshapes the payload into a consistent record \u2014 document ID, guest and booking IDs, image URL, document type, and the expected amount/currency to validate against."
      },
      "typeVersion": 1
    },
    {
      "id": "2dc958f5-2da3-439c-88ae-c1846b4cfb7e",
      "name": "Section 2 - AI Vision Extraction & Error Handling",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -272,
        48
      ],
      "parameters": {
        "color": 3,
        "width": 600,
        "height": 840,
        "content": "## 2\ufe0f\u20e3 AI Vision Extraction & Error Handling\n\nThe **GPT Vision Extract** node sends the receipt image to GPT-4o and asks for structured JSON \u2014 vendor, date, total, currency, line items, and confidence \u2014 retrying twice before failing. **Parse Extraction** turns that reply into a clean record merged with the original booking context. If extraction fails outright, **Build Error Verdict** produces a fallback ERROR record instead of losing the submission, while the workflow-level **Error Trigger** and **Alert Engineering** catch any other unhandled failure and post it to Slack."
      },
      "typeVersion": 1
    },
    {
      "id": "072fbe59-0e6a-48b8-b6fc-3fe64c1d9f68",
      "name": "Section 3 - Validation & Decision",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        368,
        -48
      ],
      "parameters": {
        "color": 6,
        "width": 840,
        "height": 568,
        "content": "## 3\ufe0f\u20e3 Validation & Decision\n\n**Validate Against Booking** checks the extracted total and currency against the expected booking values, flagging low confidence, missing totals, currency mismatches, or amounts outside a 2% tolerance, and assigns a verdict. **Auto Approve?** branches on that verdict \u2014 anything not cleanly APPROVED is routed to **Flag For Review**, which posts the mismatch reasons to the finance team on Slack."
      },
      "typeVersion": 1
    },
    {
      "id": "9af66b88-5978-4a5f-858a-c6687ef5bde1",
      "name": "Section 4 - Output & Logging",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        368,
        560
      ],
      "parameters": {
        "color": 4,
        "width": 600,
        "height": 360,
        "content": "## 4\ufe0f\u20e3 Output & Logging\n\n**Record Result** appends the final verdict, extracted fields, and reasons to a Google Sheet for audit history, then **Respond** sends the verdict back to whatever system uploaded the document."
      },
      "typeVersion": 1
    },
    {
      "id": "f86e5cf2-4b49-49e6-8b27-8567956fb2e5",
      "name": "Document Upload Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -976,
        336
      ],
      "parameters": {
        "path": "receipt-verify",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "f08bcd6d-1f34-4fab-bc72-86baf87da1c2",
      "name": "Normalize Input",
      "type": "n8n-nodes-base.code",
      "position": [
        -736,
        336
      ],
      "parameters": {
        "jsCode": "const b = ($input.first().json.body) || {};\nreturn [{\n  json: {\n    documentId: b.document_id || ('doc_' + Date.now()),\n    guestId: b.guest_id || 'unknown',\n    bookingId: b.booking_id || '',\n    imageUrl: b.image_url || b.imageUrl || '',\n    docType: b.doc_type || 'receipt',\n    expectedAmount: (b.expected_amount != null) ? Number(b.expected_amount) : null,\n    expectedCurrency: String(b.currency || 'EUR').toUpperCase(),\n    receivedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "5473bb42-83df-4e58-916d-d6d90debb6fd",
      "name": "GPT Vision Extract",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueErrorOutput",
      "maxTries": 2,
      "position": [
        -496,
        336
      ],
      "parameters": {
        "url": "https://api.openai.com/v1/chat/completions",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ ({ model: 'gpt-4o', temperature: 0, max_tokens: 700, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: 'You are a precise document extraction engine for hotel receipts and invoices. Return ONLY JSON with keys: vendor (string), document_date (YYYY-MM-DD string), total (number), currency (3 letter code), line_items (array of objects with description and amount), confidence (number between 0 and 1). If a field is unreadable use null.' }, { role: 'user', content: [ { type: 'text', text: 'Extract the structured data from this document.' }, { type: 'image_url', image_url: { url: $json.imageUrl } } ] } ] }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi"
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 3000
    },
    {
      "id": "7b9a20bb-3a58-4df6-813b-4f360bb52f46",
      "name": "Parse Extraction",
      "type": "n8n-nodes-base.code",
      "position": [
        0,
        336
      ],
      "parameters": {
        "jsCode": "const res = $input.first().json;\nlet content = '{}';\ntry { content = res.choices[0].message.content; } catch (e) {}\nlet parsed = {};\ntry { parsed = JSON.parse(content); } catch (e) { parsed = { confidence: 0 }; }\nconst prev = $node['Normalize Input'].json;\nreturn [{\n  json: {\n    documentId: prev.documentId,\n    guestId: prev.guestId,\n    bookingId: prev.bookingId,\n    expectedAmount: prev.expectedAmount,\n    expectedCurrency: prev.expectedCurrency,\n    vendor: parsed.vendor ?? null,\n    date: parsed.document_date ?? null,\n    total: (parsed.total != null) ? Number(parsed.total) : null,\n    currency: parsed.currency ? String(parsed.currency).toUpperCase() : null,\n    lineItems: parsed.line_items ?? [],\n    confidence: (parsed.confidence != null) ? Number(parsed.confidence) : 0\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "3a7af534-ab76-4952-838f-b809585f6d28",
      "name": "Validate Against Booking",
      "type": "n8n-nodes-base.code",
      "position": [
        432,
        320
      ],
      "parameters": {
        "jsCode": "const d = $input.first().json;\nconst reasons = [];\nconst tolerance = 0.02;\nif (d.confidence < 0.6) reasons.push('Low extraction confidence');\nif (d.total == null) reasons.push('Total not found');\nif (d.currency && d.expectedCurrency && d.currency !== d.expectedCurrency) {\n  reasons.push('Currency mismatch: got ' + d.currency + ' expected ' + d.expectedCurrency);\n}\nif (d.total != null && d.expectedAmount != null) {\n  const diff = Math.abs(d.total - d.expectedAmount);\n  const allowed = Math.abs(d.expectedAmount) * tolerance;\n  if (diff > allowed) reasons.push('Amount mismatch: got ' + d.total + ' expected ' + d.expectedAmount);\n}\nlet verdict = 'APPROVED';\nif (reasons.length > 0) {\n  verdict = (d.confidence < 0.6 || d.total == null) ? 'REJECTED' : 'REVIEW';\n}\nreturn [{\n  json: Object.assign({}, d, {\n    verdict: verdict,\n    reasons: reasons.length ? reasons : ['All checks passed'],\n    processedAt: new Date().toISOString()\n  })\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "6ed5f500-949f-4971-8bf7-07995510c945",
      "name": "Auto Approve?",
      "type": "n8n-nodes-base.if",
      "position": [
        672,
        320
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-approved",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.verdict }}",
              "rightValue": "APPROVED"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "15b868ca-c2ea-4a72-acff-6fe9bf14a267",
      "name": "Flag For Review",
      "type": "n8n-nodes-base.slack",
      "position": [
        912,
        160
      ],
      "parameters": {
        "text": "=:mag: *Document needs review* ({{ $json.verdict }})\nDocument: {{ $json.documentId }}  Booking: {{ $json.bookingId }}\nVendor: {{ $json.vendor }}  Total: {{ $json.total }} {{ $json.currency }}\nExpected: {{ $json.expectedAmount }} {{ $json.expectedCurrency }}\nReasons: {{ $json.reasons.join('; ') }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "finance-review"
        },
        "otherOptions": {}
      },
      "typeVersion": 2.3
    },
    {
      "id": "75c7b678-28df-45f5-bd68-cc6d9531c4e0",
      "name": "Build Error Verdict",
      "type": "n8n-nodes-base.code",
      "position": [
        0,
        512
      ],
      "parameters": {
        "jsCode": "const prev = $node['Normalize Input'].json;\nreturn [{\n  json: {\n    documentId: prev.documentId,\n    guestId: prev.guestId,\n    bookingId: prev.bookingId,\n    expectedAmount: prev.expectedAmount,\n    expectedCurrency: prev.expectedCurrency,\n    vendor: null,\n    date: null,\n    total: null,\n    currency: null,\n    lineItems: [],\n    confidence: 0,\n    verdict: 'ERROR',\n    reasons: ['Vision extraction failed after retries'],\n    processedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "bb5a2a85-f7cf-4102-9e2f-cd4bb788dc8b",
      "name": "Record Result",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        480,
        768
      ],
      "parameters": {
        "columns": {
          "value": {
            "date": "={{ $json.date }}",
            "total": "={{ $json.total }}",
            "vendor": "={{ $json.vendor }}",
            "reasons": "={{ $json.reasons.join('; ') }}",
            "verdict": "={{ $json.verdict }}",
            "currency": "={{ $json.currency }}",
            "guest_id": "={{ $json.guestId }}",
            "booking_id": "={{ $json.bookingId }}",
            "confidence": "={{ $json.confidence }}",
            "line_items": "={{ JSON.stringify($json.lineItems) }}",
            "document_id": "={{ $json.documentId }}",
            "processed_at": "={{ $json.processedAt }}",
            "expected_amount": "={{ $json.expectedAmount }}"
          },
          "schema": [],
          "mappingMode": "defineBelow",
          "matchingColumns": []
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Verifications"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "f09741f9-8af2-4179-9db2-ca5c1fec5324",
      "name": "Respond",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        720,
        768
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={{ ({ documentId: $json.documentId, verdict: $json.verdict, reasons: $json.reasons }) }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "e3cf9dda-d93a-4577-9d99-655ee127e520",
      "name": "Error Trigger",
      "type": "n8n-nodes-base.errorTrigger",
      "position": [
        -176,
        688
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "fd6aca14-6ebe-4429-a3e7-62b0f84e4dd1",
      "name": "Alert Engineering",
      "type": "n8n-nodes-base.slack",
      "position": [
        80,
        688
      ],
      "parameters": {
        "text": "=:rotating_light: *Receipt verification workflow failed*\nWorkflow: {{ $json.workflow.name }}\nNode: {{ $json.execution.lastNodeExecuted }}\nError: {{ $json.execution.error.message }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "automation-alerts"
        },
        "otherOptions": {}
      },
      "typeVersion": 2.3
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "3f35832c-fd4b-4381-9cee-8ad969e1cdf3",
  "nodeGroups": [],
  "connections": {
    "Auto Approve?": {
      "main": [
        [
          {
            "node": "Record Result",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag For Review",
            "type": "main",
            "index": 0
          },
          {
            "node": "Record Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Error Trigger": {
      "main": [
        [
          {
            "node": "Alert Engineering",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record Result": {
      "main": [
        [
          {
            "node": "Respond",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Input": {
      "main": [
        [
          {
            "node": "GPT Vision Extract",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Extraction": {
      "main": [
        [
          {
            "node": "Validate Against Booking",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GPT Vision Extract": {
      "main": [
        [
          {
            "node": "Parse Extraction",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Error Verdict",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Error Verdict": {
      "main": [
        [
          {
            "node": "Record Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Document Upload Webhook": {
      "main": [
        [
          {
            "node": "Normalize Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Against Booking": {
      "main": [
        [
          {
            "node": "Auto Approve?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Pro

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

About this workflow

This workflow receives receipt or invoice uploads via a webhook, uses OpenAI GPT-4o vision to extract structured fields, validates totals and currency against expected booking values, logs the result to Google Sheets, and routes mismatches or failures to Slack for review and…

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

Invoice Data Extraction. Uses httpRequest, slack, googleSheets, errorTrigger. Webhook trigger; 16 nodes.

HTTP Request, Slack, Google Sheets +1
Slack & Telegram

This workflow is a complete, production-ready solution for recovering abandoned carts in Shopify stores using a multi-channel, multi-touch approach. It automates personalized follow-ups via Email, SMS

HTTP Request, Shopify, SendGrid +5
Slack & Telegram

This workflow is an AI-assisted clean plate and object removal pipeline built for modern VFX production environments. It transforms a single plate image and removal brief into multiple high-quality cl

HTTP Request, Google Drive, Slack +3
Slack & Telegram

This workflow is an AI-driven FX concept generation pipeline that transforms a single VFX brief into multiple high-quality simulation-ready video concepts. It automates ideation, rendering, storage, a

HTTP Request, Google Drive, Notion +2
Slack & Telegram

Turn every sales meeting into a coaching opportunity. This workflow automatically analyzes tldv meeting recordings using OpenAI (GPT-4) to provide instant, actionable feedback to your sales team.

HTTP Request, Slack, Google Sheets