# Guide to Vapi Webhooks: Features and Best Practices

Vapi webhooks notify your systems about voice-AI activity: a call started, a transcript arrived, an assistant needs tool results, a call ended with a report. If you're building on Vapi's voice-agent platform, webhooks are how your backend participates in calls rather than just observing them.

This guide covers how Vapi webhooks work, the opt-in authentication model (there's no fixed signature scheme), the nested message envelope, the four message types that require a JSON response body, and the best practices for production.

## What are Vapi webhooks?

Vapi's webhook endpoint is called the Server URL. When something happens on a call, chat, or session, Vapi POSTs a JSON message to it. Unlike most webhook products, the Server URL is bidirectional: most messages are fire-and-forget notifications, but four message types block on your response and use the JSON you return to drive the live call. Your answer picks an assistant, returns tool results, chooses a transfer destination, or supplies knowledge-base documents.

## Vapi webhook features

| Feature | Details |
| --- | --- |
| Configuration | Server URL set at four levels; the most specific wins (Custom Tool > Assistant > Phone Number > Account-wide) |
| Authentication | Opt-in, per endpoint via dashboard Custom Credentials (`credentialId` on the `server` object); none by default |
| Credential types | Bearer Token (literal shared secret), legacy `X-Vapi-Secret`, OAuth 2.0 client credentials, configurable HMAC |
| Envelope | `{ "message": { "type": ... } }` with the event type nested at `message.type`, not top-level |
| Bidirectional | Four message types require a JSON response body; `assistant-request` has a hard ~7.5-second timeout |
| Duplicates | Messages can be redelivered; dedupe on `message.call.id` + `message.type` |
| SDK | None for verification (no official verify helper, no documented source-IP allowlist) |

## Common events

Vapi event names are the `message.type` field values. Most are informational, where a bare 200 is enough:

| Event | Fires when |
| --- | --- |
| `status-update` | The call status changes |
| `transcript` | A transcript segment is available |
| `conversation-update` | The conversation history updates |
| `speech-update` | Speech starts or stops |
| `end-of-call-report` | The call ends, with transcript, recording URL, cost, and end reason |
| `hang` | The assistant hangs unexpectedly |
| `transfer-update` | A transfer changes state |
| `user-interrupted` | The caller interrupts the assistant |

Branch on the `message.type` field; the type is nested inside the `message` envelope, not at the top level.

> See Vapi webhook payloads in action. Inspect and replay sample Vapi webhook payloads in the [Hookdeck Console](https://console.hookdeck.com) — no account or setup required.

## The four request/response messages

Four `message.type` values block on your response, and Vapi consumes the JSON body you return to steer the call. A bare `200` (or the wrong shape) breaks the call:

| `message.type` | Fires when | Respond with |
| --- | --- | --- |
| `assistant-request` | An inbound number has no assistant configured | `{ "assistantId" }`, a transient `{ "assistant" }`, `{ "destination" }`, or `{ "error" }` |
| `tool-calls` | The assistant invoked a custom tool | `{ "results": [{ "name", "toolCallId", "result" }] }`, one per entry in `toolCallList` |
| `transfer-destination-request` | A `transferCall` tool ran without a destination | `{ "destination": {...}, "message": {...} }` |
| `knowledge-base-request` | The assistant uses a `custom-knowledge-base` provider | `{ "documents": [{ "content", "similarity", "uuid" }] }` |

`assistant-request` has a hard, non-configurable ~7.5-second end-to-end timeout: the telephony provider caps call setup at 15 seconds and Vapi reserves roughly half for its own setup. Timeout values elsewhere in the dashboard don't apply to it.

Two further types never reach the main Server URL: `voice-request` (expects raw PCM audio, not JSON) and `call.endpointing.request` go to separate dedicated URLs, so don't build your main handler around them.

## Setting up Vapi webhooks

Set the Server URL at any of four levels: account-wide in the dashboard under Settings > Organization > General Settings, or per assistant, phone number, or custom tool via the `server` object on that resource:

```json
{
  "server": {
    "url": "https://api.example.com/webhooks/vapi",
    "credentialId": "cred_..."
  }
}

```

Only one URL receives a given event: the most specific configured level wins (Custom Tool > Assistant > Phone Number > Account-wide), and each level can carry its own `credentialId`.

For local development, note that `vapi listen` is only a local forwarder and doesn't create a public tunnel. Pair it with a tunnel, or use the [Hookdeck CLI](/docs/cli) (`hookdeck listen 3000 vapi --path /webhooks/vapi`), which gives you a public HTTPS URL plus an inspector for replaying deliveries, and register that URL as your Server URL.

## Securing Vapi webhooks

A fresh Server URL has no authentication at all. Auth is opt-in: you create a Custom Credential in the dashboard, reference it by `credentialId`, and pick one of four mechanisms. There's no single "Vapi signature" to verify; how you verify depends on the credential you configured.

### The shared secret (recommended)

The Bearer Token credential sends `Authorization: Bearer <token>`, where the token is a literal shared secret: nothing is hashed. The legacy variant sends the same secret in an `X-Vapi-Secret` header instead (reproducing the older inline `server.secret` field). Read whichever header your credential sends and compare with a timing-safe comparison:

```javascript
const crypto = require("crypto");

function safeEqual(a, b) {
  const ab = Buffer.from(a), bb = Buffer.from(b);
  return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
}

app.post("/webhooks/vapi", express.json(), (req, res) => {
  // 1. Authenticate: Bearer Token or legacy X-Vapi-Secret credential
  const auth = req.headers["authorization"];
  const token = auth
    ? (auth.startsWith("Bearer ") ? auth.slice(7) : auth)
    : req.headers["x-vapi-secret"];
  if (!token || !safeEqual(token, process.env.VAPI_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  // 2. Dispatch on the nested message.type
  const message = req.body.message;
  if (message.type === "assistant-request") {
    // Must answer within ~7.5s; Vapi uses this to route the live call
    return res.status(200).json({ assistantId: process.env.VAPI_ASSISTANT_ID });
  }

  // Informational messages: acknowledge, then process asynchronously
  processQueue.add({ type: message.type, callId: message.call?.id });
  res.sendStatus(200);
});

```

### OAuth 2.0 client credentials

With an OAuth credential, Vapi fetches a token from your token endpoint and presents it as `Authorization: Bearer <token>`, refreshing it as it expires. Since Vapi is handing you back a token you issued, validate it the way you validate any of your own access tokens (introspect it, or check the signature).

### The configurable HMAC

The HMAC credential is fully user-configurable: you choose the algorithm, signature header, optional timestamp header, and payload format, and Vapi's docs pin no defaults. The default construction is a bare HMAC-SHA256 hex digest in an `x-signature` header, with the secret used verbatim as the key. What gets signed depends on the credential's Payload Format:

* `{body}` signs the raw request body; it's self-contained and the format to prefer.
* `{timestamp}.{body}` (Vapi's default) signs the `x-timestamp` header value plus `.` plus the raw body. The timestamp header must stay enabled, or the value Vapi signed with is never delivered and the signature can't be verified. And it's the header value that gets signed, not `message.timestamp` in the body (they differ by tens of milliseconds).

> Make Vapi webhooks production-ready. [Hookdeck Event Gateway](/event-gateway) verifies deliveries at the edge with its native Vapi source, deduplicates, and durably queues every event.

## Vapi webhook limitations and pain points

### Endpoints are unauthenticated by default

The Problem: Until you attach a credential, anyone who learns your Server URL can POST fabricated call events, tool results, or transcripts to it.

Why It Happens: Authentication is opt-in and per endpoint by design; nothing forces a credential onto a new Server URL.

Workarounds:

* Attach a Custom Credential to every Server URL level you use before going to production, and return 401 on every request that fails verification.

How Hookdeck Can Help: Hookdeck gives you a dedicated ingestion URL and verifies Vapi deliveries at the edge, so unauthenticated traffic never reaches your handler.

### No single signature scheme

The Problem: There are four credential types, and the HMAC one is fully configurable with no pinned defaults, so there's no universal "verify a Vapi webhook" recipe, no official SDK helper, and no documented source-IP allowlist.

Why It Happens: Vapi trades a fixed scheme for per-endpoint flexibility: each Server URL level picks its own credential and, for HMAC, its own construction.

Workarounds:

* Use the shared-secret path (Bearer Token or `X-Vapi-Secret`), the only fully specified option: one header, one timing-safe compare.
* If you use HMAC, set the payload format to `{body}`, and test your verifier against a known-answer vector before trusting it.

How Hookdeck Can Help: Hookdeck's Vapi source verifies the `{body}` HMAC construction upstream, so set your credential to that format and verification is handled before events reach you.

### Four messages demand a JSON body on a deadline

The Problem: `assistant-request`, `tool-calls`, `transfer-destination-request`, and `knowledge-base-request` block a live phone call on your response. A bare 200, a wrong shape, or a slow answer (past the hard ~7.5-second `assistant-request` timeout) breaks the call.

Why It Happens: The Server URL is bidirectional: Vapi outsources call-steering decisions to your endpoint, and telephony setup deadlines cap how long it can wait.

Workarounds:

* Serve the four request/response types from a fast, direct path: precompute assistant configuration, keep tool handlers lean, and keep slow work out of these routes.

How Hookdeck Can Help: Queue the firehose, not the steering wheel. Route informational messages (transcripts, status updates, end-of-call reports) through Hookdeck for queuing, deduplication, and replay, and point the interactive types at your handler directly. Vapi's per-level Server URLs (tool, assistant, phone number, account) let you split the two.

### The envelope trips people up

The Problem: The event type lives at `message.type`, nested inside the envelope, but a Vapi CLI tutorial shows a flatter shape with a top-level `type` and names like `call-started`: informal example code that doesn't match the wire format, and a common source of handlers that dispatch on a field that isn't there.

Why It Happens: Tutorial example code drifted from the authoritative [Server Events reference](https://docs.vapi.ai/server-url/events).

Workarounds:

* Dispatch on `body.message.type` and treat type-specific fields defensively; field availability varies by message type and configuration.

How Hookdeck Can Help: Every delivery is logged with its full headers and body, so you can inspect real payloads in the dashboard (or the [Console](https://console.hookdeck.com), pre-signup) before writing handler code against a shape from a tutorial.

## Best practices

### Attach a credential before production

A fresh Server URL accepts unauthenticated POSTs. Attach a Custom Credential and verify every delivery with a timing-safe comparison.

### Dispatch on message.type

The event type is nested at `message.type`. Branch on it, and handle unknown types with a plain 200 so new message types don't break you.

### Answer the request/response types quickly

The four interactive types need the right JSON body, and `assistant-request` must answer within ~7.5 seconds. Keep these paths fast and direct.

### Dedupe on call ID and type

Messages can be redelivered. Deduplicate on `message.call.id` + `message.type` so a duplicate `end-of-call-report` doesn't double-write your analytics. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Process informational messages asynchronously

Acknowledge, then defer transcripts, status updates, and reports to a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Persist end-of-call-report

It carries the final transcript, recording URL, cost breakdown, and end reason: the one message to store for analytics.

## Conclusion

Vapi's Server URL has no authentication until you attach a credential, and every event arrives wrapped in a `message` envelope. Four message types steer live calls with the JSON you return, so those paths need to be fast and correct; everything else can be acknowledged and processed on your own schedule, deduped on the call ID.

[Hookdeck Event Gateway](/event-gateway) verifies Vapi deliveries at the edge, then deduplicates and durably queues the informational stream with replay for anything that fails, so your handlers process trustworthy events while the interactive call paths stay fast and direct.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Vapi webhooks reliably in minutes.