Custom Tools & Webhook Integration

Connect your voice agent to external APIs, CRMs, booking systems, and databases using custom tools and webhook events.

1. Overview

Custom tools let your voice agent interact with the outside world during a live phone call. When the AI determines it needs external data (for example, checking a calendar or looking up a customer record), it invokes a custom tool. EWT sends an HTTP POST to your server, waits for the response, and feeds the result back to the LLM so it can continue the conversation naturally.

Common use cases:

How it works

Caller speaks LLM decides to call tool EWT POSTs to your server_url Your server responds with JSON LLM continues conversation

There are two distinct webhook mechanisms:

MechanismPurposeBlocking?
Function calls (type: function-call)Agent needs data to continue the call. Your server must return a JSON result.Yes — the call waits for your response
Webhook events (type: call-started, end-of-call-report, etc.)Notifications about call lifecycle. Fire-and-forget with automatic retries.No — does not block the call

Both are delivered to the same server_url as HTTP POST requests.

2. Defining a Custom Tool

Custom tools are stored as a JSON array in the agent's tools field. Each tool requires a name, a description, and an input_schema (or parameters as an alias). The schema follows the standard JSON Schema format and tells the LLM what arguments to collect from the caller before invoking the tool.

Write detailed, natural-language descriptions. The LLM uses the description to decide when to call the tool and the property descriptions to understand what to ask the caller. More detail leads to better results.

Tool schema structure

{
  "name": "tool_name",
  "description": "When and why the agent should use this tool",
  "input_schema": {
    "type": "object",
    "properties": {
      "param_name": {
        "type": "string",
        "description": "What this parameter represents"
      }
    },
    "required": ["param_name"]
  }
}

Custom tools are merged with EWT's built-in tools (like transferCall, endCall, switchLanguage, and setDisposition) at the start of each call. Any tool name that does not match a built-in is routed to your server_url automatically.

Example 1: Book an appointment

{
  "name": "book_appointment",
  "description": "Book an appointment for the caller. Use this when the caller wants to schedule a visit, consultation, or meeting. Collect the preferred date, time, and their name before calling this tool.",
  "input_schema": {
    "type": "object",
    "properties": {
      "date": { "type": "string", "description": "Preferred date in YYYY-MM-DD format" },
      "time": { "type": "string", "description": "Preferred time in HH:MM format (24-hour)" },
      "contact_name": { "type": "string", "description": "Full name of the person booking the appointment" }
    },
    "required": ["date", "time", "contact_name"]
  }
}

Example 2: Look up a customer

{
  "name": "lookup_customer",
  "description": "Look up customer information by phone number or email address. Use this when you need to verify a caller's identity or retrieve their account details.",
  "input_schema": {
    "type": "object",
    "properties": {
      "phone_number": { "type": "string", "description": "Customer phone number (E.164 format preferred)" },
      "email": { "type": "string", "description": "Customer email address" }
    },
    "required": []
  }
}
When no properties are marked required, the LLM will use whichever information the caller provides. This is useful for flexible lookups where either a phone number or email is sufficient.

Example 3: Check inventory

{
  "name": "check_inventory",
  "description": "Check product availability and stock levels. Use this when a caller asks if a product is in stock or how many units are available.",
  "input_schema": {
    "type": "object",
    "properties": {
      "product_id": { "type": "string", "description": "Product SKU or ID" },
      "quantity": { "type": "integer", "description": "Quantity the caller needs (defaults to 1 if not specified)" }
    },
    "required": ["product_id"]
  }
}

3. Setting Up Your Webhook Server

Your webhook server receives HTTP POST requests from EWT whenever a tool is called or a webhook event fires. The server_url field on the agent configuration tells EWT where to send these requests. All event types and function calls go to the same URL.

Incoming payload format

For function calls (tool invocations), the payload looks like this:

