Skip to content

Verify the VAPI webhook, so a forged transcript is refused - #3

Merged
buildbyjithu merged 1 commit into
devin/1789241109-vapi-adapterfrom
jithu/vapi-webhook-secret
Sep 15, 2026
Merged

buildbyjithu merged 1 commit into
devin/1789241109-vapi-adapterfrom
jithu/vapi-webhook-secret

Conversation

@buildbyjithu

@buildbyjithu buildbyjithu commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

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 transcript event 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 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. This turns that into a check.

const bridge = new Bridge(new DeepTrust(), {
  apiKey: process.env.VAPI_API_KEY!,
  secret: process.env.VAPI_WEBHOOK_SECRET!,
});

await bridge.handle(req.body, { user: caller, headers: req.headers });

A request that does not carry it raises WebhookVerificationError before a turn is appended or a session is created.

Choices worth stating

  • Constant-time compare, hand-rolled. Not 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.
  • Headers from anything. A Headers instance, a plain object, or Node's array-valued form, read case-insensitively, because every server hands them over differently.
  • Off when unset. An existing integration keeps working. The README says plainly what that costs, and the example prints verify=OFF, set VAPI_WEBHOOK_SECRET at 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, the PATCH that sets the Server URL and the secret together, and real log output. It answers 401 on a bad secret.

Verified against the running example

forged POST, no secret header  -> 401
wrong secret                   -> 401
correct secret                 -> 200

npm run check passes, 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

.gitignore gained examples/*/.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

  • backend receiver, the no-code counterpart: deeptrust-ai/deeptrust#3709
  • Python SDK equivalent: to follow

🤖 Generated with Claude Code


Devin Review

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>
@linear-code

linear-code Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
ENG-3544 The SDK VAPI bridges accept any POST, so a forged transcript is trusted

What is missing

Neither bridge verifies anything about an inbound webhook. The only authorization in the TypeScript bridge is the outbound Bearer to api.vapi.ai; the Python bridge has no match at all. The example harness accepts any POST, which is why VAPI's Webhook Server settings read No authentication in testing.

So a customer following our example ships a public endpoint where anyone who learns the URL can POST a forged transcript event. That creates a real call, a real transcript and a real analysis in their DeepTrust organization, and can also be used to suppress a real one by ending the session early with a forged end-of-call-report.

The mechanism already exists

VAPI sends X-Vapi-Secret when server.secret is set, and supports custom credential headers, which is the Authorization section in the assistant's Webhook Server settings.

What to add

  • a secret option on the bridge in both SDKs
  • verification in the handler, constant-time compare, refuse before any turn is appended
  • the example receivers reading it from the environment and returning 401 on a mismatch
  • the README saying to set it, since a customer who does not read that ships the open endpoint

Related: ENG-3543 (the DeepTrust-side receiver, where the org API key plays the same role)

Review in Linear

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Devin Review

Comment thread src/agents/vapi.ts
Comment on lines +263 to +267
verify(headers: HeadersLike | undefined): boolean {
if (!this.secret) {
return true;
}
return timingSafeEqual(readHeader(headers, SECRET_HEADER), this.secret);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Empty secret disables webhook verification

An empty configured secret makes verify accept every request. Missing environment configuration silently exposes transcript ingestion without authentication.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +48 to +52
const SECRET = process.env.VAPI_WEBHOOK_SECRET ?? "";

const bridge = new Bridge(new DeepTrust(), {
apiKey: process.env.VAPI_API_KEY ?? "",
...(SECRET ? { secret: SECRET } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Example webhook fails open without secret

Without VAPI_WEBHOOK_SECRET, the example starts and accepts every webhook. An incomplete deployment exposes transcript and end-call ingestion without authentication.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@buildbyjithu
buildbyjithu merged commit 41123ef into devin/1789241109-vapi-adapter Sep 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants