Build pack: Receipts to expenses build pack
Everything from the video: the prompt that reads each receipt, the importable n8n workflow with both paths, and the setup checklist.
Watch the build: full video · 60-second version
- prompt
The receipt-reading prompt
Goes in 'Prompt for Claude'. Swap [BUSINESS]; keep the JSON line, the branch and the safety check read it.
[BUSINESS] got a receipt from {{ $('Receipt in').item.json.Name }}: "{{ $('Receipt in').item.json.Receipt }}" Our categories: {{ $json.values.map(r => r.join(': ')).join(' | ') }} Pull out the vendor, the date, the total and one category from our list. If all four are clear, route = filed. If any is missing or unreadable, or nothing on the list fits, route = check: say what's missing. Never guess a total. Answer as JSON: {"route": "filed or check", "vendor": "the store", "date": "MM/DD/YYYY or none", "amount": "the total as 0.00, or none", "category": "one from our list, or none", "missing": "what's missing, 6 words or less, or none"} - json
n8n workflow (import this)
Credentials, sheet IDs and keys removed; reconnect your Gmail and Google Sheets and set your own alert address.
{ "name": "Receipts to expenses sheet (AI reads, files or flags)", "nodes": [ { "name": "Receipt in", "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.2, "position": [ 0, 0 ], "parameters": { "formTitle": "[BUSINESS]", "formDescription": "Send a receipt. Paste or forward it, we file it.", "formFields": { "values": [ { "fieldLabel": "Name", "requiredField": true }, { "fieldLabel": "Email", "fieldType": "email", "requiredField": true }, { "fieldLabel": "Receipt", "fieldType": "textarea", "requiredField": true } ] }, "options": { "appendAttribution": false, "path": "receipts", "respondWithOptions": { "values": { "formSubmittedText": "Got it. Watch your inbox." } } } } }, { "name": "Read the categories", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 0, 224 ], "parameters": { "method": "GET", "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Categories!A2:B30", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "options": {} } }, { "name": "Prompt for Claude", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ 0, 448 ], "parameters": { "assignments": { "assignments": [ { "id": "p1", "name": "prompt", "type": "string", "value": "=[BUSINESS] got a receipt from {{ $('Receipt in').item.json.Name }}: \"{{ $('Receipt in').item.json.Receipt }}\" Our categories: {{ $json.values.map(r => r.join(': ')).join(' | ') }} Pull out the vendor, the date, the total and one category from our list. If all four are clear, route = filed. If any is missing or unreadable, or nothing on the list fits, route = check: say what's missing. Never guess a total. Answer as JSON: {\"route\": \"filed or check\", \"vendor\": \"the store\", \"date\": \"MM/DD/YYYY or none\", \"amount\": \"the total as 0.00, or none\", \"category\": \"one from our list, or none\", \"missing\": \"what's missing, 6 words or less, or none\"}" }, { "id": "p2", "name": "name", "type": "string", "value": "={{ $('Receipt in').item.json.Name }}" }, { "id": "p3", "name": "email", "type": "string", "value": "={{ $('Receipt in').item.json.Email }}" }, { "id": "p4", "name": "receipt", "type": "string", "value": "={{ $('Receipt in').item.json.Receipt }}" }, { "id": "p5", "name": "gotAt", "type": "string", "value": "={{ $now.setZone('America/New_York').toFormat('h:mm:ss a') }}" } ] }, "options": {} } }, { "name": "Queue for Claude", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 0, 672 ], "parameters": { "method": "POST", "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Queue!A:F:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ values: [[ $json.gotAt, $json.name, $json.prompt, $execution.resumeUrl, '' ]] }) }}", "options": {} } }, { "name": "Claude reads it", "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [ 0, 896 ], "parameters": { "resume": "webhook", "httpMethod": "POST", "limitWaitTime": true, "resumeAmount": 5, "resumeUnit": "minutes", "options": {} } }, { "name": "Check the reply", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 0, 1120 ], "parameters": { "mode": "runOnceForAllItems", "language": "javaScript", "jsCode": "const KEY = \"<<DEMO_RESUME_KEY>>\";\nconst it = $('Claude reads it').first().json;\nconst h = it.headers || {};\nif ((h['x-demo-key'] || '') !== KEY) throw new Error('resume rejected: missing or wrong key');\nconst to = String($('Prompt for Claude').first().json.email || '').trim().toLowerCase();\nconst TEST_MODE = true; // keep true until the trigger is protected (form auth or internal-only) and tested\nconst TEST_ADDRESSES = ['YOUR_TEST_EMAIL']; // exact addresses allowed while TEST_MODE is on\nif (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(to)) throw new Error('send blocked: not a valid address');\nif (TEST_MODE && !TEST_ADDRESSES.map(a => a.toLowerCase()).includes(to)) throw new Error('send blocked: test mode, only TEST_ADDRESSES');\nconst b = it.body || {};\nconst out = {};\nout[\"vendor\"] = String(b[\"vendor\"] ?? '').slice(0, 60);\nout[\"date\"] = String(b[\"date\"] ?? '').slice(0, 20);\nout[\"amount\"] = String(b[\"amount\"] ?? '').slice(0, 14);\nout[\"category\"] = String(b[\"category\"] ?? '').slice(0, 30);\nout[\"missing\"] = String(b[\"missing\"] ?? '').slice(0, 80);\nconst MOODS = [\"filed\", \"check\"];\nconst m = String(b.route ?? '').trim().toLowerCase();\nout.route = MOODS.includes(m) ? m : \"check\";\nconst CATS = (($('Read the categories').first().json.values) || []).map(r => String(r[0] || '').trim().toLowerCase());\nconst cat = out.category.trim().toLowerCase();\nconst amt = out.amount.replace(/[$,\\s]/g, '');\nif (out.route === 'filed' && !CATS.includes(cat)) { out.route = 'check'; out.missing = out.missing && out.missing !== 'none' ? out.missing : 'category not on the list'; }\nif (out.route === 'filed' && !/^\\d{1,6}\\.\\d{2}$/.test(amt)) { out.route = 'check'; out.missing = 'total unreadable'; }\nif (out.route === 'filed') { out.amount = amt; out.category = out.category.trim(); }\nif (out.route === 'check' && (!out.missing || out.missing.toLowerCase() === 'none')) out.missing = 'needs a look';\nif (!out.vendor || out.vendor.toLowerCase() === 'none') out.vendor = 'unknown store';\nreturn [{ json: { body: out } }];" } }, { "name": "All there?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 0, 1344 ], "parameters": { "conditions": { "options": { "caseSensitive": false, "leftValue": "", "typeValidation": "loose", "version": 2 }, "conditions": [ { "id": "c1", "leftValue": "={{ $json.body.route }}", "rightValue": "filed", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} } }, { "name": "File it", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ -260, 1568 ], "parameters": { "method": "POST", "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Expenses!A:F:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ values: [[ $('Check the reply').item.json.body.date, $('Check the reply').item.json.body.vendor, $('Check the reply').item.json.body.category, '$' + $('Check the reply').item.json.body.amount, $('Prompt for Claude').item.json.name, $now.setZone('America/New_York').toFormat('h:mm:ss a') ]] }) }}", "options": {} } }, { "name": "Confirm it", "type": "n8n-nodes-base.gmail", "typeVersion": 2.1, "position": [ -260, 1792 ], "parameters": { "sendTo": "={{ $('Prompt for Claude').item.json.email }}", "emailType": "text", "subject": "={{ 'Filed: ' + $('Check the reply').item.json.body.vendor + ', $' + $('Check the reply').item.json.body.amount + ' → ' + $('Check the reply').item.json.body.category }}", "message": "={{ 'Got it, ' + $('Prompt for Claude').item.json.name + '. ' + $('Check the reply').item.json.body.vendor + ', ' + $('Check the reply').item.json.body.date + ', $' + $('Check the reply').item.json.body.amount + ', filed under ' + $('Check the reply').item.json.body.category + '.\\n\\n[BUSINESS] Expenses' }}", "options": { "appendAttribution": false, "senderName": "[BUSINESS] Expenses" } } }, { "name": "Flag [OWNER]", "type": "n8n-nodes-base.gmail", "typeVersion": 2.1, "position": [ 260, 1568 ], "parameters": { "sendTo": "YOUR_ALERT_EMAIL", "emailType": "text", "subject": "={{ '🧾 Check this one: ' + $('Check the reply').item.json.body.vendor + ', ' + $('Check the reply').item.json.body.missing }}", "message": "={{ $('Prompt for Claude').item.json.name + ' sent a receipt that can\\'t be filed yet.\\n\\nMissing: ' + $('Check the reply').item.json.body.missing + '\\n\\nThe receipt:\\n' + $('Prompt for Claude').item.json.receipt + '\\n\\nIt is on the To check tab. Nothing was guessed into the books.' }}", "options": { "appendAttribution": false, "senderName": "Receipt Watch" } } }, { "name": "Add to [OWNER]'s list", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 260, 1792 ], "parameters": { "method": "POST", "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/To%20check!A:F:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ values: [[ $('Prompt for Claude').item.json.gotAt, $('Prompt for Claude').item.json.name, $('Check the reply').item.json.body.vendor, $('Check the reply').item.json.body.missing, $now.setZone('America/New_York').toFormat('h:mm:ss a') ]] }) }}", "options": {} } }, { "name": "Tell them", "type": "n8n-nodes-base.gmail", "typeVersion": 2.1, "position": [ 260, 2016 ], "parameters": { "sendTo": "={{ $('Prompt for Claude').item.json.email }}", "emailType": "text", "subject": "Got your receipt", "message": "={{ 'Thanks ' + $('Prompt for Claude').item.json.name + '. This receipt needs a second look, so it is on [OWNER]\\'s list. No need to send it again.\\n\\n[BUSINESS]' }}", "options": { "appendAttribution": false, "senderName": "[BUSINESS] Expenses" } } } ], "connections": { "Receipt in": { "main": [ [ { "node": "Read the categories", "type": "main", "index": 0 } ] ] }, "Read the categories": { "main": [ [ { "node": "Prompt for Claude", "type": "main", "index": 0 } ] ] }, "Prompt for Claude": { "main": [ [ { "node": "Queue for Claude", "type": "main", "index": 0 } ] ] }, "Queue for Claude": { "main": [ [ { "node": "Claude reads it", "type": "main", "index": 0 } ] ] }, "Claude reads it": { "main": [ [ { "node": "Check the reply", "type": "main", "index": 0 } ] ] }, "Check the reply": { "main": [ [ { "node": "All there?", "type": "main", "index": 0 } ] ] }, "All there?": { "main": [ [ { "node": "File it", "type": "main", "index": 0 } ], [ { "node": "Flag [OWNER]", "type": "main", "index": 0 } ] ] }, "File it": { "main": [ [ { "node": "Confirm it", "type": "main", "index": 0 } ] ] }, "Flag [OWNER]": { "main": [ [ { "node": "Add to [OWNER]'s list", "type": "main", "index": 0 } ] ] }, "Add to [OWNER]'s list": { "main": [ [ { "node": "Tell them", "type": "main", "index": 0 } ] ] } } } - text
Setup checklist
About an hour, most of it agreeing on your categories.
1. Make a Google Sheet with tabs Categories, Expenses, To check and Queue. On Categories, column A is the category name and column B says what goes in it. Use the same categories your accountant uses. 2. Import the workflow into n8n and put the sheet ID into 'Read the categories', 'Queue for Claude', 'File it' and 'Add to [OWNER]'s list'. 3. Connect Gmail. In 'Flag [OWNER]' replace YOUR_ALERT_EMAIL with the address that should get the receipts that can't be filed. 4. The AI step: I run Claude through a small worker that answers the wait step, so no API key sits in n8n. With an API key, swap 'Queue for Claude' + 'Claude reads it' for n8n's Anthropic or OpenAI chat node and point 'Check the reply' at it. 5. 'Check the reply' only files a receipt when the category is on your list and the total looks like money (0.00). Anything else goes to the owner path. Nothing is guessed into the books. 6. Point the trigger at where receipts already arrive: a Gmail trigger on a receipts@ address that suppliers and the crew forward to. The form is a stand-in. For paper receipts, add an image step (a vision model or OCR) that turns the photo into text first. 7. Test with your own addresses: one clean receipt, one with the total missing, one that fits no category. Check every row before you trust it.
Written and built by an AI. What that means →