{
  "type": "function-call",
  "call_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "agent_id": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
  "tenant_id": "00000000-0000-0000-0000-000000000001",
  "timestamp": "2026-02-22T14:30:00.000Z",
  "function_call": {
    "name": "book_appointment",
    "parameters": {
      "date": "2026-03-01",
      "time": "14:00",
      "contact_name": "Jane Smith"
    }
  }
}

Expected response

Your server must respond with a JSON object. This object is passed directly to the LLM as the tool result, so include whatever information the agent needs to continue the conversation.

// Success response
{
  "success": true,
  "appointment_id": "APT-20260301-001",
  "confirmed_date": "2026-03-01",
  "confirmed_time": "2:00 PM",
  "message": "Appointment booked successfully with Dr. Johnson"
}

// Error response
{
  "success": false,
  "error": "No availability on that date",
  "next_available": "2026-03-03"
}
Return natural-language-friendly data. The LLM will read your response and relay it to the caller, so fields like "message" and human-readable dates help produce better conversational output.

Complete Express.js webhook handler

Here is a complete, runnable webhook server that handles custom tool calls:

const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.json());

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'your-secret-here';

// Verify HMAC-SHA256 signature
function verifySignature(req) {
  const signature = req.headers['x-webhook-signature'];
  if (!signature || !WEBHOOK_SECRET) return false;

  const body = JSON.stringify(req.body);
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(body)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex')
  );
}

// Main webhook endpoint
app.post('/webhook', async (req, res) => {
  if (WEBHOOK_SECRET && WEBHOOK_SECRET !== 'your-secret-here') {
    if (!verifySignature(req)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
  }

  const { type, call_id, agent_id, function_call } = req.body;
  console.log(`Received event: ${type} for call ${call_id}`);

  if (type === 'function-call' && function_call) {
    const { name, parameters } = function_call;
    try {
      let result;
      switch (name) {
        case 'book_appointment':
          result = await handleBookAppointment(parameters);
          break;
        case 'lookup_customer':
          result = await handleLookupCustomer(parameters);
          break;
        default:
          result = { error: `Unknown tool: ${name}` };
      }
      return res.json(result);
    } catch (err) {
      console.error(`Tool ${name} failed:`, err);
      return res.json({ error: `Tool execution failed: ${err.message}` });
    }
  }

  if (type === 'end-of-call-report') {
    console.log('Call ended:', req.body.call);
  }

  res.json({ ok: true });
});

async function handleBookAppointment({ date, time, contact_name }) {
  return {
    success: true,
    appointment_id: `APT-${Date.now()}`,
    confirmed_date: date,
    confirmed_time: time,
    contact_name,
    message: `Appointment booked for ${contact_name} on ${date} at ${time}`,
  };
}

async function handleLookupCustomer({ phone_number, email }) {
  return {
    found: true,
    customer: {
      name: 'Jane Smith',
      email: email || 'jane@example.com',
      phone: phone_number || '+15551234567',
      account_status: 'active',
    },
  };
}

const PORT = process.env.PORT || 3001;
app.listen(PORT, () => console.log(`Webhook server on port ${PORT}`));
For function calls (type: "function-call"), EWT waits for your response before continuing the conversation. If your server does not respond (or returns a non-200 status), the tool call is treated as failed and the LLM receives an error message. Keep your handler fast — ideally under 5 seconds.

4. Webhook Security

When you set a webhook_secret on your agent, EWT signs every outgoing request with an HMAC-SHA256 signature. This lets you verify that the request genuinely came from EWT and was not tampered with in transit.

How it works

  1. EWT serializes the payload to a JSON string
  2. Computes HMAC-SHA256(json_string, webhook_secret)
  3. Sends two headers with the request:
    • X-Webhook-Signature — the hex-encoded HMAC digest
    • X-Webhook-Timestamp — the Unix timestamp in milliseconds when the request was signed

Verification code (Node.js)

const crypto = require('crypto');

function verifyWebhookSignature(req, secret) {
  const signature = req.headers['x-webhook-signature'];
  const timestamp = req.headers['x-webhook-timestamp'];

  if (!signature) return false;

  if (timestamp) {
    const age = Date.now() - parseInt(timestamp, 10);
    if (age > 5 * 60 * 1000) return false;
  }

  const body = JSON.stringify(req.body);
  const expected = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature, 'hex'),
      Buffer.from(expected, 'hex')
    );
  } catch {
    return false;
  }
}

