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 →
{
"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
}
]
]
}
}
}
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 →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Invoice Data Extraction. Uses httpRequest, slack, googleSheets, errorTrigger. Webhook trigger; 16 nodes.
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
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
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
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.