See it work
Vapi Guide · 2026-06-27 · 14 min read · WildRun AI Engineering

Vapi Function Calling: Complete Guide for Developers

Learn how Vapi function calling works — custom tools, Code Tools, webhook contracts, and TypeScript patterns for production voice AI agents.

Vapi Function Calling: Complete Guide for Developers

Function calling is what separates a Vapi voice agent that can talk about scheduling from one that can actually book the appointment. When a caller says "Can I come in Thursday at 2?" the assistant needs to query your availability system, confirm the slot, and write the booking — all within natural conversation flow. That requires tools.

This guide covers every layer of Vapi function calling in TypeScript: the three tool types Vapi offers, the exact webhook contract your server must honor, how to validate arguments safely with Zod, the Code Tool alternative for serverless execution, and the production failure modes that catch developers off guard the first time.

The Three Tool Types

Vapi organizes tools into three categories with meaningfully different tradeoffs. Knowing which one to reach for saves significant infrastructure overhead.

Default Tools are built-in Vapi capabilities that require no server on your end: transferCall, endCall, sms, dtmf (send keypad tones to navigate IVR systems downstream), and apiRequest (make a direct HTTP call from within Vapi without hosting anything). If your use case is routing a call, ending it, or hitting a public REST endpoint with a static auth token, start here — zero infrastructure required.

Custom Tools (also called function tools) are webhook-based: you define a function schema, Vapi sends a POST to your server URL when the assistant decides to invoke the tool, and your server executes the logic and returns a result string. This is the right choice when you need to query a private database, enforce authorization, run complex business logic, or integrate with a system that requires your own credential management.

Code Tools let you write TypeScript that runs directly on Vapi's infrastructure — no webhook server required. Your function receives the tool arguments as parameters and must return a string. The default execution timeout is 10 seconds with a maximum of 60 seconds. Code Tools are ideal for lightweight data lookups and public API calls where your only auth requirement is an API key stored in the tool's environment config.

The LLM decides when to call each tool based on the description field and the live conversation context. This makes the description the single most important parameter in your entire tool definition. "Get appointment" will misfire constantly. "Look up available appointment slots when the caller asks about scheduling, booking, or availability — requires a date and service type" gives the model what it needs to make accurate decisions about tool selection.

Defining a Custom Tool

Tool definitions follow the same JSON Schema standard as OpenAI function calling. You can create tools via the Vapi dashboard or programmatically with the Vapi server SDK. Defining them in code is preferable for anything beyond a quick prototype — it keeps definitions version-controlled and lets you sync them as part of a deploy pipeline.

TypeScriptTool Definition via Vapi SDK
import { VapiClient } from "@vapi-ai/server-sdk";

const vapi = new VapiClient({ token: process.env.VAPI_API_KEY! });

await vapi.tools.create({
  type: "function",
  function: {
    name: "check_availability",
    description:
      "Look up open appointment slots when the caller asks about booking, scheduling, or availability. Requires a service type and preferred date.",
    parameters: {
      type: "object",
      properties: {
        service_type: {
          type: "string",
          enum: ["cleaning", "exam", "xray", "extraction"],
          description: "The type of service the caller is requesting",
        },
        preferred_date: {
          type: "string",
          description: "Caller's preferred date in YYYY-MM-DD format",
        },
        provider_id: {
          type: "string",
          description: "Optional provider ID if the caller requested a specific provider",
        },
      },
      required: ["service_type", "preferred_date"],
    },
  },
  server: {
    url: "https://tools.your-domain.workers.dev/vapi/tools",
  },
});

The server.url is where Vapi will POST the tool call payload. You can set a single server URL at the account level to route all tool calls through one handler, or configure per-tool URLs for more granular routing. The tool's name is how your handler distinguishes which function was invoked. All calls — regardless of which tool was triggered — arrive at the same endpoint.

The Webhook Request Contract

When the LLM decides to invoke a tool during a live call, Vapi sends an HTTP POST to your server URL. Understanding the exact shape of that request is non-negotiable — a misread field here causes silent failures that are difficult to debug on a live call.

