AutomationFlowsData & Sheets › Handle Dental Booking Calls with Vapi, Airtable, and Gotohuman

Handle Dental Booking Calls with Vapi, Airtable, and Gotohuman

ByDivyanshu Gupta @divyanshugupta on n8n.io

This workflow powers a voice AI receptionist for dental clinics that never bluffs. It answers calls via Vapi, checks availability, verifies insurance, and books appointments in Airtable then instantly escalates urgent or distressed callers to a real human through gotoHuman.…

Webhook trigger★★★★☆ complexity29 nodesAirtable@Gotohuman/N8N Nodes Gotohuman
Data & Sheets Trigger: Webhook Nodes: 29 Complexity: ★★★★☆ Added:
Handle Dental Booking Calls with Vapi, Airtable, and Gotohuman — n8n workflow card showing Airtable, @Gotohuman/N8N Nodes Gotohuman integration

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

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": "oflxzPJ2ugYuM9Xh",
  "name": "AI Voice Receptionist for Dental Clinics",
  "tags": [],
  "nodes": [
    {
      "id": "sticky_overview",
      "name": "OVERVIEW",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -912,
        160
      ],
      "parameters": {
        "color": 4,
        "width": 688,
        "height": 956,
        "content": "## \ud83e\uddb7 AI Voice Receptionist for Dental Clinics\n### Vapi \u2014 Real-Time Booking & Human Escalation\n\nA production-shaped voice receptionist for dental clinics, powered by Vapi. Answers calls, checks availability, checks insurance, books appointments in real time, and **escalates to a real human instead of guessing** whenever something is urgent, the caller sounds distressed, or the request falls outside its scope.\n\n**Why this isn't just another AI receptionist template:** most booking-bot templates try to resolve every call autonomously. This one is built around a narrow-scope + honest-escalation philosophy \u2014 the AI does exactly 4 defined jobs and hands off cleanly the moment it's uncertain, instead of improvising a plausible-sounding wrong answer.\n\n### Stack\n- **Vapi** \u2014 voice orchestration (STT \u2192 LLM \u2192 TTS) and the caller-facing experience; calls this workflow via 4 custom \"API Request\" tools\n- **This workflow** \u2014 routing logic, mocked business logic, Airtable logging (built in n8n)\n- **Airtable** \u2014 call log + insurance reference table\n- **gotoHuman** \u2014 async human review queue for escalations\n\n### Setup checklist\n1. Create an Airtable base with a `Calls` table (see field list in the yellow BOOKING note) and connect your own Airtable credential to both Airtable nodes\n2. Create a gotoHuman review form + agent, connect your own gotoHuman credential to the escalation node, and select your form/agent in its dropdowns\n3. Activate this workflow, copy the **Vapi Tool Call Webhook** production URL\n4. In Vapi: create 4 \"API Request\" tools (`check_availability`, `check_insurance`, `book_appointment`, `escalate_to_human`) all pointing at that URL \n5. **If self-hosting behind ngrok's free tier:** add header `ngrok-skip-browser-warning: true` to every tool, or Vapi's requests will get an HTML interstitial instead of your webhook\n6. Add the system prompt into your Vapi assistant\n7. For the Approve/Reject buttons in gotoHuman to work, paste this workflow's **gotoHuman Review Callback** URL into your gotoHuman agent's webhook settings\n"
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_routing",
      "name": "1\ufe0f\u20e3 CALL ROUTING",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -48,
        176
      ],
      "parameters": {
        "width": 520,
        "height": 356,
        "content": "## 1\ufe0f\u20e3 Receive + Route\nVapi calls this webhook every time it needs to do something \u2014 check availability, check insurance, book, or escalate."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_availability",
      "name": "2\ufe0f\u20e3 AVAILABILITY",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        496,
        176
      ],
      "parameters": {
        "color": 3,
        "width": 404,
        "height": 352,
        "content": "## 2\ufe0f\u20e3 Check Availability\nReturns 3 hardcoded slots. Replace with a real Google Calendar free/busy lookup for production use."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_insurance",
      "name": "3\ufe0f\u20e3 INSURANCE",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        976,
        176
      ],
      "parameters": {
        "color": 5,
        "width": 340,
        "height": 352,
        "content": "## 3\ufe0f\u20e3 Check Insurance\nHardcoded list of 5 providers. Replace with a real Airtable lookup table for production use."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_booking",
      "name": "4\ufe0f\u20e3 BOOKING",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1056,
        688
      ],
      "parameters": {
        "color": 2,
        "width": 1008,
        "height": 420,
        "content": "## 4\ufe0f\u20e3 Book Appointment\nWrites a **real** record to Airtable. Requires a `Calls` table with fields: Caller Name, Phone, Appointment Type, Insurance Provider, Slot Time, Source (single select: mock_logic_test / vapi_live_booking), Reason, Urgency, Needs Human Tone, Insurance Status, Slot Duration."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_escalation",
      "name": "\ud83d\udea8 ESCALATION",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1664,
        1120
      ],
      "parameters": {
        "color": 6,
        "width": 944,
        "height": 356,
        "content": "## \ud83d\udea8 Escalate to Human (gotoHuman)\nFires on emergency language, caller distress, or anything outside the assistant's 4 jobs. Uses **'send'** (fire-and-forget) not 'sendAndWait' \u2014 a live call can't stay open for days waiting on a human. The caller is told someone will follow up; the review happens async in your gotoHuman inbox."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_review_callback",
      "name": "\ud83d\udd01 REVIEW CALLBACK",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -144,
        880
      ],
      "parameters": {
        "color": 7,
        "width": 1028,
        "height": 372,
        "content": "## \ud83d\udd01 gotoHuman Review Callback\nReceives Approve/Reject decisions from gotoHuman and logs them to Airtable."
      },
      "typeVersion": 1
    },
    {
      "id": "vapi_webhook",
      "name": "Vapi Tool Call Webhook",
      "type": "n8n-nodes-base.webhook",
      "notes": "This is the URL you'll paste into Vapi as each tool's Server URL. Vapi calls this MID-CALL whenever the assistant runs check_availability, check_insurance, book_appointment, or escalate_to_human.",
      "position": [
        0,
        400
      ],
      "parameters": {
        "path": "vapi-dental-functions",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "extract_tool_call",
      "name": "Extract Tool Call",
      "type": "n8n-nodes-base.code",
      "notes": "Handles Vapi's 'API Request' tool format: raw params directly, no toolCallList wrapper. Function name inferred from which parameters are present, since all 4 tools share this one URL.",
      "position": [
        272,
        400
      ],
      "parameters": {
        "jsCode": "const body = $input.first().json.body || $input.first().json;\n\n// Custom Function tool format (message.toolCallList) \u2014 supported in case you switch to this later\nconst toolCall = body.message?.toolCallList?.[0];\nif (toolCall) {\n  return [{\n    json: {\n      toolCallId: toolCall.id,\n      functionName: toolCall.function?.name || '',\n      args: toolCall.function?.arguments || {}\n    }\n  }];\n}\n\n// API Request tool format \u2014 Vapi sends the raw params directly, no function name field.\n// Infer which of the 4 tools was called based on which params are present.\n// Check escalate_to_human FIRST since it can also carry caller_name/phone, which would otherwise collide with book_appointment.\nlet functionName = '';\nif ('reason' in body || 'urgency' in body || 'sentiment_flag' in body) {\n  functionName = 'escalate_to_human';\n} else if ('caller_name' in body || 'slot_time' in body) {\n  functionName = 'book_appointment';\n} else if ('insurance_provider' in body) {\n  functionName = 'check_insurance';\n} else if ('appointment_type' in body) {\n  functionName = 'check_availability';\n}\n\nreturn [{\n  json: {\n    toolCallId: 'direct-call',\n    functionName,\n    args: body\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "if_availability",
      "name": "Is Check Availability?",
      "type": "n8n-nodes-base.if",
      "notes": "Routes to slot-availability logic.",
      "position": [
        528,
        400
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{$json.functionName}}",
              "rightValue": "check_availability"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "if_insurance",
      "name": "Is Check Insurance?",
      "type": "n8n-nodes-base.if",
      "notes": "Routes to insurance-lookup logic.",
      "position": [
        784,
        560
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{$json.functionName}}",
              "rightValue": "check_insurance"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "if_booking",
      "name": "Is Book Appointment?",
      "type": "n8n-nodes-base.if",
      "notes": "Routes to booking logic.",
      "position": [
        1104,
        928
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{$json.functionName}}",
              "rightValue": "book_appointment"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "check_availability_logic",
      "name": "Check Availability Logic",
      "type": "n8n-nodes-base.code",
      "notes": "MOCKED: hardcoded slot list. Replace with a real Google Calendar 'Get Free/Busy' lookup for production.",
      "position": [
        752,
        352
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\nconst args = item.args;\n\nconst durations = { cleaning: 30, filling: 45, 'root canal': 90, extraction: 60, checkup: 20, whitening: 45 };\nconst type = (args.appointment_type || 'cleaning').toLowerCase();\nconst duration = durations[type] || 30;\n\n// MOCK slots \u2014 swap for real Google Calendar availability lookup\nconst mockSlots = ['tomorrow at 10:00 AM', 'tomorrow at 2:30 PM', 'Thursday at 9:00 AM'];\n\nreturn [{\n  json: {\n    toolCallId: item.toolCallId,\n    result: `For a ${type} appointment, which takes about ${duration} minutes, we have openings ${mockSlots.join(', ')}.`\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "check_insurance_logic",
      "name": "Check Insurance Logic",
      "type": "n8n-nodes-base.code",
      "notes": "MOCKED: hardcoded in-network list. Replace with a real Airtable lookup for production.",
      "position": [
        1104,
        384
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\nconst args = item.args;\n\nconst inNetworkPlans = ['delta dental', 'cigna dental', 'metlife', 'aetna dental', 'guardian'];\nconst provider = (args.insurance_provider || '').toLowerCase();\nconst inNetwork = inNetworkPlans.some(p => provider.includes(p));\n\nlet resultText;\nif (!provider) {\n  resultText = \"I didn't catch the insurance provider name \u2014 could you repeat it?\";\n} else if (inNetwork) {\n  resultText = `Good news \u2014 ${args.insurance_provider} is in-network with us.`;\n} else {\n  resultText = `I'm not seeing ${args.insurance_provider} as in-network on my end, but our front desk will confirm your exact coverage before your visit.`;\n}\n\nreturn [{ json: { toolCallId: item.toolCallId, result: resultText } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "book_appointment_prep",
      "name": "Book Appointment Prep",
      "type": "n8n-nodes-base.code",
      "notes": "Vapi assistant should call book_appointment with args: {caller_name, phone, appointment_type, slot_time, insurance_provider}.",
      "position": [
        1328,
        848
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\nconst args = item.args;\n\nreturn [{\n  json: {\n    toolCallId: item.toolCallId,\n    caller_name: args.caller_name || 'Unknown',\n    phone: args.phone || '',\n    appointment_type: (args.appointment_type || 'cleaning').toLowerCase(),\n    slot_time: args.slot_time || 'unspecified',\n    insurance_provider: args.insurance_provider || ''\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "log_booking_airtable",
      "name": "Log Booking to Airtable",
      "type": "n8n-nodes-base.airtable",
      "notes": "SETUP NEEDED: connect your own Airtable credential, then select your base + Calls table from the dropdowns.",
      "position": [
        1552,
        848
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "value": {
            "Phone": "={{$json.phone}}",
            "Source": "vapi_live_booking",
            "Slot Time": "={{$json.slot_time}}",
            "Caller Name": "={{$json.caller_name}}",
            "Appointment Type": "={{$json.appointment_type}}",
            "Insurance Provider": "={{$json.insurance_provider}}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "create"
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "format_booking_result",
      "name": "Format Booking Result",
      "type": "n8n-nodes-base.code",
      "notes": "This is what gets spoken back to the caller by Vapi.",
      "position": [
        1792,
        848
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\n\nreturn [{\n  json: {\n    toolCallId: item.toolCallId,\n    result: `You're booked for a ${item.appointment_type} appointment ${item.slot_time}. We'll send a confirmation text to ${item.phone}.`\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "unknown_function_result",
      "name": "Unknown Function Result",
      "type": "n8n-nodes-base.code",
      "notes": "Fallback for any function name that isn't recognized.",
      "position": [
        1920,
        976
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\nreturn [{ json: { toolCallId: item.toolCallId, result: \"Sorry, I'm not able to handle that yet \u2014 let me get you to the front desk.\" } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "merge_function_results",
      "name": "Merge Function Results",
      "type": "n8n-nodes-base.merge",
      "notes": "Uses 'append' mode \u2014 required since only 1 of 5 branches ever fires per call. 'combine' mode will silently produce zero output here.",
      "position": [
        2080,
        400
      ],
      "parameters": {
        "numberInputs": 5
      },
      "typeVersion": 3
    },
    {
      "id": "wrap_vapi_format",
      "name": "Format Response for Vapi",
      "type": "n8n-nodes-base.code",
      "notes": "API Request tools expect a plain JSON response, not a toolCallId-wrapped format.",
      "position": [
        2352,
        400
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\n\nreturn [{\n  json: {\n    result: item.result\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "respond_vapi",
      "name": "Respond To Vapi",
      "type": "n8n-nodes-base.respondToWebhook",
      "notes": "Forces a raw JSON string body with explicit Content-Type header \u2014 more reliable than relying on respondWith:'json' auto-serialization.",
      "position": [
        2608,
        400
      ],
      "parameters": {
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "text",
        "responseBody": "={{ JSON.stringify($json) }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "if_escalate_vapi",
      "name": "Is Escalate To Human?",
      "type": "n8n-nodes-base.if",
      "notes": "Catches the escalate_to_human tool call.",
      "position": [
        1712,
        1312
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{$json.functionName}}",
              "rightValue": "escalate_to_human"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "escalate_prep_vapi",
      "name": "Escalate Prep",
      "type": "n8n-nodes-base.code",
      "notes": "Vapi assistant should call escalate_to_human with args: {caller_name, phone, reason, urgency, sentiment_flag, appointment_type, insurance_provider}.",
      "position": [
        1936,
        1296
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\nconst args = item.args;\n\nreturn [{\n  json: {\n    toolCallId: item.toolCallId,\n    caller_name: args.caller_name || 'Unknown',\n    phone: args.phone || '',\n    reason: args.reason || '',\n    urgency: args.urgency || 'urgent',\n    sentiment_flag: args.sentiment_flag || 'neutral',\n    appointment_type: args.appointment_type || '',\n    insurance_provider: args.insurance_provider || ''\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "gotohuman_vapi",
      "name": "Escalate To Human (gotoHuman)",
      "type": "@gotohuman/n8n-nodes-gotohuman.gotoHuman",
      "notes": "SETUP NEEDED: connect your own gotoHuman credential, create a review form with fields matching reviewData below, then select your form + agent in the dropdowns. Uses 'send' (fire-and-forget) not 'sendAndWait' \u2014 a live call can't stay open waiting on a human review.",
      "position": [
        2192,
        1296
      ],
      "parameters": {
        "agentID": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "operation": "send",
        "reviewTemplateID": {
          "__rl": true,
          "mode": "list",
          "value": ""
        }
      },
      "typeVersion": 2
    },
    {
      "id": "format_escalation_result",
      "name": "Format Escalation Result",
      "type": "n8n-nodes-base.code",
      "notes": "This is what gets spoken back to the caller once escalation is sent.",
      "position": [
        2432,
        1296
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\n\nreturn [{\n  json: {\n    toolCallId: item.toolCallId,\n    result: \"I've flagged this for our team \u2014 someone will reach out to you directly as soon as possible.\"\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "gth_callback_webhook",
      "name": "gotoHuman Review Callback",
      "type": "n8n-nodes-base.webhook",
      "notes": "Paste this webhook's URL into gotoHuman's Agent settings so Approve/Reject decisions have somewhere to go.",
      "position": [
        -96,
        1024
      ],
      "parameters": {
        "path": "gotohuman-review-callback",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "parse_review_event",
      "name": "Parse Review Event",
      "type": "n8n-nodes-base.code",
      "notes": "gotoHuman's exact payload shape wasn't confirmed at build time \u2014 this logs the raw event regardless of exact structure. Refine once you see a real payload.",
      "position": [
        176,
        1024
      ],
      "parameters": {
        "jsCode": "const body = $input.first().json.body || $input.first().json;\n\nreturn [{\n  json: {\n    reviewId: body.reviewId || body.id || 'unknown',\n    eventType: body.type || body.event || 'unknown',\n    decision: body.status || body.decision || body.result || 'unknown',\n    rawPayload: JSON.stringify(body)\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "log_review_outcome",
      "name": "Log Review Outcome to Airtable",
      "type": "n8n-nodes-base.airtable",
      "notes": "SETUP NEEDED: connect your own Airtable credential and select base + table.",
      "position": [
        432,
        1024
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "value": {
            "Reason": "={{$json.rawPayload}}",
            "Caller Name": "=[Review Decision] {{$json.decision}}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "create"
      },
      "typeVersion": 2.1
    },
    {
      "id": "respond_review_callback",
      "name": "Respond OK to gotoHuman",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        688,
        1024
      ],
      "parameters": {
        "options": {},
        "respondWith": "text",
        "responseBody": "={{ JSON.stringify({received: true}) }}"
      },
      "typeVersion": 1.1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveExecutionProgress": true,
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all"
  },
  "versionId": "e866c3f1-adb1-43cb-990f-e97db595a707",
  "nodeGroups": [],
  "connections": {
    "Escalate Prep": {
      "main": [
        [
          {
            "node": "Escalate To Human (gotoHuman)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Tool Call": {
      "main": [
        [
          {
            "node": "Is Check Availability?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Review Event": {
      "main": [
        [
          {
            "node": "Log Review Outcome to Airtable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Check Insurance?": {
      "main": [
        [
          {
            "node": "Check Insurance Logic",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Is Book Appointment?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Book Appointment?": {
      "main": [
        [
          {
            "node": "Book Appointment Prep",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Is Escalate To Human?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Book Appointment Prep": {
      "main": [
        [
          {
            "node": "Log Booking to Airtable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Insurance Logic": {
      "main": [
        [
          {
            "node": "Merge Function Results",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Format Booking Result": {
      "main": [
        [
          {
            "node": "Merge Function Results",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "Is Escalate To Human?": {
      "main": [
        [
          {
            "node": "Escalate Prep",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Unknown Function Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Check Availability?": {
      "main": [
        [
          {
            "node": "Check Availability Logic",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Is Check Insurance?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Function Results": {
      "main": [
        [
          {
            "node": "Format Response for Vapi",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Vapi Tool Call Webhook": {
      "main": [
        [
          {
            "node": "Extract Tool Call",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Booking to Airtable": {
      "main": [
        [
          {
            "node": "Format Booking Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Unknown Function Result": {
      "main": [
        [
          {
            "node": "Merge Function Results",
            "type": "main",
            "index": 3
          }
        ]
      ]
    },
    "Check Availability Logic": {
      "main": [
        [
          {
            "node": "Merge Function Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Escalation Result": {
      "main": [
        [
          {
            "node": "Merge Function Results",
            "type": "main",
            "index": 4
          }
        ]
      ]
    },
    "Format Response for Vapi": {
      "main": [
        [
          {
            "node": "Respond To Vapi",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "gotoHuman Review Callback": {
      "main": [
        [
          {
            "node": "Parse Review Event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Escalate To Human (gotoHuman)": {
      "main": [
        [
          {
            "node": "Format Escalation Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Review Outcome to Airtable": {
      "main": [
        [
          {
            "node": "Respond OK to gotoHuman",
            "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

This workflow powers a voice AI receptionist for dental clinics that never bluffs. It answers calls via Vapi, checks availability, verifies insurance, and books appointments in Airtable then instantly escalates urgent or distressed callers to a real human through gotoHuman.…

Source: https://n8n.io/workflows/17490/ — original creator credit. Request a take-down →

More Data & Sheets workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Data & Sheets

This template is ideal for small businesses, agencies, and solo professionals who want to automate appointment scheduling and caller follow-up through a voice-based AI receptionist. If you’re using to

Item Lists, Google Calendar, Airtable
Data & Sheets

AI Receptionist. Uses googleCalendar, airtable. Webhook trigger; 59 nodes.

Google Calendar, Airtable
Data & Sheets

This premium n8n workflow harnesses the power of DataForSEO's API combined with Airtable's relational database capabilities to transform your keyword research process, providing deeper insights for co

HTTP Request, Airtable
Data & Sheets

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Airtable, HTTP Request, Google Drive +1
Data & Sheets

This workflow automates the entire lifecycle of a service-based client, combining four distinct business flows into a single view: Intake Leads: Receives a webhook from your form builder, validates th

Airtable, Notion, Google Calendar +3