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, orknowledge-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:
- Bearer Token (recommended, fully specified): Vapi sends
Authorization: Bearer <your-token>— a literal shared secret, nothing is hashed. - Legacy
X-Vapi-Secret: the same shared-secret idea with the header name set toX-Vapi-Secretand theBearerprefix disabled. This reproduces the older inlineserver.secretfield (kept for backward compatibility). - OAuth 2.0 (client credentials): Vapi fetches a token from your token endpoint and presents it as
Authorization: Bearer <token>. - 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}signsx-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
verifyVapiSignaturename 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.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) andcall.endpointing.requestare delivered to dedicated URLs (assistant.voice.server.url/ the smart-endpointing plan'sserver.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
- references/overview.md - Server URL model, message catalog, payload shape
- references/setup.md - Configuring the Server URL, credentials, and the shared secret
- references/verification.md - Every auth option (shared secret, OAuth2, configurable HMAC), gotchas, debugging