Convex Vapi
Place outbound Vapi voice AI calls from Convex actions, and watch status, transcript, summary, and cost update reactively.
git clone https://github.com/sholajegede/convex-vapi.git
cd convex-vapiThis upstream package has not been adapted or qualified for Bijection. Read the original documentation before adopting it.
Description
Place outbound Vapi voice AI calls from Convex actions, and watch status, transcript, summary, and cost update reactively.
Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- Exercise the component in your deployment according to your project’s verification rules.
Open source
Maintained source: sholajegede/convex-vapi. License: See repository. Consult the repository for license terms, notices, and contributor attribution.
From the upstream publisher
Sync Vapi voice AI calls into Convex reactively, and place outbound calls from Convex functions.
Explore the demo ↗Upstream overview
From the upstream package documentation.
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.
Use cases
From the upstream package documentation.
- Building a voice AI call dashboard that needs live status updates (queued, ringing, in-progress, ended) without polling Vapi's REST API
- Triggering outbound phone calls from a Convex action and immediately showing call state in a React UI via
useQuerybefore any webhook has fired - Auditing or replaying webhook deliveries by querying the raw
webhookEventstable for every payload received per call - Recovering from missed webhook deliveries by calling
refreshCallto pull the latest transcript, summary, and recording directly from the Vapi API on demand - Aggregating call metrics across an entire deployment using
getStatsto count live, ended, and total calls without joining across your own app tables
How it works
From the upstream package documentation.
The component is registered in convex/convex.config.ts using app.use(convexVapi), which provisions isolated calls and webhookEvents tables inside the component's own schema namespace, separate from your app's schema. A Vapi client instance is initialized with your Vapi API key and webhook secret, then used to mount vapi.webhookHandler as an HTTP route in convex/http.ts. This handler receives status-update and end-of-call-report events from Vapi, verifies the X-Vapi-Secret header, deduplicates by SHA-256 hash of the raw body, and patches the corresponding row in the calls table using a merge strategy that never overwrites existing non-null fields.
Placing a call uses vapi.createCall(ctx, { assistantId, phoneNumberId, customerNumber }) from a Convex action, which calls the Vapi REST API and writes the initial call record before any webhook arrives, returning { callId, status } immediately. Queries like getCall, listCallsByAssistant, and listRecentCalls can be wrapped in your own Convex query functions and consumed from React via useQuery, which re-renders reactively as each webhook patches the underlying row. The refreshCall action provides a manual fallback to re-fetch a call directly from Vapi's API, useful when webhook delivery is delayed or missed.
Upstream package FAQ
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.
Use with agents and CLI
Give your coding agent the component’s source identity and setup guidance.
Help me integrate Convex Vapi with Bijection.
Read: https://bijection.com/components/convex-vapi/SKILL.md
Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.Upstream README.md
View repository ↗Documentation from sholajegede/convex-vapi, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.
What this does
convex-vapi gives your Convex app a live, queryable record of Vapi voice AI
calls, kept up to date by Vapi's server-URL webhooks, plus a small set of
actions for placing and inspecting calls:
- Reactive call tracking — every
status-updateandend-of-call-reportwebhook event updates a Convex row, souseQueryin your React app re-renders as a call rings, connects, and ends, complete with transcript, summary, and recording once available. - Place outbound calls — call
createCallfrom a Convex action to start a phone call through a Vapi assistant. - Non-destructive merges — a sparser event (e.g. a mid-call status update) never overwrites richer data an earlier event already recorded (e.g. a transcript from the final report).
- Secret-verified webhooks — every inbound webhook is checked against Vapi's
X-Vapi-Secretheader with a constant-time comparison before anything is written.
This is a Convex component: its calls and
webhookEvents tables live in an isolated schema, not your app's schema, and
are only reachable through the functions this component exposes.
Table of Contents
- Install
- Quick Start
- Setup
- Usage
- API Reference
- Type Reference
- Webhook Events
- Database Schema
- Authentication
- Example App
- Testing
- Limitations
- Troubleshooting
- Contributing
- Changelog
Install
npm install convex-vapi
Quick Start
1. Add the component
// convex/convex.config.ts
import { defineApp } from "convex/server";
import convexVapi from "convex-vapi/convex.config";
const app = defineApp();
app.use(convexVapi);
export default app;
2. Set environment variables
npx convex env set VAPI_API_KEY your-private-api-key
npx convex env set VAPI_WEBHOOK_SECRET whsec_...
VAPI_API_KEY is your private key from the Vapi dashboard (Settings → API
Keys). VAPI_WEBHOOK_SECRET is a secret string you choose yourself — you'll
configure the same value on the assistant in step 4.
3. Mount the webhook handler
// convex/http.ts
import { httpRouter } from "convex/server";
import { components } from "./_generated/api";
import { Vapi } from "convex-vapi";
const vapi = new Vapi(components.convexVapi, {
apiKey: process.env.VAPI_API_KEY!,
webhookSecret: process.env.VAPI_WEBHOOK_SECRET!,
});
const http = httpRouter();
http.route({
path: "/webhooks/vapi",
method: "POST",
handler: vapi.webhookHandler,
});
export default http;
4. Configure the webhook on your assistant
Set the assistant's serverUrl to
https://<your-deployment>.convex.site/webhooks/vapi and its server.secret to
the same value as VAPI_WEBHOOK_SECRET, either in the Vapi dashboard or via the
API:
curl -X PATCH https://api.vapi.ai/assistant/<assistant-id> \
-H "Authorization: Bearer $VAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"serverUrl": "https://<your-deployment>.convex.site/webhooks/vapi", "server": {"secret": "whsec_..."}}'
5. Initialize the client
// convex/example.ts
import { action, query } from "./_generated/server";
import { components } from "./_generated/api";
import { Vapi } from "convex-vapi";
import { v } from "convex/values";
const vapi = new Vapi(components.convexVapi, {
apiKey: process.env.VAPI_API_KEY!,
webhookSecret: process.env.VAPI_WEBHOOK_SECRET!,
});
export const listCallsByAssistant = query({
args: { assistantId: v.string() },
handler: async (ctx, args) => {
return await vapi.listCallsByAssistant(ctx, args);
},
});
Setup
The component needs no schema changes in your app — its tables (calls,
webhookEvents) live entirely inside the component's own isolated schema. All
you need is the webhook mounted (step 3) and secret configured (step 4), plus a
Vapi client instance wherever you call its methods.
Assistants and phone numbers are managed entirely in the Vapi dashboard or API — this component tracks calls, not the assistants that make them.
Usage
Place an outbound call
export const dial = action({
args: {
assistantId: v.string(),
phoneNumberId: v.string(),
customerNumber: v.string(),
},
handler: async (ctx, args) => {
return await vapi.createCall(ctx, args);
},
});
Returns { callId, status } and immediately records the call in Convex — you
don't have to wait for the first webhook to see it in a query.
Refresh a call on demand
export const sync = action({
args: { callId: v.string() },
handler: async (ctx, args) => {
await vapi.refreshCall(ctx, args);
return null;
},
});
Fetches the call directly from the Vapi API and re-records it — useful as a fallback if a webhook delivery was missed, or to pull in the transcript/recording immediately after a call ends without waiting on the webhook queue.
Read calls reactively
const calls = useQuery(api.example.listCallsByAssistant, {
assistantId: "assistant_...",
});
Every status-update and end-of-call-report webhook event patches a row, so
this query re-renders live as a call progresses from queued through ringing,
in-progress, and ended — no polling.
API Reference
Actions (need ctx from an action)
| Method | Description |
|---|---|
createCall(ctx, { assistantId, phoneNumberId, customerNumber }) | Places an outbound call via the REST API and records it. Returns { callId, status }. |
refreshCall(ctx, { callId }) | Re-fetches a call from the Vapi API and re-records its current state, including transcript/summary/recording if the call has ended. |
Queries (work from actions, queries, or mutations)
| Method | Description |
|---|---|
getCall(ctx, { callId }) | Fetch one call by its Vapi call id. |
listCallsByAssistant(ctx, { assistantId, limit? }) | Most recently updated calls for an assistant, newest first. |
getStats(ctx) | Aggregate counts across every call and webhook event this deployment has recorded: { callCount, endedCount, liveCount, webhookEventCount }. |
listRecentCalls(ctx, { limit? }) | Most recently updated calls across every assistant, newest first. |
listRecentWebhookEvents(ctx, { limit? }) | Most recent webhook deliveries across every call, newest first. |
Webhook
| Property | Description |
|---|---|
webhookHandler | An httpAction that verifies, deduplicates, and processes status-update and end-of-call-report webhook deliveries. Mount it at any route. |
Type Reference
type VapiOptions = {
apiKey: string; // private API key from the Vapi dashboard
webhookSecret: string; // matches the assistant's server.secret
};
type CreateCallArgs = {
assistantId: string;
phoneNumberId: string;
customerNumber: string; // E.164 format, e.g. "+11231231234"
};
type Call = {
callId: string;
assistantId?: string;
phoneNumberId?: string;
customerNumber?: string;
status: string; // "queued" | "ringing" | "in-progress" | "forwarding" | "ended" | ...
endedReason?: string;
transcript?: string;
summary?: string;
recordingUrl?: string;
cost?: number;
startedAt?: number;
endedAt?: number;
createdAt: number;
updatedAt: number;
};
Webhook Events
The webhook handler processes two of Vapi's server message types (all others are recorded for idempotency but otherwise ignored — see Limitations):
status-update— updates the call'sstatusfield (queued,ringing,in-progress,forwarding,ended, ...) as the call progresses.end-of-call-report— the final summary sent once a call ends; recordsendedReason,transcript,summary,recordingUrl, andcost.
Every request is checked against the X-Vapi-Secret header with a constant-time
comparison against your configured webhookSecret before anything is written.
Vapi does not include a per-delivery id in its webhook headers, so duplicate
detection here hashes the raw request body with SHA-256 — an identical payload
delivered twice is treated as a duplicate and skipped.
Calls are recorded with a merge, not a blind overwrite: fields missing from the
current event (e.g. transcript on a mid-call status-update) fall back to
whatever was already stored, so a later, sparser event can never blank out data
an earlier, richer one recorded.
Database Schema
calls: {
callId: string; // indexed: by_callId
assistantId?: string; // indexed: by_assistantId
phoneNumberId?: string;
customerNumber?: string;
status: string;
endedReason?: string;
transcript?: string;
summary?: string;
recordingUrl?: string;
cost?: number;
startedAt?: number;
endedAt?: number;
createdAt: number;
updatedAt: number;
}
webhookEvents: {
eventId: string; // indexed: by_eventId — SHA-256 hex of the raw payload
eventType: string; // message.type
callId?: string;
payload: string; // raw JSON body, for auditing/replay
receivedAt: number;
}
This schema lives entirely inside the component's isolated namespace — it will
never collide with tables in your app's own convex/schema.ts.
Authentication
Vapi supports several ways to authenticate outgoing webhook requests. This
component implements the original and simplest one: a shared secret you set on
the assistant's server.secret field, which Vapi echoes back verbatim in the
X-Vapi-Secret header on every request — verified here with a constant-time
string comparison. Vapi's newer configurable-HMAC system (a separate Custom
Credential resource with a selectable algorithm and signature header) is not
implemented, since it requires provisioning that Credential outside of the
assistant's own configuration; if you need it, verify the signature yourself
before the request reaches this component's webhookHandler.
Example App
The example/ app is a full interactive demo, not just a form:
- Calls — place a call (with a live-updating list for that assistant, reactively reflected the instant the action call returns — no waiting on the webhook), and a stats bar showing total/live/ended calls plus webhook deliveries, visible from every tab.
- Webhooks — a live, expandable feed of every raw delivery to
/webhooks/vapi, so you can watch signature verification and deduplication happen in real time. - History — every call this component has ever recorded, newest first, across every assistant, with status, cost, duration, and an expandable transcript.
- A sidebar Activity console logging every action call this demo makes, with its result or error.
Run it from the repo root (not example/):
npm install --legacy-peer-deps
npx convex env set VAPI_API_KEY your-private-api-key
npx convex env set VAPI_WEBHOOK_SECRET whsec_...
npm run dev
Then point a test assistant's serverUrl at
https://<your-dev-deployment>.convex.site/webhooks/vapi (see
Quick Start) to see live deliveries land in the Webhooks tab.
Testing
npm run test
npm run typecheck
Tests use convex-test and cover
recordCall's merge-on-upsert behavior (confirming a sparser later event never
blanks a richer earlier one, and that a late, out-of-order status-update can
never regress a call back out of ended), listCallsByAssistant scoping by
assistant, webhook idempotency via checkAndRecordEvent, and the dashboard
queries (getStats, listRecentCalls, listRecentWebhookEvents) used by the
example app.
Limitations
- Only
status-updateandend-of-call-reportmessage types update thecallstable.transcript(partial live captions),conversation-update,speech-update,tool-calls, and other real-time message types are accepted (and recorded inwebhookEventsfor auditing) but not persisted tocalls— storing every partial transcript chunk would be high-volume and is better handled with your own streaming UI if you need live captions. - Assistant and phone number management (creating, updating, or listing assistants) is out of scope — this component tracks calls, not the resources that place them.
- Live in-call control (e.g. programmatically ending an active call, or
transferring it) is not implemented; only
createCall(start) andrefreshCall(poll) are provided. - Rate limits are Vapi's own — this component does not implement its own rate limiting or backoff.
Troubleshooting
Webhook returns 401 — the X-Vapi-Secret header didn't match. Confirm the
assistant's server.secret field is set to exactly the same value as
VAPI_WEBHOOK_SECRET (a PATCH /assistant/{id} call, or the dashboard's
assistant settings, both work).
Webhook returns 400 "Missing X-Vapi-Secret header" — the assistant's
serverUrl is set but server.secret isn't configured, so Vapi isn't sending
the header at all.
Calls never appear in queries — confirm the assistant's serverUrl points
at your deployment's .convex.site domain (not .convex.cloud), and that it's
reachable publicly (Convex HTTP actions are public by default, so this is
usually a typo in the URL).
Transcript/summary are missing after a call ends — these only arrive with
the end-of-call-report message, sent shortly after the call ends; call
refreshCall if you need them sooner than the webhook arrives, or if a delivery
was dropped.
Contributing
See CONTRIBUTING.md.
Changelog
See CHANGELOG.md.