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

# Receive real-time call events with webhooks

> Configure a webhook URL on your agent to receive call_started, call_ended, call_analyzed, and tool_invoked events in real time.

Verbeo sends webhook events to your HTTPS endpoint as calls progress. You configure one URL per agent and Verbeo delivers all lifecycle events there automatically. This lets your systems react in real time — open a CRM record when a call starts, log duration when it ends, and push extracted insights to your database when analysis completes.

## Event types

| Event | Fires | Delivered to |
| - | - | - |
| `call_started` | Immediately when the call is answered | Agent `webhookUrl` |
| `call_ended` | Immediately when either party disconnects | Agent `webhookUrl` |
| `call_analyzed` | Once the summary and post-call analysis are ready — usually within seconds of the call ending; up to 3 hours if generating them fails. Sent once | Agent `webhookUrl` |
| `tool_invoked` | During the call, when the agent invokes a custom tool | The tool's own endpoint URL |

All four events carry the same `call_id` for a given call. Use it as your primary key to correlate events across your systems.

<Note>
  These events tell you what happened *after the fact*. To influence a call before it connects — supplying dynamic variables for the agent's prompt, or choosing which agent answers — use the [inbound call webhook](/guides/inbound-call-webhook), which is configured per phone number rather than per agent.
</Note>

## Set your webhook URL

In the Agent Editor, open **Settings** and fill in the **Webhook URL** field with your HTTPS endpoint. You can also set it via the API with [Update assistant](/api-reference/assistants/update), which changes the webhook and leaves the rest of the assistant as it is:

```bash theme={null}
curl -X PATCH "https://api.verbeo.ai/v1/assistant/YOUR_ASSISTANT_ID" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "config": {
      "settings": {
        "webhookUrl": "https://api.yourcompany.com/verbeo-events"
      }
    }
  }'
```

<Warning>
  Verbeo only delivers to HTTPS endpoints. Plain HTTP URLs are rejected. Your endpoint must present a valid TLS certificate and respond with a `2xx` status promptly — Verbeo's delivery request times out after about 12 seconds — or the event is retried.
</Warning>

