Agent skill

Vapi Webhooks Skill

Receive and verify Vapi webhooks (the "Server URL"). Use when setting up a Vapi Server URL receiver, authenticating deliveries with a shared secret (Authorization: Bearer or the legacy X-Vapi-Secret header), or handling voice-agent server messages — including the four request/response types that REQUIRE a JSON body back (assistant-request, tool-calls, transfer-destination-request, knowledge-base-request) plus informational ones like status-update and end-of-call-report.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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).

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:

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);
}
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/, examples/nextjs/, 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:

{
  "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.typeRespond withNotes
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

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:

# 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Aug 27, 2026

View on GitHub →