VaaniYantra — Developer Guide
This guide covers building on VaaniYantra (the public REST API and webhooks) and working in the codebase (architecture, local setup, deployment).
- End-user docs: User Guide
- Base URL:
https://vaaniyantra.com
Table of contents
Integrating with the API
Working on the codebase 4. Architecture overview 5. Tech stack 6. Local development 7. Project structure 8. Telephony & the voice pipeline 9. Deployment
Integrating with the API
1. Authentication
Create an API key in Settings → API keys. Keys look like ck_live_… and are
shown once — store it securely (it's hashed at rest, so it can't be
retrieved later).
Send it on every request as a Bearer token or an x-api-key header:
curl https://vaaniyantra.com/api/v1/agents \
-H "Authorization: Bearer ck_live_xxxxxxxxxxxxxxxx"
# equivalently:
curl https://vaaniyantra.com/api/v1/agents \
-H "x-api-key: ck_live_xxxxxxxxxxxxxxxx"
- All endpoints are scoped to the organization that owns the key.
- Missing/invalid/revoked/expired keys return
401{"error":"Invalid or missing API key"}. - Revoke a key anytime from Settings → API keys.
2. REST API reference
Base path: https://vaaniyantra.com/api/v1. All responses are JSON.
List endpoints accept ?limit= (default 25, max 100) and ?offset=.
Rate limits: 120 requests/minute per API key (plus a per-IP cap on
authentication attempts). Exceeding it returns 429 with a Retry-After
header — back off and retry after that many seconds.
Agents
GET /api/v1/agents — list agents.
{ "agents": [
{ "id": "…", "name": "Hindi Helpdesk", "language": "hi-IN",
"voice": "Aoede", "status": "ACTIVE", "direction": "BOTH" }
]}
GET /api/v1/agents/:id — one agent (adds greeting, createdAt).
{ "agent": { "id": "…", "name": "…", "language": "en-IN", "voice": "…",
"status": "ACTIVE", "direction": "BOTH", "greeting": "Hello!…",
"createdAt": "2026-07-10T…" } }
Calls
GET /api/v1/calls — list calls.
Query filters: direction (INBOUND|OUTBOUND), status
(COMPLETED|FAILED|NO_ANSWER|…), agentId, plus limit/offset.
{ "calls": [ { …call… } ], "total": 128, "limit": 25, "offset": 0 }
GET /api/v1/calls/:id — one call with full transcript.
{ "call": {
"id": "…", "direction": "INBOUND", "status": "COMPLETED",
"fromNumber": "+9198…", "toNumber": "+9180…", "durationSec": 92,
"language": "en-IN", "agent": { "id": "…", "name": "…" },
"summary": "Caller asked about pricing and booked a follow-up.",
"sentiment": "POSITIVE", "sentimentScore": 0.6,
"topics": ["pricing","follow-up"],
"collectedData": { "name": "Alex", "preferred_time": "Tomorrow 3pm" },
"recordingUrl": "/api/calls/…/recording",
"createdAt": "…", "startedAt": "…", "endedAt": "…",
"transcript": [ { "role": "AGENT", "text": "Hello!…", "atMs": 300 },
{ "role": "CALLER", "text": "Hi…", "atMs": 2100 } ]
}}
POST /api/v1/calls — place an outbound call.
curl -X POST https://vaaniyantra.com/api/v1/calls \
-H "Authorization: Bearer ck_live_…" \
-H "Content-Type: application/json" \
-d '{ "agentId": "AGENT_ID", "to": "+919876543210", "fromNumberId": "NUMBER_ID" }'
| Field | Required | Notes |
|---|---|---|
agentId | yes | An agent in your org. |
to | yes | Destination in E.164. |
fromNumberId | no | Which of your numbers to call from (else a default caller ID). |
Returns 201 with the created call object. Errors: 400 (bad input),
403 (monthly call-minute limit reached), 404 (agent not found),
502 (provider failed). Outbound is subject to your plan's call-minute limit.
Numbers
GET /api/v1/numbers — list your phone numbers.
{ "numbers": [
{ "id": "…", "e164": "+916624394745", "label": "Main line",
"provider": "twilio", "agentId": "…", "inboundEnabled": true,
"webhookConfigured": true, "createdAt": "…" }
]}
Appointments
GET /api/v1/appointments — list appointments.
Query filters: status (CONFIRMED|CANCELLED), upcoming=true, limit/offset.
{ "appointments": [
{ "id": "…", "customerName": "Alex", "customerNumber": "+9198…",
"service": "consultation", "expertName": "Dr. Rao",
"startAt": "…", "endAt": "…", "timezone": "Asia/Kolkata",
"status": "CONFIRMED", "channel": "whatsapp", "agentId": "…", "createdAt": "…" }
], "total": 12, "limit": 25, "offset": 0 }
3. Webhooks
Register endpoints in Settings → Webhooks. When a subscribed event fires,
we POST a JSON payload to your URL.
Events
| Event | When |
|---|---|
call.completed | A call finished successfully (includes summary, sentiment, collected data). |
call.failed | A call ended without connecting (failed, busy, no answer). |
Delivery & headers
- Method:
POST,Content-Type: application/json. X-VaaniYantra-Event: call.completedX-VaaniYantra-Signature: sha256=<hex HMAC-SHA256 of the raw body>- Redirects are not followed, and endpoints must resolve to a public address (internal/loopback addresses are blocked). 6-second timeout.
- Each webhook has a signing secret (
whsec_…) shown in the UI. Delivery outcome (last status, last error) is recorded on the webhook. - Slack webhooks (type
slack) receive a Block Kit message instead and are not HMAC-signed.
Payload
{
"event": "call.completed",
"timestamp": "2026-07-10T12:00:00.000Z",
"data": {
"id": "call_…", "direction": "INBOUND", "status": "COMPLETED",
"fromNumber": "+9198…", "toNumber": "+9180…", "durationSec": 42,
"language": "en-IN", "agent": { "id": "…", "name": "…" },
"summary": "…", "sentiment": "POSITIVE", "sentimentScore": 0.6,
"topics": ["pricing"], "collectedData": { "name": "Alex" },
"recordingUrl": "https://vaaniyantra.com/api/calls/…/recording",
"createdAt": "…", "endedAt": "…"
}
}
Verifying the signature (Node.js)
import crypto from 'crypto';
function verify(rawBody, signatureHeader, secret) {
const expected =
'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
}
// Express example — use the RAW body, not the parsed object:
app.post('/hook', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verify(req.body, req.get('X-VaaniYantra-Signature'), process.env.WEBHOOK_SECRET);
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString());
// … handle event …
res.sendStatus(200);
});
Use the "Send test" button in the UI to deliver a sample payload and confirm your endpoint verifies it.
Working on the codebase
4. Architecture overview
Two Node processes back the platform:
vaani-web— the Next.js 14 app (App Router): dashboard UI + all/api/*routes. Port3000.vaani-voice— the realtime media server (voice-server/index.ts): the WebSocket endpoint telephony providers stream call audio to, bridged to the AI in real time. Port8080.
Caller ─▶ Twilio / Plivo / Exotel ─▶ (webhook/applet) ─▶ voice-server WS (:8080)
│ audio ⇄ Gemini Live
▼
PostgreSQL ◀─▶ Next.js app (:3000)
- Auth: Firebase Authentication (client SDK + Admin SDK for session cookies).
- Data: PostgreSQL via Prisma.
- Voice AI: Gemini Live (native speech-to-speech) is the default realtime path; a cascade path (Deepgram STT → LLM → TTS) exists as a fallback.
- Telephony: provider-agnostic adapters (Twilio, Plivo, Exotel, mock).
5. Tech stack
| Area | Choice |
|---|---|
| Framework | Next.js 14 (App Router), React 18, TypeScript |
| Styling | Tailwind CSS |
| DB / ORM | PostgreSQL + Prisma |
| Auth | Firebase Auth (firebase, firebase-admin) |
| Realtime voice | @google/genai (Gemini Live), ws |
| STT / TTS | Deepgram, Google Cloud Speech/TTS, ElevenLabs (optional) |
| Telephony | Twilio REST, Plivo REST, Exotel (Voicebot streaming) |
| Payments | Cashfree — PG orders + Subscriptions (REST; JS SDK for checkout only) |
| Validation | Zod |
6. Local development
Prerequisites: Node 20+, a PostgreSQL database.
# 1. Install
npm install
# 2. Configure env
cp .env.example .env # then fill in real values (see below)
# 3. Set up the database
npm run db:push # apply the Prisma schema
npm run db:seed # optional: seed demo data
# 4. Run (two processes)
npm run dev # Next.js app on :3000
npm run voice-server # realtime voice server on :8080
Mock mode: with USE_MOCK_TELEPHONY/SPEECH/LLM="true" the app runs fully
offline with deterministic mock providers — no external keys needed. Flip them to
"false" and add real keys to go live.
Key environment variables (see .env.example for the full list):
| Var | Purpose |
|---|---|
DATABASE_URL | Postgres connection string |
APP_BASE_URL / PUBLIC_BASE_URL | Public URLs used to build webhook URLs |
VOICE_WS_URL / VOICE_WS_PORT | Realtime voice-server WS URL / port |
GEMINI_API_KEY | Gemini (LLM + Live voice) |
GOOGLE_CLOUD_API_KEY | Google Speech-to-Text / Text-to-Speech |
TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_CALLER_ID | Platform Twilio (fallback only) |
NEXT_PUBLIC_FIREBASE_* | Firebase client config (public) |
FIREBASE_* (admin) | Firebase Admin credentials (server) |
GOOGLE_OAUTH_CLIENT_ID / _SECRET | Google Calendar connector |
DEEPGRAM_API_KEY, ELEVENLABS_API_KEY | Optional STT/TTS providers |
SUPERADMIN_EMAILS | Comma-separated founder emails for /admin |
There are deliberately no Plivo or Exotel env vars: those are bring-your-own
only. A workspace connects its own credentials in the UI and they are stored
encrypted per-account (TwilioAccount / PlivoAccount / ExotelAccount), which
is also the preferred path for Twilio — the env vars above are a platform-level
fallback for workspaces with no connected account.
⚠️ Never commit real secrets.
.env,Exotel.env,*.db, and build output are git-ignored..env.examplemust contain placeholders only.
Useful scripts: npm run build, npm run start, npm run lint,
npm run db:studio (Prisma Studio), npm run db:generate.
7. Project structure
src/
app/ Next.js App Router
(app)/… dashboard pages (agents, numbers, calls, settings…)
(auth)/… sign-in / sign-up
api/ REST endpoints
v1/ ← public API (agents, calls, numbers, appointments)
telephony/ Twilio + Exotel webhooks / stream URL
(Plivo's are served by voice-server, not Next)
webhooks/, keys/ outbound webhooks + API keys
admin/ founder-only platform config
components/ React UI (AgentForm, NumbersManager, PricingGrid, …)
lib/
billing.ts plan catalogue + limits/usage enforcement
api-keys.ts API-key generation + authentication
webhooks.ts signing, delivery, dispatch, SSRF guard
telephony-accounts.ts BYO Twilio/Plivo/Exotel account resolution
adapters/telephony/ TelephonyProvider: twilio.ts, plivo.ts, exotel.ts, mock.ts
voice/ codec.ts, live.ts (Gemini Live), deepgram.ts, tts.ts…
calls/place-outbound.ts shared outbound-call placement
connectors.ts, crm.ts Google/WhatsApp/HubSpot integrations
auth/ Firebase session, workspace context, RBAC
prisma/schema.prisma database schema
voice-server/index.ts realtime media server (WS :8080)
8. Telephony & the voice pipeline
Providers implement the TelephonyProvider interface
(src/lib/adapters/telephony/types.ts): provisionNumber, listAccountNumbers,
configureNumberWebhook, placeCall, buildAnswerDocument, redirectCall, plus
capability flags (canBuyNumbers, usesAnswerWebhook).
- Twilio — mulaw/8kHz audio over Media Streams; per-call TwiML; webhooks set automatically via the REST API on import.
- Plivo — the same mulaw/8kHz audio as Twilio but a different WebSocket
dialect (
playAudio/clearAudioinstead ofmedia/clear, andstreamIdinstead ofstreamSid); per-call answer XML; webhooks set automatically via the REST API on import. - Exotel — raw PCM16/8kHz audio over the Voicebot applet (configured once
in the Exotel dashboard, pointed at
wss://…/exotel); no per-call document, so it is the one provider that needs a manual setup step.
Because the three differ in wire format and dialect, the voice pipeline is
parameterised by a TelephonyCodec (src/lib/voice/codec.ts):
MULAW_CODEC (Twilio), PLIVO_CODEC (Plivo) and PCM16_CODEC (Exotel), chosen
by codecForProvider(). The voice-server upgrades the /twilio, /plivo and
/exotel WebSocket paths and picks the codec per connection; live.ts (Gemini
Live) and the Deepgram/TTS cascade both consume the codec, so no provider
hard-codes an audio format. Exotel additionally requires outbound chunks to be a
multiple of 320 bytes, which is why its frame size is 3200 rather than 160.
9. Deployment
Production runs on a GCP Compute Engine VM behind Caddy (auto-HTTPS),
with pm2 managing vaani-web and vaani-voice, and self-hosted
PostgreSQL on the box.
The deploy flow (no CI): build locally, tar the project (excluding
node_modules, .next, .git, .env, and secret files), gcloud compute scp
to the VM, extract, prisma db push if the schema changed, npm run build, then
pm2 restart. Caddy routes /twilio, /plivo, /exotel, /health, and the
Twilio and Plivo webhook paths to the voice-server (:8080); everything else to
Next.js (:3000).
vaaniyantra.com is the canonical host: a separate Caddy site block 301-redirects
www.vaaniyantra.com to the apex domain (path preserved) so search engines see a
single host. The previous config is kept at /etc/caddy/Caddyfile.bak on the VM.
Note: pushing to GitHub does not auto-deploy — production deploys are deliberate (tar + scp + build + restart).
10. Operations (health, backups, rate limits, headers)
Health checks — point an uptime monitor (UptimeRobot, GCP uptime check, …) at both:
GET /api/health(Next.js) — returns{ ok, db, uptimeSec }; 503 when Postgres is unreachable. Unauthenticated, no sensitive detail.GET /health(voice-server, routed by Caddy to:8080) — plainok.
Database backups — scripts/backup-db.sh does a pg_dump (custom format)
to ~/db-backups/, optionally uploads to a GCS bucket (GCS_BUCKET env), and
prunes local copies older than KEEP_LOCAL_DAYS (default 14). It reads
DATABASE_URL from the app's .env automatically. Install once on the VM:
chmod +x scripts/backup-db.sh
crontab -e # add:
17 2 * * * /path/to/app/scripts/backup-db.sh >> $HOME/db-backups/backup.log 2>&1
For off-VM safety, create the bucket once and set a 60-day lifecycle:
gsutil mb -l us-east1 gs://vaaniyantra-db-backups
gsutil lifecycle set scripts/gcs-backup-lifecycle.json gs://vaaniyantra-db-backups
Restore with pg_restore --clean --if-exists --dbname="$DATABASE_URL" <file>.dump.
This path is verified — scripts/restore-drill.sh restores the newest GCS dump
into a throwaway database and row-counts it against production, without touching
the live database. Recovery procedures, RPO/RTO and the known gaps live in
DISASTER_RECOVERY.md; read §0 before you need it —
.env (and so SECRET_ENC_KEY) is in no backup, and without it a perfect
database restore still leaves every encrypted secret unreadable.
API rate limiting — the public /api/v1/* routes are limited per key
(120/min) and per IP (240 auth attempts/min) by src/lib/rate-limit.ts via
requireApiAuth() in src/lib/api/v1.ts. The limiter is in-process (fine for
the single-VM pm2 deployment); if the app is ever scaled to multiple
processes/hosts, replace it with a shared store (Redis/Postgres).
Security headers — set globally in next.config.js (headers()): HSTS,
X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy,
and a Permissions-Policy disabling camera/mic/geolocation (the app uses none
of them in the browser). If a page ever needs to be embeddable or use the mic,
relax the relevant header there.
Questions or issues? See the User Guide for product behavior, or open an issue in the repository.