The URL must be publicly reachable. Verbeo refuses webhook URLs that point at a private, local or cloud metadata address — for example `localhost`, `127.0.0.1`, `10.x.x.x`, `192.168.x.x` or `169.254.169.254` — and sends no events to them. To receive events on a server running on your own machine, expose it through a public tunnel. Use the final address as your webhook URL: Verbeo follows a `307` or `308` redirect once, and no other redirects (see [Retries](#retries)).

## Event payloads

All lifecycle events (`call_started`, `call_ended`, `call_analyzed`) share the same top-level envelope:

```json theme={null}
{
  "event": "call_started | call_ended | call_analyzed",
  "call_id": "b7a3c1e4-94f2-4c9d-a8e0-5d9f6c2a1b34",
  "agent_id": "agent_f3a2b1c4e5d6",
  "timestamp": "2026-04-17T11:03:15.000Z",
  "payload": { }
}
```

Dispatch in your handler using the top-level `event` field. For a field-by-field reference of every event, see the [Webhook events reference](/api-reference/webhooks/events).

### `call_started`

Fires within 1–2 seconds of the call being answered. Contains caller/callee numbers, call type, and any dynamic variables you passed when initiating the call.

```json theme={null}
{
  "event": "call_started",
  "call_id": "b7a3c1e4-94f2-4c9d-a8e0-5d9f6c2a1b34",
  "agent_id": "agent_f3a2b1c4e5d6",
  "timestamp": "2026-04-17T11:03:15.000Z",
  "payload": {
    "status": "started",
    "start_time": "2026-04-17T11:03:15.000Z",
    "call_type": "outbound",
    "from_number": "+15550100001",
    "to_number": "+15550100002",
    "dynamic_variables": {
      "customer_name": "Sarah Chen",
      "plan": "premium"
    }
  }
}
```

### `call_ended`

Fires within 1–2 seconds of disconnect. Includes duration in seconds, cost in cents, and the disconnection reason. The transcript and extracted data are not yet available at this point.

```json theme={null}
{
  "event": "call_ended",
  "call_id": "b7a3c1e4-94f2-4c9d-a8e0-5d9f6c2a1b34",
  "agent_id": "agent_f3a2b1c4e5d6",
  "timestamp": "2026-04-17T11:06:42.000Z",
  "payload": {
    "status": "completed",
    "call_type": "outbound",
    "from_number": "+15550100001",
    "to_number": "+15550100002",
    "start_time": "2026-04-17T11:03:15.000Z",
    "end_time": "2026-04-17T11:06:42.000Z",
    "duration": 207,
    "cost": 12,
    "disconnection_reason": "user_hangup",
    "dynamic_variables": {
      "customer_name": "Sarah Chen",
      "plan": "premium"
    }
  }
}
```

**Disconnection reasons:**

| Value | Meaning |
| - | - |
| `completed` | The call finished normally (default) |
| `user_hangup` | The human caller hung up |
| `agent_hangup` | The AI agent ended the call |
| `timeout` | Call exceeded the maximum duration |
| `voicemail` | Agent detected voicemail |
| `no_answer` | The called party did not pick up |
| `error` | A platform error ended the call |
| `error_user_not_joined` | A web call was started but the caller never joined it; the agent ended it after 60 seconds |

`disconnection_reason` is an open string — Verbeo may return additional or more specific values over time, so treat any value you don't recognize gracefully rather than assuming a fixed set.

### `call_analyzed`

Fires once, when the summary and post-call analysis are ready — usually within seconds of the call ending. If generating them fails, Verbeo keeps retrying and the event can take up to 3 hours; it is still sent only once, and in that rare case `summary` or `analysis_data` can be `null`. This is the primary event for downstream integrations. It carries the full transcript, AI-generated summary, your extracted `analysis_data` fields, a log of every tool executed, and a recording URL.

```json theme={null}
{
  "event": "call_analyzed",
  "call_id": "b7a3c1e4-94f2-4c9d-a8e0-5d9f6c2a1b34",
  "agent_id": "agent_f3a2b1c4e5d6",
  "timestamp": "2026-04-17T11:07:05.000Z",
  "payload": {
    "status": "analyzed",
    "data": {
      "id": "b7a3c1e4-94f2-4c9d-a8e0-5d9f6c2a1b34",
      "workspace_id": "5c1d9b27-3e4a-4f8b-bc12-7a9e0d4f6b1a",
      "agent_id": "agent_f3a2b1c4e5d6",
      "call_type": "outbound",
      "from_number": "+15550100001",
      "to_number": "+15550100002",
      "status": "completed",
      "duration": 207,
      "started_at": "2026-04-17T11:03:15.000Z",
      "ended_at": "2026-04-17T11:06:42.000Z",
      "disconnection_reason": "user_hangup",
      "recording_url": "https://recordings.example.com/call-b7a3c1e4.wav",
      "recording_sid": "RE9f8e7d6c5b4a3928170655e4d3c2b1a0",
      "call_sid": "CA1a2b3c4d5e6f70819a2b3c4d5e6f7081",
      "cost": 12,
      "created_at": "2026-04-17T11:03:14.000Z",
      "updated_at": "2026-04-17T11:07:05.000Z",
      "transcript": [
        { "role": "agent", "content": "Hi, how can I help you today?", "timestamp": "2026-04-17T11:03:18.000Z" },
        { "role": "user", "content": "Can you check the status of order ORD-98432?", "timestamp": "2026-04-17T11:03:22.000Z" },
        {
          "role": "tool",
          "tool_name": "lookup_order",
          "query": "{\"order_id\":\"ORD-98432\"}",
          "status_code": 200,
          "result": { "status": "shipped", "eta": "2026-04-19" },
          "error": null,
          "documents": [],
          "timestamp": "2026-04-17T11:03:26.000Z"
        },
        { "role": "agent", "content": "Your order shipped and arrives on April 19th.", "timestamp": "2026-04-17T11:03:30.000Z" }
      ],
      "transcript_clean": [
        { "role": "agent", "content": "Hi, how can I help you today?", "timestamp": "2026-04-17T11:03:18.000Z" },
        { "role": "user", "content": "Can you check the status of order ORD-98432?", "timestamp": "2026-04-17T11:03:22.000Z" },
        { "role": "agent", "content": "Your order shipped and arrives on April 19th.", "timestamp": "2026-04-17T11:03:30.000Z" }
      ],
      "summary": "Customer Sarah Chen called to check on order ORD-98432. The agent looked up the order, confirmed it had shipped, and gave an estimated delivery date of April 19th. The customer was satisfied and ended the call.",
      "analysis_data": {
        "issue_resolved": true,
        "sentiment": "positive"
      },
      "dynamic_variables": {
        "customer_name": "Sarah Chen",
        "plan": "premium"
      },
      "tools": [
        {
          "tool_name": "lookup_order",
          "tool_type": "custom",
          "description": "lookup_order executed (HTTP 200)",
          "args": { "order_id": "ORD-98432" },
          "result": { "status": "shipped", "eta": "2026-04-19" },
          "error": null,
          "executed_at": "2026-04-17T11:03:26.000Z"
        }
      ]
    }
  }
}
```

The `transcript` array interleaves `user`, `agent`, and `tool` turns in chronological order. A `tool` turn carries `tool_name`, `query`, `status_code`, `result`, `error`, and `documents`. The `transcript_clean` array is the same conversation with the `tool` turns removed — use it when you want only the human-readable dialogue. The `tools` array is a separate, flat log of every tool the agent executed, where `tool_type` is an open value such as `custom`, `native`, or `knowledge_base`.

<Note>
  The `recording_url` is a pre-signed, time-limited link. Download and store the audio in your own system if you need it beyond the signature window.
</Note>

### `tool_invoked`

Fires during the call, every time the agent invokes one of your custom tools. This event is sent directly to the tool's configured endpoint URL — not to the agent's `webhookUrl`.

```json theme={null}
{
  "event": "tool_invoked",
  "call_id": "8eb0e5f2-e8b5-44cb-beba-9b802f39c340",
  "agent_id": "a2e0146d-6b37-4b09-b33b-f2c72f22684d",
  "timestamp": "2026-04-18T12:19:20.789Z",
  "payload": {
    "tool_name": "lookup_order",
    "args": {
      "order_id": "ORD-98432"
    },
    "call": {
      "call_type": "outbound",
      "customer_number": "+12125550001",
      "dynamic_variables": { "customer_name": "Sarah Chen" },
      "transcript": "[12:19:08] User: Can you check order ORD-98432?",
      "transcript_object": [
        { "role": "user", "content": "Can you check order ORD-98432?", "timestamp": "2026-04-18T12:19:08.000Z" }
      ]
    }
  }
}
```

Your handler must respond within 10 seconds with JSON. See the [Custom tools guide](/guides/custom-tools) for the full response format.

## Handle webhooks reliably

### Respond with 2xx immediately

Return a `200 OK` as fast as possible — Verbeo's delivery request times out after about 12 seconds. If your processing takes longer, queue the payload and acknowledge immediately:

```javascript theme={null}
app.post('/verbeo-events', (req, res) => {
  res.sendStatus(200); // Acknowledge immediately
  processEventAsync(req.body); // Queue for background processing
});
```

Respond from the webhook URL itself. Verbeo follows a `307` or `308` redirect once; any other `3xx`, or a second redirect, counts as a failed delivery. Configure the final URL rather than relying on a redirect.

### Retries

Any response other than `2xx` — or no response within 12 seconds — is retried, up to 12 automatic attempts per event (a resend from the dashboard starts a fresh series):

* The wait between attempts doubles from 2 minutes up to a cap of 6 hours, so the last attempt comes about 26 hours after the first.
* If a `429` or `503` response carries a `Retry-After` header, Verbeo waits that long instead (between 1 minute and 6 hours).
* A `307` or `308` redirect is followed once, re-sending the same request to its `Location`. Other `3xx` responses, and a second redirect, are not followed and count as failures.

See [Retries](/api-reference/webhooks/events#retries) for the exact schedule.

### Make your handler idempotent

Verbeo retries events if your endpoint returns a non-2xx response or times out — a background reconciler re-drives any event that wasn't confirmed delivered, and you can resend manually from the dashboard (see [Monitor and retry deliveries](#monitor-and-retry-deliveries-in-the-dashboard)). The same event can therefore arrive more than once. Deduplicate on `(call_id, event)`:

```javascript theme={null}
const processed = new Set();

async function handleEvent(body) {
  const key = `${body.call_id}:${body.event}`;
  if (processed.has(key)) return; // Already handled
  processed.add(key);

  switch (body.event) {
    case 'call_started':
      await createCallRecord(body);
      break;
    case 'call_ended':
      await updateCallDuration(body);
      break;
    case 'call_analyzed':
      await pushToCRM(body);
      break;
  }
}
```

In production, use a persistent store (Redis, database) rather than an in-memory set.

Once Verbeo has recorded a `2xx` from your endpoint for an event, it never sends that event again on its own, so a repeat you did not ask for is a retry and safe to skip. The only other repeats come from your team in the dashboard: **Retry** on a call's **Webhooks** tab (see [Retry a delivery](#retry-a-delivery)), and **Enhance Transcript**, which sends `call_analyzed` again with the improved transcript. If you want that transcript, update the stored call instead of skipping the repeat.

### Events may arrive out of order

Verbeo delivers each call's events in order — `call_started`, then `call_ended`, then `call_analyzed` — and holds a later event while an earlier one is still being retried, until 30 minutes after the call ended. If your endpoint is down for longer than that, later events are sent anyway and can arrive before earlier ones; the same goes for resends from the dashboard after that point. Each event is self-contained — process them independently rather than relying on order.

## Example Express.js webhook handler

```javascript theme={null}
const express = require('express');
const app = express();
app.use(express.json());

app.post('/verbeo-events', async (req, res) => {
  // Acknowledge immediately
  res.sendStatus(200);

  const { event, call_id, payload } = req.body;

  switch (event) {
    case 'call_started':
      console.log(`Call ${call_id} started — caller: ${payload.from_number}`);
      await db.calls.create({ call_id, status: 'in-progress', ...payload });
      break;

    case 'call_ended':
      console.log(`Call ${call_id} ended — duration: ${payload.duration}s`);
      await db.calls.update(call_id, {
        status: 'ended',
        duration: payload.duration,
        disconnection_reason: payload.disconnection_reason,
      });
      break;

    case 'call_analyzed':
      const { data } = payload;
      console.log(`Call ${call_id} analyzed — summary: ${data.summary}`);
      await crm.updateContact({
        phone: data.to_number,
        call_summary: data.summary,
        ...data.analysis_data,
      });
      break;

    default:
      console.warn(`Unknown event type: ${event}`);
  }
});

app.listen(3000);
```

## Monitor and retry deliveries in the dashboard

Every delivery attempt is recorded — the full request Verbeo sent and the full response your endpoint returned — so you can confirm exactly what Verbeo sent and debug failures without adding any logging on your side.

Open **Call History**, click a call to open its details, and select the **Webhooks** tab. Attempts are grouped by event, newest first. Each row shows:

| Column | What it shows |
| - | - |
| # | The attempt number for that event |
| Time | When the attempt was made |
| Outcome | How the attempt ended (see below) |
| Duration | How long your endpoint took to respond |
| Source | How the attempt was triggered: `live` (real time), `cron` (automatic retry), `retry` (manual), or `failure` (failed-call path) |

The outcome is one of:

| Outcome | What it means |
| - | - |
| A status code (for example `200` or `HTTP 500`) | Your endpoint answered with that status. A `2xx` means the event was delivered; any other status is retried |
| Redirect | Your endpoint answered with a `3xx` that Verbeo doesn't follow: any redirect other than `307` or `308`, or a second redirect after one was followed. Retried like any failure |
| Timeout | No response within 12 seconds. Retried |
| Network error | The connection failed — for example DNS, TLS or a reset. Retried |
| Too large | Your endpoint answered `413`, rejecting the request body as too large. Retried like any failure; `call_analyzed` carries the full transcript, so raise your endpoint's request size limit |
| Sending | No result was recorded for this attempt: it is still in progress, or Verbeo's sender stopped before it could record your endpoint's answer, so the request may or may not have reached you. If it doesn't complete, Verbeo sends the event again, so it can reach you twice; your deduplication handles that |

Expand an attempt to see the full exchange:

* **Request** — `POST` and the destination URL, every header Verbeo sent, and the exact JSON body, with a copy button.
* **Response** — the status code, headers and body your endpoint returned (up to 16 KB of body), or the error message when there was no response.

Developers, admins and owners see the full request and response; the member role sees metadata only — the event, attempt number, time, outcome, status code, duration, source and the endpoint's host. Attempt details are kept for 30 days for successful deliveries and 7 days for failed ones.

Calls delivered before Verbeo started recording full requests keep the earlier view: one row per delivery attempt, newest first, across all three lifecycle events.

| Column | What it shows |
| - | - |
| Event | `Started`, `Ended`, or `Analyzed` |
| Status | The HTTP status your endpoint returned — green for `2xx`, red otherwise. Shows `no response` on a timeout or network error, and `no URL` when the agent has no webhook configured |
| Source | How the attempt was triggered: `live` (real time), `cron` (automatic retry), `retry` (manual), or `failure` (failed-call path) |
| Time | When the attempt was made |

In that view, expand a row to see the destination URL, a summary of the payload that was sent, and the response body or error your endpoint returned. Full URLs and payloads are visible to developers, admins and owners; the member role sees metadata only. Verbeo keeps the most recent 50 attempts per call.

### Retry a delivery

If your endpoint was down, click **Retry**, choose which events to resend (**All**, **Started**, **Ended**, or **Analyzed**), and click **Send**. Verbeo sends the selected events in lifecycle order and doesn't stop at a failure: any event that fails, or has to wait for an earlier one, stays queued and is retried automatically. A resend more than 30 minutes after the call ended isn't held for ordering, so `call_analyzed` can arrive before a `call_ended` that is still failing. The tab then polls for a few minutes to surface the new attempts as they land. Manual retry is available to developers, admins and owners.

A resend sends exactly the body Verbeo sent before. When Verbeo no longer has that body — more than 30 days after the event was first sent, or for a call from before Verbeo started recording full requests — it rebuilds the event from the call's current data.

<Note>
  Verbeo also retries failed deliveries automatically in the background, so a transient outage usually resolves itself. Use manual retry when you've fixed your endpoint and want the data redelivered right away.
</Note>

## Security recommendations

* **Verify the `event` field** before processing. Unknown events should be ignored, not errored.
* **Use HTTPS with a valid certificate.** Plain HTTP endpoints are rejected before any events are sent.
* **Authenticate inbound requests** by adding a shared secret to the agent's webhook URL as a query parameter (e.g. `https://api.yourcompany.com/webhooks?secret=abc123`) and validating it in your handler.
* **Verbeo does not currently sign webhook payloads.** There is no HMAC signature header to verify — rely on the shared-secret query parameter above, and optionally allow-list Verbeo's egress IP range (contact [support@verbeo.ai](mailto:support@verbeo.ai) for the current addresses).
* **Don't return secrets in webhook responses.** Verbeo records each attempt's full URL, including its query parameters (so a shared secret in the URL too), and your endpoint's response headers and first 16 KB of response body. It shows them in the dashboard to the developers, admins and owners of your workspace for up to 30 days. Keep tokens and session cookies out of your webhook responses.
* **Do not log full transcripts** to services that cannot handle PII. Transcripts may contain names, phone numbers, emails, and other personal data.
* **Store `recording_url` contents promptly.** The pre-signed URLs expire — download recordings to your own storage if you need them for compliance or QA.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.