Verify the VAPI webhook, so a forged transcript is refused - #3
Conversation
The bridge took any POST. A customer's webhook is a public URL, so anyone who learned it could post a transcript that was never said and have it become a real call, a real analysis and a real finding in their organization. A forged end-of-call-report could also end a real call's session early. VAPI already sends server.secret back in X-Vapi-Secret on every request. A `secret` option now turns that into a check: pass the request headers to handle, and a request without it raises WebhookVerificationError before a turn is appended or a session is created. The compare is constant time, hand-rolled rather than crypto.timingSafeEqual so the module stays runtime-agnostic across Node, Bun, Deno and the edge. Headers are read case-insensitively from a Headers instance or a plain object, because every server hands them over differently. Verification is off when no secret is configured, so an existing integration keeps working. The README says plainly what that costs, and the example receiver now reads VAPI_WEBHOOK_SECRET, answers 401, and prints verify=OFF at boot when it is unset. Proven against the running example: a forged POST with no header is 401, a wrong secret is 401, the right secret is 200. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ENG-3544 The SDK VAPI bridges accept any POST, so a forged transcript is trusted
What is missingNeither bridge verifies anything about an inbound webhook. The only So a customer following our example ships a public endpoint where anyone who learns the URL can POST a forged The mechanism already existsVAPI sends What to add
Related: ENG-3543 (the DeepTrust-side receiver, where the org API key plays the same role) |
| verify(headers: HeadersLike | undefined): boolean { | ||
| if (!this.secret) { | ||
| return true; | ||
| } | ||
| return timingSafeEqual(readHeader(headers, SECRET_HEADER), this.secret); |
There was a problem hiding this comment.
| const SECRET = process.env.VAPI_WEBHOOK_SECRET ?? ""; | ||
|
|
||
| const bridge = new Bridge(new DeepTrust(), { | ||
| apiKey: process.env.VAPI_API_KEY ?? "", | ||
| ...(SECRET ? { secret: SECRET } : {}), |
There was a problem hiding this comment.
Closes ENG-3544. Stacked on #2, which adds the bridge this verifies.
Summary
The bridge took any POST. A customer's webhook is a public URL, so anyone who learned it could post a
transcriptevent for a call that never happened, or words that were never said in one that did, and have it become a real call, a real analysis and a real finding in their DeepTrust organization. A forgedend-of-call-reportcould also end a real call's session early.VAPI already sends
server.secretback inX-Vapi-Secreton every request. This turns that into a check.A request that does not carry it raises
WebhookVerificationErrorbefore a turn is appended or a session is created.Choices worth stating
crypto.timingSafeEqual, because this module runs on Node, Bun, Deno and the edge and only Node has it. Length is compared first, since it leaks anyway through the size of request a caller can send.Headersinstance, a plain object, or Node's array-valued form, read case-insensitively, because every server hands them over differently.verify=OFF, set VAPI_WEBHOOK_SECRETat boot so it is visible rather than silent.The example is the thing most customers will copy
examples/vapi-webhook/now ships as a documented example with its own README: the four setup steps, thePATCHthat sets the Server URL and the secret together, and real log output. It answers 401 on a bad secret.Verified against the running example
npm run checkpasses, 39 tests. Six are new: a configured secret refusing a request without it, a wrong secret refused, nothing recorded when verification fails, no secret keeping existing behaviour, the header read three ways, and the compare itself.Note
.gitignoregainedexamples/*/.env. The sibling Python repo already had it; this one did not, and an example directory with real keys in it is exactly where that matters.Ships with
🤖 Generated with Claude Code