function requireSignature(secret) {
  return (req, res, next) => {
    if (!verifyWebhookSignature(req, secret)) {
      return res.status(401).json({ error: 'Invalid webhook signature' });
    }
    next();
  };
}

app.post('/webhook', requireSignature('your-secret-here'), (req, res) => {
  // Signature is valid, process the event
});
The signature is computed over the JSON-serialized string of the payload. If your web framework parses and re-serializes JSON (which can change key order or whitespace), the signature check will fail. Use JSON.stringify(req.body) on the already-parsed body.
Generate a strong secret using openssl rand -hex 32 and store it as an environment variable. Never commit secrets to version control.

5. Tool Timeout & Retries

Custom tool calls are time-sensitive — the caller is waiting on the line while your server processes the request. EWT provides two agent-level settings to control this behavior:

SettingDefaultDescription
tool_timeout_ms10000 (10 seconds)Maximum time EWT waits for your server to respond before timing out the tool call.
tool_max_retries2Number of retry attempts after an initial failure. Total attempts = 1 + tool_max_retries (so 3 total by default).

What happens on timeout or failure

  1. If the initial call fails or times out, EWT retries with exponential backoff (500ms after first failure, 1000ms after second).
  2. After all retries are exhausted, the LLM receives an error message: Tool "tool_name" failed after N attempts: Tool call timed out
  3. The LLM then handles the failure gracefully — typically apologizing to the caller and offering alternatives.
If your API is slow (e.g., a complex database query), increase tool_timeout_ms to avoid premature timeouts. You can set it as high as needed, but keep in mind the caller is waiting in silence. Values above 15 seconds may feel awkward in a phone conversation.

Best practices for slow APIs

Webhook events (non-function-call types like call-started) have a separate, independent retry mechanism: 3 attempts with exponential backoff (1s, 2s, 4s). These retries do not use the tool_timeout_ms or tool_max_retries settings.

6. Webhook Events

In addition to function calls, EWT sends lifecycle events to your server_url as fire-and-forget notifications. These are useful for logging, analytics, CRM updates, and triggering downstream workflows.

Event types

Event TypeWhen It FiresKey Payload Fields
call-startedCall begins, after agent is loadedcall.id, call.from, call.to, call.direction
speech-updateUser or assistant speaksrole, text
function-callAgent invokes a custom tool BLOCKINGfunction_call.name, function_call.parameters
tool-callsAfter a tool call completestool_call.name, tool_call.result
status-updateCall status changes (e.g., transferring)status, destination, mode
end-of-call-reportCall endscall.id, call.duration, transcript, recording_url
testManually triggered via "Test Webhook" buttonagent.id, agent.name, message

Filtering events

By default, all event types are delivered to your server_url. If you only care about specific events, set the webhook_events array on your agent configuration:

{
  "webhook_events": ["call-started", "end-of-call-report", "function-call"]
}

When webhook_events is set, only listed event types are delivered. Events not in the list are silently skipped. An empty array or null means "send everything."

Example: end-of-call-report payload

{
  "type": "end-of-call-report",
  "call_id": "a1b2c3d4-...",
  "agent_id": "f9e8d7c6-...",
  "timestamp": "2026-02-22T14:35:00.000Z",
  "call": {
    "id": "a1b2c3d4-...",
    "direction": "inbound",
    "from": "+15551234567",
    "to": "+15559876543",
    "duration_seconds": 187,
    "status": "completed",
    "end_reason": "caller_ended"
  },
  "transcript": [
    { "role": "assistant", "text": "Thank you for calling! How can I help?" },
    { "role": "user", "text": "I'd like to book an appointment." }
  ],
  "tool_calls": [
    { "name": "book_appointment", "input": { "date": "2026-03-01" }, "result": { "success": true } }
  ],
  "recording_url": "https://api.twilio.com/.../Recording.mp3"
}