TypeScriptTypeScript Interfaces: Request and Response Shapes
// The POST body Vapi sends to your server URL
interface VapiToolCallRequest {
  message: {
    type: "tool-calls";
    timestamp: string;
    toolCallList: Array<{
      id: string;         // toolCallId — must echo this exactly in your response
      type: "function";
      function: {
        name: string;     // matches your tool definition's function.name
        arguments: Record<string, unknown>; // already parsed — NOT a JSON string
      };
    }>;
    call: {
      id: string;         // Vapi call ID — useful for audit logs and async delivery
      orgId: string;
    };
    assistant: {
      name: string;
      model: { provider: string; model: string };
    };
  };
}

// What your server must return — always HTTP 200
interface VapiToolCallResponse {
  results: Array<{
    toolCallId: string; // must exactly match the id from the request
    result?: string;    // success path — must be a string, not an object or array
    error?: string;     // failure path — also a string; the LLM recovers gracefully
  }>;
}

Three things that trip up developers reading this contract for the first time:

  • toolCallList can contain multiple calls in one request. The LLM sometimes batches tool invocations. Your handler must process every item and return a results entry for each id. A missing result causes the assistant to stall waiting for data that never arrives.
  • arguments is already an object. Unlike the raw OpenAI API where arguments arrive as a JSON string that you must parse, Vapi parses them for you. Calling JSON.parse() on them again causes a runtime error.
  • result and error must be strings. If you need to return structured data, serialize with JSON.stringify() and note in your tool description that it will receive JSON. Returning a raw object silently breaks the pipeline.

Building the Webhook Handler

The optimal deployment target for a Vapi tool webhook is Cloudflare Workers: sub-millisecond cold starts globally, no provisioned concurrency to manage, and total execution time that fits comfortably within Vapi's hard 7.5-second response deadline. The example below uses Hono, which compiles to a clean Workers-compatible bundle with excellent TypeScript support.

TypeScriptHono Cloudflare Worker Webhook Handler
import { Hono } from "hono";
import { checkAvailability } from "./tools/availability";
import { bookAppointment } from "./tools/booking";

type Bindings = { DB: D1Database; VAPI_SECRET: string };

const app = new Hono<{ Bindings: Bindings }>();

app.post("/vapi/tools", async (c) => {
  // Verify shared secret — but always return HTTP 200, even on auth failure
  const secret = c.req.header("x-vapi-secret");
  if (secret !== c.env.VAPI_SECRET) {
    // Non-200 responses are silently dropped by Vapi — return 200 with an error string
    return c.json({
      results: [{ toolCallId: "unknown", error: "Unauthorized request" }],
    });
  }

  const body = await c.req.json<VapiToolCallRequest>();
  const { toolCallList, call } = body.message;

  // Process all tool calls concurrently — batched calls arrive in one request
  const results = await Promise.all(
    toolCallList.map(async ({ id: toolCallId, function: fn }) => {
      try {
        const result = await dispatch(fn.name, fn.arguments, call.id, c.env);
        return { toolCallId, result };
      } catch (err) {
        const message = err instanceof Error ? err.message : "Internal tool error";
        return { toolCallId, error: message };
      }
    })
  );

  return c.json({ results }); // always HTTP 200
});

async function dispatch(
  name: string,
  args: Record<string, unknown>,
  callId: string,
  env: Bindings
): Promise<string> {
  switch (name) {
    case "check_availability":
      return checkAvailability(args, env.DB);
    case "book_appointment":
      return bookAppointment(args, callId, env.DB);
    default:
      throw new Error(`Unknown tool: ${name}`);
  }
}

export default app;

The outer try/catch around each tool call dispatch ensures that an error in one tool handler does not prevent the other batched tool calls from completing. Each call gets its own error boundary, and the overall response always returns HTTP 200 with a per-call error string where needed.

Validating Arguments and Returning Results

The LLM will occasionally pass arguments that deviate from your schema — wrong types, values outside an enum, or missing required fields under unusual caller phrasing. Validate every incoming argument before touching your database or calling external APIs. Zod is the right tool here: it validates at runtime and narrows TypeScript types in a single operation, eliminating a separate type assertion step.

TypeScriptZod Validation and Cloudflare D1 Query
import { z } from "zod";

