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:
- Book or reschedule appointments in Calendly, Acuity, or your own scheduling system
- Look up customer records in Salesforce, HubSpot, or a custom CRM
- Check order status or inventory levels from Shopify, WooCommerce, or an internal database
- Create support tickets in Zendesk, Freshdesk, or Jira
- Process payments or look up invoices
How it works
server_url
→
Your server responds with JSON
→
LLM continues conversation
There are two distinct webhook mechanisms:
| Mechanism | Purpose | Blocking? |
|---|---|---|
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.
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": []
}
}
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"
}
"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}`));
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
- EWT serializes the payload to a JSON string
- Computes
HMAC-SHA256(json_string, webhook_secret) - Sends two headers with the request:
X-Webhook-Signature— the hex-encoded HMAC digestX-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
});
JSON.stringify(req.body) on the already-parsed body.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:
| Setting | Default | Description |
|---|---|---|
tool_timeout_ms | 10000 (10 seconds) | Maximum time EWT waits for your server to respond before timing out the tool call. |
tool_max_retries | 2 | Number of retry attempts after an initial failure. Total attempts = 1 + tool_max_retries (so 3 total by default). |
What happens on timeout or failure
- If the initial call fails or times out, EWT retries with exponential backoff (500ms after first failure, 1000ms after second).
- After all retries are exhausted, the LLM receives an error message:
Tool "tool_name" failed after N attempts: Tool call timed out - The LLM then handles the failure gracefully — typically apologizing to the caller and offering alternatives.
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
- Add a filler message to your system prompt. Instruct the agent to say something like "Let me look that up for you, one moment" before invoking the tool.
- Cache frequently-requested data. If every caller asks about the same products, cache inventory data locally so your webhook responds in under a second.
- Use async processing for writes. If you are creating a record (like booking an appointment), acknowledge immediately and process in the background.
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 Type | When It Fires | Key Payload Fields |
|---|---|---|
call-started | Call begins, after agent is loaded | call.id, call.from, call.to, call.direction |
speech-update | User or assistant speaks | role, text |
function-call | Agent invokes a custom tool BLOCKING | function_call.name, function_call.parameters |
tool-calls | After a tool call completes | tool_call.name, tool_call.result |
status-update | Call status changes (e.g., transferring) | status, destination, mode |
end-of-call-report | Call ends | call.id, call.duration, transcript, recording_url |
test | Manually triggered via "Test Webhook" button | agent.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
- Check the webhook events log. EWT logs every webhook delivery to the
webhook_eventstable. Use the admin API or dashboard to inspect failed deliveries. - Return descriptive errors. When your tool handler encounters an error, return a meaningful message in the JSON response.
- Watch ngrok's inspector. Open
http://127.0.0.1:4040to see all forwarded requests. - Test with a real call. Use
POST /api/agents/:id/test-callto place an actual outbound call to your phone.
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}.` : ''),
};
}
message field so the AI can relay helpful information to the caller.