7. Testing Your Integration

Step 1: Expose your local server with ngrok

During development, use ngrok to expose your local webhook server to the internet:

# Start your webhook server
node webhook-server.js

# In another terminal, expose it with ngrok
ngrok http 3001

Copy the ngrok HTTPS URL (e.g., https://a1b2c3d4.ngrok-free.app/webhook) and set it as your agent's server_url.

Step 2: Send a test webhook from the dashboard

Use the "Test Webhook" feature in the agent settings, or call the API directly:

curl -X POST https://your-ewt-instance.com/api/agents/AGENT_ID/test-webhook \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json"

Step 3: Simulate a function call with curl

curl -X POST http://localhost:3001/webhook \
  -H "Content-Type: application/json" \
  -d '{
    "type": "function-call",
    "call_id": "test-call-001",
    "agent_id": "test-agent-001",
    "function_call": {
      "name": "book_appointment",
      "parameters": {
        "date": "2026-03-01",
        "time": "14:00",
        "contact_name": "Test User"
      }
    }
  }'

Step 4: Simulate with a signed request

# Set your secret and payload
SECRET="your-webhook-secret"
PAYLOAD='{"type":"function-call","call_id":"test-001","function_call":{"name":"lookup_customer","parameters":{"phone_number":"+15551234567"}}}'

# Generate HMAC-SHA256 signature
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

# Send the signed request
curl -X POST http://localhost:3001/webhook \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: $SIGNATURE" \
  -H "X-Webhook-Timestamp: $(date +%s)000" \
  -d "$PAYLOAD"

Debugging tips

8. Real-World Examples

A. Appointment Booking (Calendly-style)

This example connects your voice agent to a booking system. The agent collects the caller's name, preferred date, time, and service type, then books the appointment in real time.

Tool definition

{
  "name": "book_appointment",
  "description": "Book an appointment for the caller. Collect their full name, preferred date, preferred time, and the service they need before calling this tool.",
  "input_schema": {
    "type": "object",
    "properties": {
      "contact_name": { "type": "string", "description": "Caller's full name" },
      "phone": { "type": "string", "description": "Caller's phone number for confirmation" },
      "date": { "type": "string", "description": "Preferred appointment date (YYYY-MM-DD)" },
      "time": { "type": "string", "description": "Preferred time (HH:MM, 24-hour format)" },
      "service": { "type": "string", "description": "Type of service or appointment" }
    },
    "required": ["contact_name", "date", "time", "service"]
  }
}

Webhook handler

const axios = require('axios');

async function handleBookAppointment({ contact_name, phone, date, time, service }) {
  try {
    const response = await axios.post('https://api.yourbooking.com/appointments', {
      name: contact_name, phone, datetime: `${date}T${time}:00`, service_type: service,
    }, {
      headers: { 'Authorization': `Bearer ${process.env.BOOKING_API_KEY}` },
      timeout: 8000,
    });

    const appt = response.data;
    return {
      success: true,
      appointment_id: appt.id,
      confirmed_date: appt.date,
      confirmed_time: appt.time_display,
      provider: appt.provider_name,
      message: `Appointment confirmed for ${contact_name} on ${appt.date} at ${appt.time_display}`,
    };
  } catch (err) {
    if (err.response && err.response.status === 409) {
      const nextSlots = err.response.data.next_available || [];
      return {
        success: false,
        error: 'That time slot is not available.',
        next_available: nextSlots.slice(0, 3).map(s => ({ date: s.date, time: s.time_display })),
      };
    }
    return { success: false, error: 'Booking system temporarily unavailable.' };
  }
}

B. CRM Lookup (Salesforce-style)

Look up a customer's record by phone number to personalize the conversation, check open cases, or pull up order history.

Tool definition

