Gareth Wilson Gareth Wilson

Guide to Vapi Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationServer URL set at four levels; the most specific wins (Custom Tool > Assistant > Phone Number > Account-wide)
AuthenticationOpt-in, per endpoint via dashboard Custom Credentials (credentialId on the server object); none by default
Credential typesBearer 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
BidirectionalFour message types require a JSON response body; assistant-request has a hard ~7.5-second timeout
DuplicatesMessages can be redelivered; dedupe on message.call.id + message.type
SDKNone 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:

EventFires when
status-updateThe call status changes
transcriptA transcript segment is available
conversation-updateThe conversation history updates
speech-updateSpeech starts or stops
end-of-call-reportThe call ends, with transcript, recording URL, cost, and end reason
hangThe assistant hangs unexpectedly
transfer-updateA transfer changes state
user-interruptedThe 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 — 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.typeFires whenRespond with
assistant-requestAn inbound number has no assistant configured{ "assistantId" }, a transient { "assistant" }, { "destination" }, or { "error" }
tool-callsThe assistant invoked a custom tool{ "results": [{ "name", "toolCallId", "result" }] }, one per entry in toolCallList
transfer-destination-requestA transferCall tool ran without a destination{ "destination": {...}, "message": {...} }
knowledge-base-requestThe 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:

{
  "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 (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 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:

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 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.

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, 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.

Process informational messages asynchronously

Acknowledge, then defer transcripts, status updates, and reports to a queue. See why to process webhooks asynchronously.

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 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 for free and handle Vapi webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.