const AvailabilityArgs = z.object({
  service_type: z.enum(["cleaning", "exam", "xray", "extraction"]),
  preferred_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD"),
  provider_id: z.string().optional(),
});

export async function checkAvailability(
  rawArgs: Record<string, unknown>,
  db: D1Database
): Promise<string> {
  const parsed = AvailabilityArgs.safeParse(rawArgs);
  if (!parsed.success) {
    const issues = parsed.error.issues.map((i) => i.message).join("; ");
    // Return a recoverable error string — the LLM re-asks the caller for correct info
    return `I need a bit more information to check that: ${issues}`;
  }

  const { service_type, preferred_date, provider_id } = parsed.data;

  const query = provider_id
    ? db
        .prepare(
          "SELECT time_slot, provider_name FROM appointments WHERE date = ? AND service_type = ? AND provider_id = ? AND status = 'available' ORDER BY time_slot LIMIT 5"
        )
        .bind(preferred_date, service_type, provider_id)
    : db
        .prepare(
          "SELECT time_slot, provider_name FROM appointments WHERE date = ? AND service_type = ? AND status = 'available' ORDER BY time_slot LIMIT 5"
        )
        .bind(preferred_date, service_type);

  const { results } = await query.all<{ time_slot: string; provider_name: string }>();

  if (!results.length) {
    return `No availability for a ${service_type} on ${preferred_date}. Would you like me to check the next available date?`;
  }

  // Collapse to a single-line string — no literal newlines allowed in result values
  const slots = results
    .map((s) => `${s.time_slot} with ${s.provider_name}`)
    .join(", ");
  return `Available slots on ${preferred_date}: ${slots}. Which works best for you?`;
}

Phrasing the result as a complete sentence that the assistant can relay directly to the caller — rather than returning raw JSON — reduces LLM processing time between receiving the tool result and speaking. A caller hears the answer roughly 200–400ms faster when the model doesn't need to interpret and reformat a data structure.

Code Tools: TypeScript Without a Server

For tools that call public APIs or perform lightweight calculations, Vapi's Code Tool removes the webhook entirely. You write a TypeScript function that runs on Vapi's infrastructure. Your parameters arrive as function arguments, and API keys are stored as environment variables in the tool config in the Vapi dashboard — not embedded in the code itself.

TypeScriptCode Tool: Runs on Vapi Infrastructure
// This TypeScript runs on Vapi's infrastructure — no server or webhook needed
// Set OPENWEATHER_KEY in the tool's Environment Variables config in the Vapi dashboard
export default async function handler(params: {
  zip_code: string;
  unit?: "fahrenheit" | "celsius";
}): Promise<string> {
  const { zip_code, unit = "fahrenheit" } = params;
  const units = unit === "fahrenheit" ? "imperial" : "metric";

  const res = await fetch(
    `https://api.openweathermap.org/data/2.5/weather?zip=${zip_code},us&units=${units}&appid=${process.env.OPENWEATHER_KEY}`
  );

  if (!res.ok) {
    return `Unable to retrieve weather for ZIP code ${zip_code} at this time.`;
  }

  const data = (await res.json()) as {
    main: { temp: number; feels_like: number };
    weather: [{ description: string }];
    name: string;
  };

  const temp = Math.round(data.main.temp);
  const feels = Math.round(data.main.feels_like);
  const desc = data.weather[0]?.description ?? "conditions unknown";
  const sym = unit === "fahrenheit" ? "F" : "C";

  // Single-line string only — no literal newlines in Code Tool return values
  return `Currently ${temp}°${sym} and ${desc} in ${data.name}, feeling like ${feels}°${sym}.`;
}

Code Tools cannot reach resources inside a private VPC and cannot maintain persistent connections. The execution environment is isolated per-call. If your tool needs to hit your own database, go through a VPN, or use credentials managed by your infrastructure rather than Vapi's dashboard, use a Custom webhook tool instead. Code Tools are the right choice for public API lookups, ZIP code data, currency conversion, and any integration where the only credential is an API key.

Static Parameters

Custom tools support static parameters — server-side values injected into every tool call that the LLM never sees and the caller cannot influence. This is how you enforce multi-tenancy and prevent prompt injection attacks where a caller tries to extract internal system identifiers by phrasing them as conversation topics.

