Skip to content

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

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

buildbyjithu merged 1 commit into
mainfrom
jithu/vapi-webhook-secret

Conversation

@buildbyjithu

@buildbyjithu buildbyjithu commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Closes ENG-3544 for Python. Stacked on #4, which adds the bridge this verifies. Mirrors deeptrust-ai/deeptrust-typescript#3.

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

bridge = Bridge(
    DeepTrust(),
    api_key=os.environ["VAPI_API_KEY"],
    secret=os.environ["VAPI_WEBHOOK_SECRET"],
)

@app.post("/vapi/webhook")
async def vapi_webhook(request: Request, payload: dict):
    try:
        await bridge.handle(payload, user=caller, headers=request.headers)
    except WebhookVerificationError:
        raise HTTPException(status_code=401)
    return {}

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

Choices

  • hmac.compare_digest for the compare, so a caller cannot learn the secret one character at a time from how long the refusal took.
  • The header is read case-insensitively. Starlette hands over a case-insensitive mapping, but a plain dict is a reasonable thing for a caller to pass and it should not silently fail to match.
  • Verification is off when no secret is configured, so an existing integration keeps working. The README and the module docstring both say what that costs rather than leaving it implied.

Testing

just check clean: ruff, ruff format, mypy, 42 tests passing, 5 of them new.

The new ones cover a request without the secret refused with nothing recorded (no analyze call, no session), a wrong secret refused, the right secret accepted and returning an analysis, no configured secret keeping existing behaviour, and the header read case-insensitively.

Parity note

The TypeScript version hand-rolls its constant-time compare because that module has to run on Node, Bun, Deno and the edge, where crypto.timingSafeEqual is Node-only. Python has hmac.compare_digest in the standard library, so this uses it. Same behaviour, same refusal, same header.

🤖 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` argument 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.

hmac.compare_digest for the compare. The header is read case-insensitively,
since Starlette hands over a case-insensitive mapping and a plain dict is also
a reasonable thing for a caller to pass.

Verification is off when no secret is configured, so an existing integration
keeps working, and the README and module docstring both say what that costs.

Mirrors deeptrust-ai/deeptrust-typescript#3, so the two SDKs refuse the same
request for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

1 bug not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

dt: DeepTrust,
*,
api_key: str,
secret: str | None = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟨 Webhook authentication defaults to disabled

Omitting or emptying secret makes verify accept every request. Misconfigured deployments remain open to forged transcripts and premature call termination.

Devin Review

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

@buildbyjithu
buildbyjithu changed the base branch from devin/1789240942-vapi-bridge to main September 15, 2026 18:10
@buildbyjithu
buildbyjithu merged commit 08d2310 into main Sep 15, 2026
4 checks 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