> ## Documentation Index
> Fetch the complete documentation index at: https://voice.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time event notifications, personalize inbound calls, and expose callable function tools for agent actions.

Webhooks let your application receive signed inbound HTTP requests from Voice.ai and expose outbound callable endpoints for agent actions.

<Info>
  **Prerequisites**: [API key](/docs/guides/authentication) and an agent configured with webhook settings.
</Info>

## Overview

Voice.ai supports **three webhook types**. `webhooks.events[]`, `webhooks.inbound_call`, and `webhooks.tools` use different contracts:

* `webhooks.events[]` supports `secret` (write-only on create/update) and `has_secret` (read-only on fetch), with fan-out across enabled endpoints.
* `webhooks.inbound_call` supports `secret` (write-only on create/update) and `has_secret` (read-only on fetch).
* `webhooks.tools` define outbound API calls and do not use `secret`.

See [Agent Configuration](/docs/guides/voice-agents/configuration), the [Web SDK guide](/docs/guides/voice-agents/web#webhooks), and the [API reference](/docs/api-reference) for the same public contract on agent config and call-start requests.

| Type                     | Direction                         | Purpose                                             | Auth                                                              |
| ------------------------ | --------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------- |
| **Event Webhooks**       | Inbound: Voice.ai → your URL      | Notify your system about call lifecycle events      | Use `secret` for HMAC signature verification                      |
| **Inbound Call Webhook** | Inbound: Voice.ai → your URL      | Personalize inbound calls with `dynamic_variables`  | Use `secret` for HMAC signature verification                      |
| **Webhook Tools**        | Outbound: Voice.ai calls your API | Let the agent invoke your application during a call | Use `auth_type`/`auth_token`/`headers` for request authentication |

| Event            | Description                                        |
| ---------------- | -------------------------------------------------- |
| `call.started`   | Call has connected and the agent is ready          |
| `call.completed` | Call has ended, includes transcript and usage data |
| `call.failed`    | Call initiation was blocked before `call.started`  |

When configured, your server receives an HTTP POST request for each event with a JSON payload containing event details.

### Example routes

The runnable receiver example and the verification snippets below use these canonical routes:

| Config field                | Example URL                                             | Method | Purpose                                                                                       |
| --------------------------- | ------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------- |
| `webhooks.events[].url`     | `https://your-server.com/webhooks/voice-events`         | `POST` | Receive signed `call.started`, `call.completed`, and `call.failed` events                     |
| `webhooks.inbound_call.url` | `https://your-server.com/webhooks/inbound-call`         | `POST` | Return `dynamic_variables` and optional `agent_overrides` before an inbound phone call starts |
| `webhooks.tools[0].url`     | `https://your-server.com/webhooks/tools/account-status` | `POST` | Return account status data for a sync tool                                                    |
| `webhooks.tools[1].url`     | `https://your-server.com/webhooks/tools/search-kb`      | `GET`  | Accept search parameters for a tool request                                                   |

## Event Webhooks

### Configuration

Add event webhook configuration to your agent's `config.webhooks.events` array:

```json theme={null}
{
  "name": "My Agent",
  "config": {
    "prompt": "You are a helpful assistant.",
    "webhooks": {
      "events": [
        {
          "url": "https://your-server.com/webhooks/voice-events",
          "secret": "your-signing-secret",
          "events": ["call.started", "call.completed", "call.failed"],
          "timeout": 5,
          "enabled": true
        }
      ]
    }
  }
}
```

### Event configuration parameters

Each entry in `webhooks.events[]` supports required/optional fields: required `url`; optional `secret`, `events`, `timeout` (default `5`), `enabled` (default `true`). Omit optional fields to use defaults.

| Parameter    | Type    | Required | Default | Description                                                 |
| ------------ | ------- | -------- | ------- | ----------------------------------------------------------- |
| `url`        | string  | Yes      | -       | Your webhook endpoint URL (must be HTTPS in production)     |
| `secret`     | string  | No       | `null`  | HMAC-SHA256 signing secret for payload verification         |
| `has_secret` | boolean | No       | `false` | Whether a signing secret is configured (read-only on fetch) |
| `events`     | array   | No       | `[]`    | Event types to receive. Empty array = all events            |
| `timeout`    | number  | No       | `5`     | Request timeout in seconds (1-30)                           |
| `enabled`    | boolean | No       | `true`  | Whether webhook notifications are active                    |

On update, omit `webhooks.events` to preserve the current list, set `webhooks.events: null` to clear it, or pass a full array to replace it. Within a replacement array, omitted `secret` values are preserved only for entries whose `url` exactly matches an existing endpoint, `secret: null` clears that endpoint's signing secret, duplicate URLs are invalid, and `events: []` means that endpoint receives all event types.

## Event Payloads

All webhook events share a common structure with top-level fields and event-specific data:

| Field       | Type           | Description                                                  |
| ----------- | -------------- | ------------------------------------------------------------ |
| `event`     | string         | Event type (`call.started`, `call.completed`, `call.failed`) |
| `timestamp` | string         | ISO 8601 timestamp of when the event occurred                |
| `call_id`   | string or null | Unique identifier for the call, when available               |
| `agent_id`  | string         | Agent handling the call                                      |
| `data`      | object         | Event-specific additional data                               |

### call.started

Sent when a call connects and the agent is ready to interact.

```json theme={null}
{
  "event": "call.started",
  "timestamp": "2025-02-03T14:30:00.000Z",
  "call_id": "call_abc123",
  "agent_id": "agent_456",
  "data": {
    "call_type": "sip_inbound",
    "started_at": "2025-02-03T14:30:00.000Z",
    "from_number": "+14155551234",
    "to_number": "+14155559876"
  }
}
```

**`data` fields:**

| Field         | Type   | Description                                                                      |
| ------------- | ------ | -------------------------------------------------------------------------------- |
| `call_type`   | string | `"web"`, `"sip_inbound"`, `"sip_outbound"`, `"pbx_inbound"`, or `"pbx_outbound"` |
| `started_at`  | string | ISO 8601 timestamp                                                               |
| `from_number` | string | Caller's phone number (SIP calls only)                                           |
| `to_number`   | string | Called phone number (SIP calls only)                                             |

<Note>
  `from_number` and `to_number` are only included for SIP (phone) calls. Web calls do not have these fields.
</Note>

### call.completed

Sent after the call ends and all data has been processed. Includes transcript and usage information.

```json theme={null}
{
  "event": "call.completed",
  "timestamp": "2025-02-03T14:35:00.000Z",
  "call_id": "call_abc123",
  "agent_id": "agent_456",
  "data": {
    "call_type": "sip_inbound",
    "duration_seconds": 300.5,
    "credits_used": 15.25,
    "transcript_uri": "https://storage.example.com/transcripts/call_abc123.json",
    "transcript_summary": "Customer inquired about pricing for the enterprise plan...",
    "from_number": "+14155551234",
    "to_number": "+14155559876"
  }
}
```

**`data` fields:**

| Field                | Type   | Description                                                                      |
| -------------------- | ------ | -------------------------------------------------------------------------------- |
| `call_type`          | string | `"web"`, `"sip_inbound"`, `"sip_outbound"`, `"pbx_inbound"`, or `"pbx_outbound"` |
| `duration_seconds`   | number | Call duration in seconds                                                         |
| `credits_used`       | number | Credits consumed by this call                                                    |
| `transcript_uri`     | string | URL to full transcript JSON                                                      |
| `transcript_summary` | string | AI-generated summary of the call                                                 |
| `from_number`        | string | Caller's phone number (SIP calls only)                                           |
| `to_number`          | string | Called phone number (SIP calls only)                                             |

### call.failed

Sent when a phone call initiation attempt is blocked during validation before `call.started`. No `call.started` or `call.completed` event is sent for the failed attempt.

```json theme={null}
{
  "event": "call.failed",
  "timestamp": "2025-02-03T14:29:58.000Z",
  "call_id": "call_def456",
  "agent_id": "agent_456",
  "data": {
    "status": "failed",
    "failure_stage": "validation",
    "reason": "insufficient_credits",
    "call_type": "sip_inbound",
    "from_number": "+14155551234",
    "to_number": "+14155559876",
    "details": {
      "allowed": false,
      "reason": "insufficient_credits",
      "required_credits": 15,
      "available_credits": 0
    }
  }
}
```

**`data` fields:**

| Field           | Type   | Description                                                                                                          |
| --------------- | ------ | -------------------------------------------------------------------------------------------------------------------- |
| `status`        | string | Always `"failed"`                                                                                                    |
| `failure_stage` | string | `"validation"` for initiation validation blocks                                                                      |
| `reason`        | string | Public validation reason such as `insufficient_credits`, `subscription_inactive`, or `max_concurrent_calls_exceeded` |
| `call_type`     | string | Phone initiation path, such as `"sip_inbound"` or `"pbx_inbound"`                                                    |
| `details`       | object | Sanitized validation details for the integrator                                                                      |
| `from_number`   | string | Caller's phone number, when available                                                                                |
| `to_number`     | string | Called phone number, when available                                                                                  |

<Note>
  `call.failed` is for owner/integrator notification. Phone callers do not receive a custom spoken failure reason in this flow.
</Note>

## Inbound Call Webhook

`webhooks.inbound_call` runs during inbound phone call initiation before the normal agent session starts. Use it to personalize a call with [`dynamic_variables`](/docs/guides/voice-agents/configuration#dynamic-variables) and optional `agent_overrides`. `dynamic_variables` can be referenced by both the runtime `prompt` and `greeting`. Do not use it to route a call to a different agent. Call validation can still block the call after this webhook runs; subscribe to `call.failed` to receive owner/integrator notification for those blocked attempts.

### Configuration

Add inbound call webhook configuration to your agent's `config.webhooks.inbound_call` object:

```json theme={null}
{
  "name": "My Agent",
  "config": {
    "prompt": "You are a helpful assistant.",
    "webhooks": {
      "inbound_call": {
        "url": "https://your-server.com/webhooks/inbound-call",
        "secret": "your-signing-secret",
        "timeout": 5,
        "enabled": true
      }
    }
  }
}
```

### Inbound call configuration parameters

| Parameter    | Type    | Required | Default | Description                                                 |
| ------------ | ------- | -------- | ------- | ----------------------------------------------------------- |
| `url`        | string  | Yes      | -       | Your inbound call webhook endpoint URL                      |
| `secret`     | string  | No       | `null`  | HMAC-SHA256 signing secret for payload verification         |
| `has_secret` | boolean | No       | `false` | Whether a signing secret is configured (read-only on fetch) |
| `timeout`    | number  | No       | `5`     | Request timeout in seconds (1-30)                           |
| `enabled`    | boolean | No       | `true`  | Whether inbound call personalization is active              |

On update, omit `webhooks.inbound_call` to preserve it, set `webhooks.inbound_call: null` to remove it, and set `secret: null` to clear only the signing secret.

### Request payload

Voice.ai sends a POST request with the inbound call context:

```json theme={null}
{
  "agent_id": "agent_456",
  "call_id": "call_abc123",
  "from_number": "+14155551234",
  "to_number": "+14155559876"
}
```

| Field         | Type   | Description                      |
| ------------- | ------ | -------------------------------- |
| `agent_id`    | string | Agent receiving the inbound call |
| `call_id`     | string | Unique identifier for the call   |
| `from_number` | string | Caller's phone number            |
| `to_number`   | string | Number the caller dialed         |

### Response payload

Your endpoint can return `dynamic_variables` and optional `agent_overrides`:

```json theme={null}
{
  "dynamic_variables": {
    "customer_name": "Alice",
    "vip": true
  },
  "agent_overrides": {
    "tts_params": {
      "voice_id": "voice_vip_alice",
      "dictionary_id": "dict-123",
      "dictionary_version": 2
    }
  }
}
```

| Field               | Type   | Description                                                     |
| ------------------- | ------ | --------------------------------------------------------------- |
| `dynamic_variables` | object | Optional flat object of `string`, `number`, or `boolean` values |
| `agent_overrides`   | object | Optional runtime config overrides                               |

Omitted fields are allowed. Unused `dynamic_variables` are ignored by the runtime. Use `agent_overrides` for allowlisted call-scoped config changes such as `tts_params` overrides. `dynamic_variables` can be used in both your saved agent `prompt` and `greeting`. See the [Web SDK guide](/docs/guides/voice-agents/web#runtime-agent-overrides) for browser-side examples.

## Webhook Tools

Tools are **outbound** API calls: Voice.ai calls your endpoint. Use `auth_type`/`auth_token`/`headers` (not `secret` - that is only for signed inbound webhooks like `events` and `inbound_call`).

Use `config.webhooks.tools` to declare callable functions:

```json theme={null}
{
  "name": "My Agent",
  "config": {
    "prompt": "You are a helpful assistant.",
    "webhooks": {
      "events": [
        {
          "url": "https://your-server.com/webhooks/voice-events",
          "secret": "your-signing-secret",
          "events": ["call.started", "call.completed", "call.failed"]
        }
      ],
      "tools": [
        {
          "name": "get_account_status",
          "description": "Fetches the latest account status for a customer.",
          "url": "https://your-server.com/webhooks/tools/account-status",
          "parameters": { "customer_id": "string" },
          "method": "POST",
          "execution_mode": "sync",
          "auth_type": "api_key",
          "auth_token": "your-api-key",
          "headers": { "X-Service-Version": "2026-02" },
          "response": {
            "type": "object",
            "properties": {
              "status": { "type": "string" },
              "tier": { "type": "string" }
            }
          },
          "timeout": 10
        },
        {
          "name": "search_knowledge_base",
          "description": "Searches the internal KB and returns ranked snippets.",
          "url": "https://your-server.com/webhooks/tools/search-kb",
          "parameters": {
            "query": "string",
            "top_k": "number"
          },
          "method": "GET",
          "execution_mode": "async",
          "auth_type": "custom_headers",
          "headers": {
            "X-Internal-Token": "your-internal-token"
          },
          "timeout": 10
        }
      ]
    }
  }
}
```

### Tool configuration parameters

`tools[]` supports required/optional fields per tool: required `name`, `description`, `parameters`, `url`, `method`, `execution_mode`, `auth_type`; optional `auth_token`, `headers`, `response`, `timeout` (default `10`). Omit optional fields to use defaults.

| Parameter        | Type   | Required | Default | Description                                            |
| ---------------- | ------ | -------- | ------- | ------------------------------------------------------ |
| `name`           | string | Yes      | -       | Tool name used in `function_name`                      |
| `description`    | string | Yes      | -       | Human-readable tool behavior description               |
| `url`            | string | Yes      | -       | Your API endpoint URL                                  |
| `parameters`     | object | Yes      | -       | Tool argument schema                                   |
| `method`         | string | Yes      | -       | `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`             |
| `execution_mode` | string | Yes      | -       | `sync` (wait for response) or `async` (accept 2xx)     |
| `auth_type`      | string | Yes      | -       | `none`, `bearer_token`, `api_key`, or `custom_headers` |
| `auth_token`     | string | No       | `null`  | Token for bearer\_token or api\_key auth               |
| `headers`        | object | No       | `{}`    | Custom headers (for auth\_type: custom\_headers)       |
| `response`       | object | No       | `{}`    | Expected response shape                                |
| `timeout`        | number | No       | `10`    | Request timeout in seconds                             |

On update, omit `webhooks.tools` to leave the current tool list unchanged, set `webhooks.tools: null` to clear all tools, or pass a new array to replace the current list.

### Tool request shape

Voice.ai makes HTTP requests directly to your tool URL:

* **GET**: Arguments as query parameters
* **POST/PUT/PATCH/DELETE**: Arguments as JSON body

Metadata is sent in headers: `X-VoiceAI-Request-Id`, `X-VoiceAI-Tool-Name`, `X-VoiceAI-Agent-Id`, `X-VoiceAI-Call-Id`

**Example GET request:**

```http theme={null}
GET /webhooks/tools/search-kb?query=refund+policy&top_k=3
X-VoiceAI-Request-Id: req_123
X-VoiceAI-Tool-Name: search_knowledge_base
X-VoiceAI-Agent-Id: agent_123
X-VoiceAI-Call-Id: call_123
```

**Example POST request:**

```http theme={null}
POST /webhooks/tools/account-status
Content-Type: application/json
X-VoiceAI-Request-Id: req_456
X-VoiceAI-Tool-Name: get_account_status
X-VoiceAI-Agent-Id: agent_123
X-VoiceAI-Call-Id: call_123

{"customer_id":"cust_987"}
```

**Recommended response** (sync mode):

```json theme={null}
{
  "result": {
    "status": "active",
    "tier": "enterprise"
  }
}
```

### Tool authentication

* `auth_type: 'none'`: no auth headers added.
* `auth_type: 'bearer_token'`: sends `Authorization: Bearer <auth_token>`.
* `auth_type: 'api_key'`: sends `X-API-Key: <auth_token>`.
* `auth_type: 'custom_headers'`: sends your configured `headers` map.

### Tool response behavior

* `execution_mode: 'sync'`: waits for downstream response body; non-2xx fails the tool call.
* `execution_mode: 'async'`: treats any 2xx as accepted and does not require a response payload.

## Signed Inbound Webhook Headers

`webhooks.events[]` and `webhooks.inbound_call` requests include:

| Header                | Description                                                                                       |
| --------------------- | ------------------------------------------------------------------------------------------------- |
| `Content-Type`        | `application/json`                                                                                |
| `X-Webhook-Timestamp` | Unix timestamp of when the request was signed                                                     |
| `X-Webhook-Signature` | HMAC-SHA256 signature (only if `webhooks.events[].secret` or `inbound_call.secret` is configured) |

## Signature Verification (Event and Inbound Call Webhooks)

`webhooks.events[]` and `webhooks.inbound_call` use `secret` for HMAC-SHA256. If you configure either secret, verify signatures to ensure requests are from Voice.ai. Tool webhooks use `auth_type`/`auth_token`/`headers` instead and do not use HMAC.

### Signature Format

The signature is computed as:

```
HMAC-SHA256(secret, "{timestamp}.{payload}")
```

Where:

* `timestamp` is the value from `X-Webhook-Timestamp` header
* `payload` is the raw JSON request body

### Verification Examples

Use the same verifier for both event webhooks and inbound call webhooks. The examples below expose the same four routes listed above: one event endpoint, one inbound call endpoint, and two tool endpoints.

<CodeGroup>
  ```python Python theme={null}
  import hmac
  import hashlib
  import time
  from flask import Flask, jsonify, request

  def verify_webhook(request_body: bytes, headers: dict, secret: str) -> bool:
      """Verify webhook signature and timestamp."""
      signature = headers.get('X-Webhook-Signature')
      timestamp = headers.get('X-Webhook-Timestamp')
      
      if not signature or not timestamp:
          return False
      
      # Reject requests older than 5 minutes (replay attack prevention)
      if abs(time.time() - int(timestamp)) > 300:
          return False
      
      # Compute expected signature
      message = f"{timestamp}.{request_body.decode()}"
      expected = hmac.new(
          secret.encode(),
          message.encode(),
          hashlib.sha256
      ).hexdigest()
      
      # Constant-time comparison
      return hmac.compare_digest(expected, signature)


  app = Flask(__name__)
  WEBHOOK_SECRET = "your-signing-secret"

  def get_tool_metadata(headers: dict) -> dict:
      return {
          "request_id": headers.get("X-VoiceAI-Request-Id"),
          "tool_name": headers.get("X-VoiceAI-Tool-Name"),
          "agent_id": headers.get("X-VoiceAI-Agent-Id"),
          "call_id": headers.get("X-VoiceAI-Call-Id"),
      }


  @app.post('/webhooks/voice-events')
  def handle_event_webhook():
      if not verify_webhook(request.data, request.headers, WEBHOOK_SECRET):
          return jsonify({"error": "Invalid signature"}), 401

      event = request.get_json(force=True)
      event_type = event.get("event")

      if event_type == 'call.started':
          print(f"Call started: {event['call_id']}")
      elif event_type == 'call.completed':
          print(f"Call completed: {event['call_id']}, duration: {event['data']['duration_seconds']}s")

      return jsonify({
          "status": "ok",
          "event": event_type,
          "call_id": event.get("call_id"),
      }), 200


  @app.post('/webhooks/inbound-call')
  def handle_inbound_call_webhook():
      if not verify_webhook(request.data, request.headers, WEBHOOK_SECRET):
          return jsonify({"error": "Invalid signature"}), 401

      payload = request.get_json(force=True)
      return jsonify({
          "agent_overrides": {
              "tts_params": {
                  "voice_id": "voice_spanish_support",
                  "temperature": 0.7,
              }
          },
      }), 200


  # Tool routes do not use HMAC. Voice.ai sends metadata in headers instead.
  @app.get('/webhooks/tools/search-kb')
  def search_knowledge_base():
      print(f"Tool request: {get_tool_metadata(request.headers)}")
      return jsonify({
          "result": {
              "matches": [
                  {
                      "title": "Refund policy",
                      "snippet": "Customers can request a refund within 30 days.",
                  }
              ],
              "query": request.args.get("query"),
              "top_k": int(request.args.get("top_k", "3")),
          }
      }), 200


  @app.post('/webhooks/tools/account-status')
  def get_account_status():
      print(f"Tool request: {get_tool_metadata(request.headers)}")
      payload = request.get_json(silent=True) or {}
      return jsonify({
          "result": {
              "status": "active",
              "tier": "enterprise",
              "customer_id": payload.get("customer_id"),
          }
      }), 200
  ```

  ```javascript Node.js theme={null}
  const crypto = require('crypto');
  const express = require('express');

  const app = express();
  const WEBHOOK_SECRET = 'your-signing-secret';
  const signedJson = express.raw({ type: 'application/json' });
  const jsonParser = express.json();

  function verifyWebhook(body, headers, secret) {
    const signature = headers['x-webhook-signature'];
    const timestamp = headers['x-webhook-timestamp'];
    
    if (!signature || !timestamp) {
      return false;
    }
    
    // Reject requests older than 5 minutes
    if (Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)) > 300) {
      return false;
    }
    
    // Compute expected signature
    const message = `${timestamp}.${body}`;
    const expected = crypto
      .createHmac('sha256', secret)
      .update(message)
      .digest('hex');

    // Constant-time comparison
    const expectedBuffer = Buffer.from(expected, 'utf8');
    const signatureBuffer = Buffer.from(signature, 'utf8');
    return expectedBuffer.length === signatureBuffer.length
      && crypto.timingSafeEqual(expectedBuffer, signatureBuffer);
  }

  function getToolMetadata(headers) {
    return {
      request_id: headers['x-voiceai-request-id'],
      tool_name: headers['x-voiceai-tool-name'],
      agent_id: headers['x-voiceai-agent-id'],
      call_id: headers['x-voiceai-call-id'],
    };
  }

  app.post('/webhooks/voice-events', signedJson, (req, res) => {
    if (!verifyWebhook(req.body.toString('utf8'), req.headers, WEBHOOK_SECRET)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const event = JSON.parse(req.body.toString('utf8'));

    if (event.event === 'call.started') {
      console.log(`Call started: ${event.call_id}`);
    } else if (event.event === 'call.completed') {
      console.log(`Call completed: ${event.call_id}, duration: ${event.data.duration_seconds}s`);
    }

    res.json({
      status: 'ok',
      event: event.event,
      call_id: event.call_id,
    });
  });

  app.post('/webhooks/inbound-call', signedJson, (req, res) => {
    if (!verifyWebhook(req.body.toString('utf8'), req.headers, WEBHOOK_SECRET)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const payload = JSON.parse(req.body.toString('utf8'));
    res.json({
      agent_overrides: {
        tts_params: {
          voice_id: 'voice_spanish_support',
          temperature: 0.7,
        },
      },
    });
  });

  // Tool routes do not use HMAC. Voice.ai sends metadata in headers instead.
  app.get('/webhooks/tools/search-kb', (req, res) => {
    console.log('Tool request:', getToolMetadata(req.headers));
    res.json({
      result: {
        matches: [
          {
            title: 'Refund policy',
            snippet: 'Customers can request a refund within 30 days.',
          },
        ],
        query: req.query.query,
        top_k: Number.parseInt(req.query.top_k ?? '3', 10),
      },
    });
  });

  app.post('/webhooks/tools/account-status', jsonParser, (req, res) => {
    console.log('Tool request:', getToolMetadata(req.headers));
    res.json({
      result: {
        status: 'active',
        tier: 'enterprise',
        customer_id: req.body?.customer_id ?? null,
      },
    });
  });

  app.listen(3000);
  ```
</CodeGroup>

## Retry Logic

Failed webhook deliveries are retried with exponential backoff:

| Attempt | Delay     |
| ------- | --------- |
| 1       | Immediate |
| 2       | 1 second  |
| 3       | 2 seconds |
| 4       | 4 seconds |
| 5       | 8 seconds |

Retries stop after 5 attempts or on receiving a 4xx response (except 429 rate limit).

<Warning>
  **Idempotency**: Your webhook handler should be idempotent. The same event may be delivered multiple times due to retries. Use `call_id` to deduplicate events.
</Warning>

## Best Practices

1. **Always verify signatures** in production to prevent spoofed requests
2. **Respond quickly** with a 2xx status code within 5 seconds to avoid retries
3. **Process asynchronously** - queue events for processing rather than blocking the response
4. **Handle duplicates** - use `call_id` to deduplicate in case of retries
5. **Check timestamps** - reject signed requests older than 5 minutes to prevent replay attacks
6. **Use HTTPS** - ensure your webhook endpoint uses TLS encryption

## Filtering Events

You can filter which events you receive by specifying the `events` array:

```json theme={null}
{
  "webhooks": {
    "events": [
      {
        "url": "https://your-server.com/webhooks",
        "events": ["call.completed"]
      }
    ]
  }
}
```

This configuration only receives `call.completed` events. Leave `events` empty or omit it to receive all event types, including `call.failed`.

## Testing Webhooks

You can test your configured event webhook endpoints using the [Test Events Webhook](/docs/api-reference/agent-management/test-events-webhook) endpoint:

```bash theme={null}
curl -X POST "https://api.voice.ai/api/v1/agent/{agent_id}/webhook/test" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

This sends a `test` event to each enabled configured `webhooks.events[]` endpoint and returns per-endpoint delivery results. It does not invoke `webhooks.inbound_call`.

### Testing Tools Webhooks

You can test an individual tools webhook using the [Test Tools Webhook](/docs/api-reference/agent-management/test-tools-webhook) endpoint:

```bash theme={null}
curl -X POST "https://api.voice.ai/api/v1/agent/{agent_id}/webhook/test-tool/get_account_status" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

This sends a sample `function_call` payload for the specified `webhooks.tools[]` entry and returns the delivery result. It does not invoke `webhooks.inbound_call`.

### Testing Inbound Call Webhooks

You can test your configured inbound call webhook using the [Test Inbound Call Webhook](/docs/api-reference/agent-management/test-inbound-call-webhook) endpoint:

```bash theme={null}
curl -X POST "https://api.voice.ai/api/v1/agent/{agent_id}/webhook/test-inbound-call" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

This sends a synthetic inbound-call payload with sample phone numbers to `webhooks.inbound_call` and validates the response before you use it in a live call. It does not place a real phone call.

The test endpoint is intentionally strict:

* `dynamic_variables` must be valid scalar values and may only include variables referenced by the agent's saved `prompt` or `greeting`
* `agent_overrides` must match the runtime override schema
* returned TTS overrides are validated and normalized the same way runtime inbound-call overrides are, including `voice_id -> speaker` and dictionary resolution

For local development, use a tunnel service like [ngrok](https://ngrok.com) to expose your local server:

```bash theme={null}
ngrok http 3000
# Use the ngrok URL as your webhook URL during development
```

## Next Steps

* [Agent Configuration](/docs/guides/voice-agents/configuration) - Configure prompts, `dynamic_variables`, and webhook settings
* [Web SDK](/docs/guides/voice-agents/web) - Pass `dynamicVariables` from the browser and configure webhooks through the SDK
* [Analytics](/docs/guides/voice-agents/analytics) - View call history and metrics
* [API Reference](/docs/api-reference) - Complete API documentation