If you're running a voice agent for multiple clients on a shared Workers deployment — for example, serving several independent businesses in the Bend, OR area from one codebase — static parameters let you scope every tool call to the correct client account without the LLM ever knowing account IDs exist. Define the static parameter in your tool config in the Vapi dashboard; it arrives alongside the LLM-supplied arguments in every webhook request. Never rely on the model to pass security-sensitive values like organization_id, tenant_id, or internal record IDs. Route those through static parameters where the caller cannot influence them.

Async Tool Calls

By default, Vapi waits for your tool response before the assistant continues speaking. For fast operations — a sub-200ms database lookup — this is barely perceptible. For slower operations like sending an email confirmation via Resend, writing a lead to Salesforce or HubSpot, or triggering a downstream workflow, the silence is awkward and callers notice it.

Vapi's async tool mode solves this. Enable it in your tool configuration, and your webhook returns an immediate acknowledgment: {"results": [{"toolCallId": "...", "result": "ack"}]}. The assistant continues the conversation immediately — "I've sent that to your email, it should arrive in just a moment" — while your background job completes. The full result is delivered to the ongoing call via a separate Vapi REST API call once your async work finishes. The caller hears a natural handoff rather than dead air.

Testing Locally

Vapi requires a publicly reachable HTTPS endpoint to send tool call webhooks. During development, ngrok is the fastest option: run ngrok http 8787 (or whatever port your local dev server uses via wrangler dev), copy the HTTPS forwarding URL, and paste it into the tool's server URL field in the Vapi dashboard.

Vapi also ships a vapi listen CLI command that proxies webhook events directly to your local server without requiring ngrok — useful for isolating tool call events during focused debugging sessions.

Log the full raw request body on every incoming call during development. The model's argument choices are often surprising, and seeing the actual payloads is the fastest path to catching tool description quality issues and schema mismatches before they reach production callers. A console.log(JSON.stringify(body, null, 2)) at the top of your handler pays for itself the first time the LLM passes an unexpected argument shape.

Production Gotchas

The 7.5-second hard wall. Vapi enforces a fixed response deadline measured from when the tool call fires to when your server responds. This is not configurable. Your database query, external API call, serialization, and network round-trip must all complete inside that window. Cold start on AWS Lambda can eat 1–3 seconds of your budget before a single line of your code runs. Cloudflare Workers has no cold start — use it. If you must stay on Lambda, provision concurrency.

HTTP 200, always. Any non-200 status code from your server is silently discarded by the Vapi pipeline. Your load balancer's 502, your auth middleware's 401, an unhandled exception that returns a 500 — none of these reach the assistant. The LLM receives no result and typically fills the conversational gap with a hallucinated answer. Wrap your entire handler in a top-level try/catch that always returns HTTP 200 with an error string for every failure mode.

toolCallId must match exactly. A single character difference between the id in the request and the toolCallId in your response causes a silent mismatch. The assistant receives no result. This is most common when developers reconstruct or transform the ID somewhere in the handler rather than passing the raw value through. Echo it directly from the request object without modification.

No literal newlines in result strings. Newline characters inside result values cause parsing errors in the Vapi response pipeline. Collapse multi-line results to comma-separated values or escape with \n. This catches developers returning text from templates, database text fields, or copying output from console.log() calls that added formatting characters.

Parallel tool calls need parallel results. When the LLM batches multiple tool calls, toolCallList has more than one entry. Your handler must return one results entry per toolCallId. Missing entries cause the assistant to stall indefinitely waiting for responses that never arrive. Always process all items in the list.

Description quality is load-bearing. If the assistant calls the wrong tool, calls a tool when it shouldn't, or fails to call one when it should, the root cause is almost always the description field — not the code. Test your tool definitions with real caller utterances using Vapi's built-in testing interface and inspect which tool the model selects. Poor tool descriptions waste more debugging time than any implementation bug.

When NOT to Build This Yourself

Custom function calling adds real complexity to your voice agent stack: a webhook server to deploy and maintain, a request contract to implement correctly, argument validation to keep in sync with your schema evolution, and a tight latency budget to stay under. Before writing the first line of webhook code, check these alternatives.

