# Vapi Webhooks

Vapi is a voice-AI agent platform (assistants place and receive phone calls,
plus chat/session APIs). Its webhook endpoint is called the Server URL. It is
bidirectional: most messages are fire-and-forget notifications, but four
message types require your endpoint to return a meaningful JSON response body —
not just `200 OK` — because Vapi uses your answer to drive the live call.

## When to Use This Skill

* How do I receive Vapi webhooks / configure the Server URL?
* How do I authenticate a Vapi webhook? Which header carries the secret?
* Why is there no fixed HMAC signature to verify?
* How do I respond to `assistant-request`, `tool-calls`,
  `transfer-destination-request`, or `knowledge-base-request`?
* How do I read the event type — why is it at `message.type`, not the top level?

## Verification (core)

Vapi has no single, fixed signature scheme. Authentication is opt-in and
per-endpoint — a Server URL has no authentication until you attach a
credential. Auth is configured in the dashboard as a Custom Credential
(referenced by `credentialId` on the `server` object) and comes in four flavours:

1. Bearer Token (recommended, fully specified): Vapi sends
  `Authorization: Bearer <your-token>` — a literal shared secret, nothing is
  hashed.
2. Legacy `X-Vapi-Secret`: the same shared-secret idea with the header name
  set to `X-Vapi-Secret` and the `Bearer ` prefix disabled. This reproduces the
  older inline `server.secret` field (kept for backward compatibility).
3. OAuth 2.0 (client credentials): Vapi fetches a token from your token
  endpoint and presents it as `Authorization: Bearer <token>`.
4. HMAC: configurable algorithm/header/encoding/payload-format. Verified
  construction (2026-08-12): HMAC-SHA256 (hex) in `x-signature`, secret
  verbatim. The Payload Format decides what's signed: `{body}` signs the raw
  body (recommended, self-contained, Hookdeck-compatible); `{timestamp}.{body}`
  signs `x-timestamp` + `.` + raw body and requires the timestamp header on
  (see [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/vapi-webhooks/references/verification.md)).

The primary, fully-specified path — and the one these examples implement — is
the shared secret (#1/#2). Read the token from `Authorization` (stripping a
`Bearer ` prefix) or `X-Vapi-Secret`, and compare it to your stored secret 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); // guard: throws on length mismatch
}

// Read the shared secret from either header Vapi may be configured to send.
function extractToken(headers) {
  const auth = headers['authorization'];
  if (auth) return auth.startsWith('Bearer ') ? auth.slice(7) : auth;
  return headers['x-vapi-secret']; // legacy header / server.secret
}

function verifyVapiSecret(headers, expected) {
  const token = extractToken(headers);
  if (!token || !expected) return false;
  return safeEqual(token, expected);
}

```

```python
import hmac

def verify_vapi_secret(headers, expected: str | None) -> bool:
    auth = headers.get("authorization")
    token = auth[7:] if auth and auth.startswith("Bearer ") else (auth or headers.get("x-vapi-secret"))
    if not token or not expected:
        return False
    return hmac.compare_digest(token, expected)

```

> There is no official Vapi SDK helper for webhook verification, and no
> documented source-IP allowlist. A `verifyVapiSignature` name appears in one
> CLI tutorial snippet with no implementation — it is a placeholder, not a real
> export. Don't call it.

> For complete handlers with the request/response protocol and tests, see
> [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/vapi-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/vapi-webhooks/examples/nextjs/),
> [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/vapi-webhooks/examples/fastapi/).

## The Envelope — `message.type`

Every delivery is a POST whose body wraps the event in a `message` object. The
event type is nested at `message.type`, not at the top level:

```json
{
  "message": {
    "type": "status-update",
    "call": { "id": "..." },
    "phoneNumber": { "...": "..." },
    "timestamp": 1712345678000
  }
}

```

Dispatch on `body.message.type`. (A CLI tutorial page shows a flatter shape with
top-level `type`/`transcript` and names like `call-started` — that is informal
example code, not the wire format. Trust `message.type`.)

## Request/Response Protocol (four types need a JSON body)

These four `message.type` values require a JSON response body — Vapi consumes
it to steer the call:

| `message.type` | Respond with | Notes |
| --- | --- | --- |
| `assistant-request` | `{ "assistantId": "..." }`, a transient `{ "assistant": {…} }`, a `{ "destination": {…} }`, or `{ "error": "spoken message" }` | Sent when an inbound number has no assistant. Hard 7.5s end-to-end timeout (fixed). |
| `tool-calls` | `{ "results": [ { "name", "toolCallId", "result" } ] }` | One entry per call in the incoming `toolCallList`. |
| `transfer-destination-request` | `{ "destination": {…}, "message": {…} }` | Only when a `transferCall` tool has no destination. |
| `knowledge-base-request` | `{ "documents": [ { "content", "similarity", "uuid" } ] }` | Only for a `custom-knowledge-base` provider. |

All other message types are informational — a bare `200` (no body) is enough:
`status-update`, `end-of-call-report`, `hang`, `conversation-update`,
`transcript`, `speech-update`, `model-output`, `transfer-update`,
`user-interrupted`, `language-change-detected`, `phone-call-control`, and the
`chat.*` / `session.*` messages.

> Edge cases handled elsewhere: `voice-request` (expects raw PCM audio, not
> JSON) and `call.endpointing.request` are delivered to dedicated URLs
> (`assistant.voice.server.url` / the smart-endpointing plan's `server.url`), not
> the main Server URL. Don't build the main handler around them.

## Environment Variables

```bash
VAPI_WEBHOOK_SECRET=your_shared_secret   # the Bearer token / X-Vapi-Secret value from your Server URL credential

```

## Local Development

`vapi listen` is a local forwarder only — it does not create a public tunnel:

```bash
# 1) Forward Vapi traffic hitting your machine to your app (default listen port 4242)
vapi listen --forward-to localhost:3000/webhooks/vapi

# 2) Expose it publicly (pick one) and set THAT URL as the Server URL in Vapi:
npx hookdeck-cli listen 3000 vapi --path /webhooks/vapi

```

The Hookdeck CLI gives you a public HTTPS URL plus a UI to inspect and replay
deliveries — register that URL as your Server URL.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/vapi-webhooks/references/overview.md) - Server URL model, message catalog, payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/vapi-webhooks/references/setup.md) - Configuring the Server URL, credentials, and the shared secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/vapi-webhooks/references/verification.md) - Every auth option (shared secret, OAuth2, configurable HMAC), gotchas, debugging