Convex Vapi

Sync Vapi voice AI call state into Convex reactively via webhooks, and place outbound calls from Convex actions with live status, transcript, and cost tracking.

Installation

npm install convex-vapi

About Convex Vapi

convex-vapi syncs Vapi voice AI call state into your Convex database via server-URL webhooks, enabling reactive queries on call status, transcript, summary, and cost without polling. It also exposes a `createCall` action for placing outbound calls directly from Convex, recording the call immediately before the first webhook arrives. Webhook payloads are verified with constant-time secret comparison and deduplicated via SHA-256 body hashing, with non-destructive merges ensuring richer data from earlier events is never overwritten by sparser later ones.

Benefits

Use cases

how to place outbound voice AI calls from Convex actions

convex-vapi exposes a createCall action that calls the Vapi REST API and immediately records the call in Convex. It returns { callId, status } so you can reference the call in queries before the first webhook arrives, without polling or waiting.

how to track Vapi call status reactively in a React app

convex-vapi mounts a webhook handler at any HTTP route in your Convex deployment. Each incoming status-update and end-of-call-report event patches the corresponding row in the component's calls table, so useQuery re-renders automatically as a call moves through queued, ringing, in-progress, and ended states.

how to handle Vapi webhooks in Convex with secret verification

The convex-vapi webhookHandler verifies every inbound request against the X-Vapi-Secret header using a constant-time string comparison before writing anything to the database. You configure a shared secret on both the Vapi assistant's server.secret field and as a Convex environment variable named VAPI_WEBHOOK_SECRET.

how to recover missed Vapi webhook deliveries in Convex

convex-vapi provides a refreshCall action that fetches the current call state directly from the Vapi API and re-records it, including transcript, summary, and recording URL if the call has ended. This works as a fallback for missed webhook deliveries without any manual database manipulation.

Frequently asked questions

Does convex-vapi require changes to my app's Convex schema?

No. convex-vapi is a Convex component, so its calls and webhookEvents tables live in an isolated namespace separate from your app's convex/schema.ts. You add it via app.use(convexVapi) in convex.config.ts and access its data only through the functions the component exposes.

What Vapi webhook event types does convex-vapi handle?

convex-vapi processes two event types: status-update, which updates the call's status field as the call progresses, and end-of-call-report, which records endedReason, transcript, summary, recordingUrl, and cost once a call ends. All other event types are recorded for idempotency but otherwise ignored.

How does convex-vapi prevent duplicate webhook deliveries from being applied twice?

Because Vapi does not include a per-delivery ID in its webhook headers, convex-vapi hashes the raw request body with SHA-256 and stores the result as an eventId in the webhookEvents table. An identical payload delivered twice is detected as a duplicate and skipped before any database write occurs.

Can a mid-call status-update webhook overwrite the transcript recorded by an end-of-call-report?

No. convex-vapi merges webhook payloads non-destructively: fields that are missing from the current event fall back to whatever was already stored in the database. A sparser event like a mid-call status-update will never blank out richer data like a transcript that an earlier end-of-call-report already recorded.

What is the minimum setup required to use convex-vapi?

You need to install the package with npm install convex-vapi, register the component in convex/convex.config.ts, set the VAPI_API_KEY and VAPI_WEBHOOK_SECRET environment variables, mount the webhookHandler on an HTTP route in convex/http.ts, and configure your Vapi assistant's serverUrl and server.secret to match. No schema changes to your app are needed.

Links