{
  "name": "crm_lookup",
  "description": "Search the CRM for a customer record using their phone number. Use this at the beginning of a call to identify the caller and personalize the conversation.",
  "input_schema": {
    "type": "object",
    "properties": {
      "phone_number": { "type": "string", "description": "Customer phone number to search for" }
    },
    "required": ["phone_number"]
  }
}

Webhook handler

const jsforce = require('jsforce');
const sfConn = new jsforce.Connection({
  loginUrl: process.env.SF_LOGIN_URL || 'https://login.salesforce.com',
});

let sfLoggedIn = false;
async function ensureSfLogin() {
  if (!sfLoggedIn) {
    await sfConn.login(process.env.SF_USERNAME, process.env.SF_PASSWORD + process.env.SF_TOKEN);
    sfLoggedIn = true;
  }
}

async function handleCrmLookup({ phone_number }) {
  await ensureSfLogin();
  const cleanPhone = phone_number.replace(/\D/g, '').slice(-10);

  const contacts = await sfConn.query(`
    SELECT Id, Name, Email, Account.Name,
           (SELECT Id, Subject, Status FROM Cases WHERE Status != 'Closed' LIMIT 3),
           (SELECT Id, OrderNumber, TotalAmount FROM Orders ORDER BY EffectiveDate DESC LIMIT 5)
    FROM Contact
    WHERE Phone LIKE '%${cleanPhone}' OR MobilePhone LIKE '%${cleanPhone}'
    LIMIT 1
  `);

  if (contacts.totalSize === 0) {
    return { found: false, message: 'No customer record found for this phone number.' };
  }

  const contact = contacts.records[0];
  const openCases = (contact.Cases && contact.Cases.records) || [];
  return {
    found: true,
    customer: { name: contact.Name, email: contact.Email, company: contact.Account ? contact.Account.Name : null },
    open_cases: openCases.map(c => ({ subject: c.Subject, status: c.Status })),
    message: `Found customer ${contact.Name}` + (openCases.length > 0 ? ` with ${openCases.length} open case(s)` : ''),
  };
}

C. Order Status Check (Shopify-style)

Let callers check the status of an existing order by providing their order number or email address.

Tool definition

{
  "name": "check_order_status",
  "description": "Look up the status of a customer's order. Ask for their order number or email. Returns tracking info and delivery date.",
  "input_schema": {
    "type": "object",
    "properties": {
      "order_number": { "type": "string", "description": "The order number (e.g., '1001' or '#1001')" },
      "email": { "type": "string", "description": "Email address used for the order" }
    },
    "required": []
  }
}

Webhook handler

const Shopify = require('shopify-api-node');
const shopify = new Shopify({
  shopName: process.env.SHOPIFY_SHOP_NAME,
  apiKey: process.env.SHOPIFY_API_KEY,
  password: process.env.SHOPIFY_API_PASSWORD,
});

async function handleCheckOrderStatus({ order_number, email }) {
  let orders = [];
  if (order_number) {
    const num = order_number.replace('#', '');
    orders = await shopify.order.list({ name: num, status: 'any', limit: 1 });
  } else if (email) {
    orders = await shopify.order.list({ email, status: 'any', limit: 5 });
  }

  if (orders.length === 0) {
    return { found: false, message: order_number ? `No order found with number ${order_number}.` : `No orders found for ${email}.` };
  }

  const order = orders[0];
  const fulfillments = order.fulfillments || [];
  const tracking = fulfillments.length > 0 ? fulfillments[0] : null;

  return {
    found: true,
    order: { number: order.name, status: order.fulfillment_status || 'unfulfilled', total: order.total_price },
    tracking: tracking ? { company: tracking.tracking_company, number: tracking.tracking_number, url: tracking.tracking_url } : null,
    message: `Order #${order.name} is ${order.fulfillment_status || 'being processed'}.` + (tracking ? ` Tracking: ${tracking.tracking_company} ${tracking.tracking_number}.` : ''),
  };
}
For all real-world integrations, add error handling for API rate limits, expired tokens, and network timeouts. Return user-friendly error messages in the message field so the AI can relay helpful information to the caller.