{ "name": "Answer order-status calls and capture callbacks with Vapi and Data Tables", "nodes": [ { "id": "set-up-demo-tables", "name": "Set up demo tables", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ -960, -620 ], "parameters": {} }, { "id": "create-demo-orders", "name": "Create demo orders", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ -740, -620 ], "parameters": { "resource": "table", "operation": "create", "tableName": "blucca_vapi_demo_orders", "columns": { "column": [ { "name": "orderId", "type": "string" }, { "name": "email", "type": "string" }, { "name": "status", "type": "string" }, { "name": "carrier", "type": "string" }, { "name": "trackingNumber", "type": "string" }, { "name": "estimatedDelivery", "type": "string" } ] }, "options": { "createIfNotExists": true } } }, { "id": "create-callback-queue", "name": "Create callback queue", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ -520, -620 ], "parameters": { "resource": "table", "operation": "create", "tableName": "blucca_vapi_demo_callbacks", "columns": { "column": [ { "name": "requestKey", "type": "string" }, { "name": "orderId", "type": "string" }, { "name": "email", "type": "string" }, { "name": "phone", "type": "string" }, { "name": "reason", "type": "string" }, { "name": "status", "type": "string" } ] }, "options": { "createIfNotExists": true } } }, { "id": "two-synthetic-orders", "name": "Two synthetic orders", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -300, -620 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "return [\n {orderId:'ORD-1001',email:'alex@example.com',status:'shipped',carrier:'Demo Parcel',trackingNumber:'DEMO-1001',estimatedDelivery:'2026-10-12'},\n {orderId:'ORD-1002',email:'jamie@example.com',status:'processing',carrier:'',trackingNumber:'',estimatedDelivery:''}\n].map(json => ({json}));" } }, { "id": "seed-demo-orders", "name": "Seed demo orders", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ -80, -620 ], "parameters": { "resource": "row", "operation": "upsert", "dataTableId": { "__rl": true, "mode": "name", "value": "blucca_vapi_demo_orders" }, "matchType": "allConditions", "filters": { "conditions": [ { "keyName": "orderId", "condition": "eq", "keyValue": "={{ $json.orderId }}" } ] }, "columns": { "mappingMode": "defineBelow", "value": { "orderId": "={{ $json.orderId }}", "email": "={{ $json.email }}", "status": "={{ $json.status }}", "carrier": "={{ $json.carrier }}", "trackingNumber": "={{ $json.trackingNumber }}", "estimatedDelivery": "={{ $json.estimatedDelivery }}" }, "matchingColumns": [], "schema": [ { "id": "orderId", "displayName": "orderId", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true }, { "id": "email", "displayName": "email", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true }, { "id": "status", "displayName": "status", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true }, { "id": "carrier", "displayName": "carrier", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true }, { "id": "trackingNumber", "displayName": "trackingNumber", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true }, { "id": "estimatedDelivery", "displayName": "estimatedDelivery", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true } ], "attemptToConvertTypes": false }, "options": {} } }, { "id": "setup-ready", "name": "Setup ready", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 140, -620 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "return [{json:{ready:true,ordersTable:'blucca_vapi_demo_orders',callbacksTable:'blucca_vapi_demo_callbacks',seededOrders:$input.all().length,next:'Configure the Vapi webhook Header Auth credential, activate this workflow, and connect the two Vapi tools.'}}];" } }, { "id": "vapi-webhook", "name": "Vapi webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ -960, 0 ], "parameters": { "httpMethod": "POST", "path": "vapi-order-support", "authentication": "headerAuth", "responseMode": "responseNode", "options": {} }, "webhookId": "blucca-vapi-order-support", "notes": "Create Header Auth: Name X-Vapi-Secret; Value your shared secret. Configure the same header in both Vapi tools." }, { "id": "split-tool-calls", "name": "Split tool calls", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -740, 0 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const message = $input.first().json.body?.message;\nif (message?.type !== 'tool-calls' || !Array.isArray(message.toolCallList) || message.toolCallList.length === 0) throw new Error('Expected a non-empty Vapi tool-calls message.');\nconst ids = new Set();\nreturn message.toolCallList.map(call => {\n if (typeof call.id !== 'string' || !call.id || ids.has(call.id)) throw new Error('Tool call IDs must be present and unique.');\n ids.add(call.id);\n return {json:{toolCallId:call.id,callId:message.call?.id ?? '',toolName:call.function?.name ?? '',arguments:call.function?.arguments},pairedItem:{item:0}};\n});" } }, { "id": "each-tool-call", "name": "Each tool call", "type": "n8n-nodes-base.splitInBatches", "typeVersion": 3, "position": [ -520, 0 ], "parameters": { "batchSize": 1, "options": {} } }, { "id": "validate-current-tool", "name": "Validate current tool", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -300, 120 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const tool = $input.first().json;\nconst args = tool.arguments;\nlet error;\nif (!['getOrderStatus','requestCallback'].includes(tool.toolName)) error = 'Unsupported tool. Use getOrderStatus or requestCallback.';\nelse if (!args || typeof args !== 'object' || Array.isArray(args) || typeof args.orderId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(args.orderId.trim()) || typeof args.email !== 'string' || args.email.length > 254 || !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(args.email.trim())) error = 'Provide a valid orderId and email.';\nelse if (tool.toolName === 'requestCallback' && (typeof args.phone !== 'string' || !/^\\+?[0-9][0-9 ()-]{5,24}$/.test(args.phone.trim()) || typeof args.reason !== 'string' || !args.reason.trim() || args.reason.trim().length > 500)) error = 'Provide a valid phone number and a reason of 1–500 characters.';\nelse if (tool.toolName === 'requestCallback' && (typeof tool.callId !== 'string' || !tool.callId)) error = 'Callback requests require a Vapi call ID.';\nreturn [{json:{...tool,valid:!error,validationError:error,orderId:typeof args?.orderId === 'string' ? args.orderId.trim() : '',email:typeof args?.email === 'string' ? args.email.trim().toLowerCase() : '',phone:typeof args?.phone === 'string' ? args.phone.trim() : '',reason:typeof args?.reason === 'string' ? args.reason.trim() : '',requestKey:JSON.stringify([tool.callId,tool.toolCallId])}}];" } }, { "id": "valid-arguments-", "name": "Valid arguments?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ -80, 120 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "condition", "leftValue": "={{ $json.valid }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "argument-error-result", "name": "Argument error result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 140, 380 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const tool = $input.first().json; return [{json:{toolCallId:tool.toolCallId,error:tool.validationError}}];" } }, { "id": "find-matching-order", "name": "Find matching order", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 140, 40 ], "parameters": { "resource": "row", "operation": "get", "dataTableId": { "__rl": true, "mode": "name", "value": "blucca_vapi_demo_orders" }, "matchType": "allConditions", "filters": { "conditions": [ { "keyName": "orderId", "condition": "eq", "keyValue": "={{ $json.orderId }}" }, { "keyName": "email", "condition": "eq", "keyValue": "={{ $json.email }}" } ] }, "returnAll": false, "limit": 2 }, "alwaysOutputData": true, "onError": "continueRegularOutput" }, { "id": "resolve-order", "name": "Resolve order", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 360, 40 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const tool = $('Validate current tool').item.json;\nconst items = $input.all(); const row = items[0].json;\nlet response; let callbackReady = false;\nif (row.error || items[0].error || items.length > 1) response = {toolCallId:tool.toolCallId,error:\"Order support is temporarily unavailable. Please try again later.\"};\nelse if (!row.id || row.orderId !== tool.orderId || row.email !== tool.email) response = {toolCallId:tool.toolCallId,result:JSON.stringify({status:'order_not_found',message:'No order matched those details. Please check the order number and email.'})};\nelse if (tool.toolName === 'requestCallback') callbackReady = true;\nelse response = {toolCallId:tool.toolCallId,result:JSON.stringify({status:'found',orderId:row.orderId,fulfillmentStatus:row.status,carrier:row.carrier ?? '',trackingNumber:row.trackingNumber ?? '',estimatedDelivery:row.estimatedDelivery ?? ''})};\nreturn [{json:{...tool,callbackReady,response}}];" } }, { "id": "callback-ready-", "name": "Callback ready?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 580, 40 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "condition", "leftValue": "={{ $json.callbackReady }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "order-result", "name": "Order result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 800, 280 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "return [{json:$input.first().json.response}];" } }, { "id": "find-callback-request", "name": "Find callback request", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 800, -40 ], "parameters": { "resource": "row", "operation": "get", "dataTableId": { "__rl": true, "mode": "name", "value": "blucca_vapi_demo_callbacks" }, "matchType": "allConditions", "filters": { "conditions": [ { "keyName": "requestKey", "condition": "eq", "keyValue": "={{ $json.requestKey }}" } ] }, "returnAll": false, "limit": 2 }, "alwaysOutputData": true, "onError": "continueRegularOutput" }, { "id": "resolve-callback-request", "name": "Resolve callback request", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1020, -40 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const tool = $('Resolve order').item.json;\nconst items = $input.all(); const row = items[0].json;\nlet response; let shouldStore = false;\nif (row.error || items[0].error || items.length > 1) response = {toolCallId:tool.toolCallId,error:\"Order support is temporarily unavailable. Please try again later.\"};\nelse if (row.id) {\n if (['orderId','email','phone','reason'].some(key => row[key] !== tool[key])) response = {toolCallId:tool.toolCallId,error:'This tool call already recorded a different callback request.'};\n else response = {toolCallId:tool.toolCallId,result:JSON.stringify({status:'callback_requested',requestId:String(row.id),orderId:row.orderId,message:'Your callback request has been recorded for the support team.'})};\n} else shouldStore = true;\nreturn [{json:{...tool,shouldStore,response}}];" } }, { "id": "store-new-callback-", "name": "Store new callback?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 1240, -40 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "condition", "leftValue": "={{ $json.shouldStore }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "store-callback-request", "name": "Store callback request", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 1460, -120 ], "parameters": { "resource": "row", "operation": "upsert", "dataTableId": { "__rl": true, "mode": "name", "value": "blucca_vapi_demo_callbacks" }, "matchType": "allConditions", "filters": { "conditions": [ { "keyName": "requestKey", "condition": "eq", "keyValue": "={{ $json.requestKey }}" } ] }, "columns": { "mappingMode": "defineBelow", "value": { "requestKey": "={{ $json.requestKey }}", "orderId": "={{ $json.orderId }}", "email": "={{ $json.email }}", "phone": "={{ $json.phone }}", "reason": "={{ $json.reason }}", "status": "pending" }, "matchingColumns": [], "schema": [ { "id": "requestKey", "displayName": "requestKey", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true }, { "id": "orderId", "displayName": "orderId", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true }, { "id": "email", "displayName": "email", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true }, { "id": "phone", "displayName": "phone", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true }, { "id": "reason", "displayName": "reason", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true }, { "id": "status", "displayName": "status", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true } ], "attemptToConvertTypes": false }, "options": {} }, "alwaysOutputData": true, "onError": "continueRegularOutput" }, { "id": "stored-callback-result", "name": "Stored callback result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1680, -120 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const tool = $('Resolve callback request').item.json;\nconst items = $input.all(); const row = items[0].json;\nif (row.error || items[0].error || items.length !== 1 || !row.id) return [{json:{toolCallId:tool.toolCallId,error:\"Order support is temporarily unavailable. Please try again later.\"}}];\nreturn [{json:{toolCallId:tool.toolCallId,result:JSON.stringify({status:'callback_requested',requestId:String(row.id),orderId:row.orderId,message:'Your callback request has been recorded for the support team.'})}}];" } }, { "id": "existing-callback-result", "name": "Existing callback result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1460, 120 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "return [{json:$input.first().json.response}];" } }, { "id": "build-vapi-response", "name": "Build Vapi response", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -300, -240 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "return [{json:{results:$input.all().map(item => item.json)}}];" } }, { "id": "respond-to-vapi", "name": "Respond to Vapi", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.4, "position": [ -80, -240 ], "parameters": { "respondWith": "json", "responseBody": "={{ $json }}", "options": { "responseCode": 200 } } }, { "id": "start-here-5-minute-setup", "name": "Start here — 5 minute setup", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -1020, -1140 ], "parameters": { "width": 1530, "height": 390, "color": 5, "content": "## Vapi order support: status answers + human callback queue\n**1. Run “Set up demo tables” once.** It creates blucca_vapi_demo_orders and blucca_vapi_demo_callbacks, then upserts two synthetic orders. Re-running setup restores those two demo orders; callback records stay in place. Tables are referenced by name in this workflow's project.\n**2. Configure “Vapi webhook” → Header Auth:** header name **X-Vapi-Secret**, a secret value of your choice. Set the same header in the server settings of both Vapi tools.\n**3. Activate the workflow.** Copy the Production webhook URL into both tools. Tool definitions and sample requests are alongside workflow.json.\n**4. Try ORD-1001 / alex@example.com and ORD-1002 / jamie@example.com.** All seed values are synthetic.\n**5. Open the callback Data Table.** Filter status=pending, contact the customer, and update status when handled. Callback responses acknowledge a recorded request.\nUses standard n8n nodes and Data Tables v1.1; target n8n 2.41.7." } }, { "id": "tool-contract-and-outcomes", "name": "Tool contract and outcomes", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -1020, 580 ], "parameters": { "width": 1390, "height": 380, "color": 4, "content": "## One result per tool call\n**getOrderStatus(orderId, email):** exact order ID and normalized email must match the same row. Returns only status, carrier, tracking number and estimated delivery.\n**requestCallback(orderId, email, phone, reason):** verifies that order, stores a request, returns the row ID. Requires message.call.id.\nMissing order / mismatched email share the order_not_found business result. Unknown tools and invalid arguments return that tool's error; sibling tools continue. HTTP 200 wraps all results. Each result/error is a single-line string paired with its original toolCallId.\nInput is the Vapi custom-tools shape: body.message.type=tool-calls and message.toolCallList[].function.arguments as an object. Tool-call IDs must be non-empty and unique within a message. Configure the assistant to ask the caller to check their order ID/email after order_not_found." } }, { "id": "connect-real-orders-and-handle-callbacks", "name": "Connect real orders and handle callbacks", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 440, -680 ], "parameters": { "width": 1440, "height": 460, "color": 6, "content": "## From synthetic demo to your support queue\nPopulate the orders table with your own orderId, email, status, carrier, trackingNumber and estimatedDelivery columns. Normalize email to lowercase. Use one row per orderId/email pair. Keep table names unique in this n8n project; select your tables in the Data Table nodes when adapting names. Run the seed branch against dedicated demo tables.\n## Replay behavior\nCallback requestKey = JSON.stringify([message.call.id, toolCall.id]). A sequential replay returns the existing row and preserves its status. Changed callback details for that key produce an explicit error. A new toolCallId creates a new request.\nData Table upsert uses a transaction with update/insert. Concurrent first writes require a separately verified deployment contract; PostgreSQL UNIQUE(requestKey) is the integration path for atomic business uniqueness.\n## Operational scope\nThe supplied workflow stores an actionable callback queue for a support operator. Customer communications, store synchronization and voice-agent behavior follow your connected systems. Keep the order response fields concise and set Vapi tool timeout to suit your tested n8n endpoint latency." } } ], "connections": { "Set up demo tables": { "main": [ [ { "node": "Create demo orders", "type": "main", "index": 0 } ] ] }, "Create demo orders": { "main": [ [ { "node": "Create callback queue", "type": "main", "index": 0 } ] ] }, "Create callback queue": { "main": [ [ { "node": "Two synthetic orders", "type": "main", "index": 0 } ] ] }, "Two synthetic orders": { "main": [ [ { "node": "Seed demo orders", "type": "main", "index": 0 } ] ] }, "Seed demo orders": { "main": [ [ { "node": "Setup ready", "type": "main", "index": 0 } ] ] }, "Vapi webhook": { "main": [ [ { "node": "Split tool calls", "type": "main", "index": 0 } ] ] }, "Split tool calls": { "main": [ [ { "node": "Each tool call", "type": "main", "index": 0 } ] ] }, "Each tool call": { "main": [ [ { "node": "Build Vapi response", "type": "main", "index": 0 } ], [ { "node": "Validate current tool", "type": "main", "index": 0 } ] ] }, "Build Vapi response": { "main": [ [ { "node": "Respond to Vapi", "type": "main", "index": 0 } ] ] }, "Validate current tool": { "main": [ [ { "node": "Valid arguments?", "type": "main", "index": 0 } ] ] }, "Valid arguments?": { "main": [ [ { "node": "Find matching order", "type": "main", "index": 0 } ], [ { "node": "Argument error result", "type": "main", "index": 0 } ] ] }, "Argument error result": { "main": [ [ { "node": "Each tool call", "type": "main", "index": 0 } ] ] }, "Find matching order": { "main": [ [ { "node": "Resolve order", "type": "main", "index": 0 } ] ] }, "Resolve order": { "main": [ [ { "node": "Callback ready?", "type": "main", "index": 0 } ] ] }, "Callback ready?": { "main": [ [ { "node": "Find callback request", "type": "main", "index": 0 } ], [ { "node": "Order result", "type": "main", "index": 0 } ] ] }, "Order result": { "main": [ [ { "node": "Each tool call", "type": "main", "index": 0 } ] ] }, "Find callback request": { "main": [ [ { "node": "Resolve callback request", "type": "main", "index": 0 } ] ] }, "Resolve callback request": { "main": [ [ { "node": "Store new callback?", "type": "main", "index": 0 } ] ] }, "Store new callback?": { "main": [ [ { "node": "Store callback request", "type": "main", "index": 0 } ], [ { "node": "Existing callback result", "type": "main", "index": 0 } ] ] }, "Store callback request": { "main": [ [ { "node": "Stored callback result", "type": "main", "index": 0 } ] ] }, "Stored callback result": { "main": [ [ { "node": "Each tool call", "type": "main", "index": 0 } ] ] }, "Existing callback result": { "main": [ [ { "node": "Each tool call", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1" }, "active": false }