The built-in apiRequest default tool can call any HTTP endpoint directly from within Vapi, with configurable headers, body templating, and method selection — no server required. If your integration is a simple REST call to a third-party API that accepts a static auth token, apiRequest covers it without any custom infrastructure or maintenance overhead.

For CRM writes, calendar integrations, and notification workflows, check whether Zapier or Make has a native Vapi connector — they often do, and they handle auth token refresh, retry logic, and failure alerting out of the box. Building comparable reliability into a custom webhook handler from scratch is a multi-week project in edge case coverage.

For teams building voice agents for SMB clients in specific service verticals — dental practices running Dentrix or Eaglesoft, field service companies on ServiceTitan, or law firms using Clio — WildRun AI ships pre-built integrations that skip the webhook layer entirely. The edge cases in booking flows (timezone handling across Pacific and Mountain time, double-booking prevention, cancellation policy enforcement) take weeks to get right from scratch in each vertical. When the integration exists, buying it is almost always faster than building it.

Build custom function calling when you have proprietary data that cannot leave your infrastructure, when your business logic is complex enough that no off-the-shelf integration covers it, or when the tool layer is your product's actual competitive advantage.

What to Build Next

Function calling solves the action layer. The surrounding webhook infrastructure — signature verification, call lifecycle events, and server-side logging — is covered in the Vapi webhook architecture guide. For reducing latency after your tools are wired up, see AI voice agent latency optimization, which covers end-to-end timing from the first phoneme through tool round-trips and back to speech output.

If you're deploying this for a client and want help with the integration layer for a specific vertical, book a call with our team — we've wired Vapi function calling into dozens of SMB stacks across Central Oregon and the Pacific Northwest.

Architecture
Caller --> Phone / Web Channel
               |
         [Vapi Platform]
               |
         LLM Inference
         (tool call decision)
               |
               v POST /vapi/tools
               |  { toolCallList: [...] }
         [Cloudflare Worker - Hono]
               |
         dispatch() by fn.name
         Zod argument validation
         DB query / external API
               |
               v HTTP 200
               |  { results: [...] }
         LLM continues conversation

Frequently asked questions

What is Vapi function calling and what does it enable?

Vapi function calling lets your voice assistant invoke custom server-side tools during a live call — querying databases, booking appointments, or triggering workflows in real time. The assistant sends a POST to a webhook URL your server handles, executes the logic, and returns a result string to continue the conversation.

What format must my webhook server return for Vapi tool calls?

Your server must return HTTP 200 with JSON: {"results": [{"toolCallId": "...", "result": "your string"}]}. The toolCallId must exactly match the id from the incoming request. Both result and error values must be strings, not objects. Any non-200 status code is silently discarded by Vapi.

What is the response timeout for Vapi tool calls?

Vapi enforces a hard 7.5-second deadline from when the tool call fires to when your server must respond. This limit is fixed and not configurable. Your database query, external API call, serialization, and network round-trip must all complete within that window.

What is a Vapi Code Tool and how does it differ from a Custom Tool?

A Code Tool is TypeScript that runs directly on Vapi's infrastructure without any webhook server. It receives the tool parameters as function arguments and must return a string. The default timeout is 10 seconds with a 60-second maximum. Unlike Custom Tools, Code Tools cannot reach private VPC resources and are best for public API lookups and lightweight logic.

How do I handle batched tool calls in a single Vapi webhook request?

Vapi can batch multiple tool calls in one request inside the toolCallList array. Your handler must return a results array entry for every toolCallId. Use Promise.all to process them concurrently before responding. Missing entries cause the assistant to stall waiting for responses that never arrive.

What are Vapi static parameters and when should I use them?

Static parameters are server-side values injected into every tool call that the LLM never sees and callers cannot influence. Use them to pass tenant IDs, account IDs, or auth tokens that scope tool execution to the correct context. Never rely on the LLM to supply security-sensitive identifiers — route those through static parameters instead.

Ready to stop losing calls?

Free 30-minute consult. We build a live mockup of your agent on the call — no slides.

Book Your Free Demo