The Stack
Every voice-agent SaaS I've built uses the same four pieces:
- Next.js — the app layer, API routes, and dashboard
- Twilio — phone numbers, call routing, SMS
- Vapi — the conversation engine (STT → LLM → TTS)
- Stripe — billing, subscriptions, usage records
That's it. No Kubernetes. No microservices. No event sourcing. Just four services wired together properly.
Here's how they connect.
Architecture Overview
Caller → Twilio Number → Your Next.js API → Vapi Assistant
↓
Stripe (billing)
The flow:
- Someone calls a Twilio number
- Twilio POSTs to your
/api/twilio/voiceendpoint - Your app looks up which tenant owns that number
- Your app returns TwiML that connects the call to a Vapi assistant
- Vapi handles the conversation (transcribe → LLM → speak)
- When the call ends, Twilio POSTs to
/api/twilio/call-status - Your app bills the call duration to Stripe as a usage record
That's the entire production loop. Everything else is UI and database.
Next.js: The App Layer
I use Next.js because it gives me API routes and a React frontend in one codebase. For voice-agent SaaS, you need:
- API routes for Twilio webhooks (must be public, fast, idempotent)
- API routes for Vapi webhooks (assistant events, transcript completion)
- Dashboard for tenants to manage their assistant, view calls, see billing
- Auth (I use NextAuth.js with Prisma)
Key files:
app/
api/
twilio/
voice/route.ts # Incoming call handler
call-status/route.ts # Post-call billing
vapi/
webhook/route.ts # Assistant events
stripe/
webhook/route.ts # Subscription events
dashboard/
page.tsx # Tenant dashboard
calls/page.tsx # Call history
billing/page.tsx # Usage + invoices
Twilio: Phone Numbers and Routing
Twilio handles the telephony layer. You buy numbers through their API, configure webhooks, and they route calls to your app.
Buying a number:
const number = await twilio.incomingPhoneNumbers.create({
phoneNumber: availableNumber,
voiceUrl: `${APP_URL}/api/twilio/voice`,
voiceMethod: "POST",
smsUrl: `${APP_URL}/api/twilio/sms`,
smsMethod: "POST",
});
Handling an incoming call:
export async function POST(req: Request) {
const formData = await req.formData();
const to = formData.get("To") as string;
const phoneNumber = await db.phoneNumber.findUnique({
where: { e164: to },
include: { tenant: true },
});
return new Response(
`<Response>
<Connect>
<Stream url="wss://api.vapi.ai/call/${phoneNumber.vapiAssistantId}">
<Parameter name="tenantId" value="${phoneNumber.tenantId}" />
</Stream>
</Connect>
</Response>`,
{ headers: { "Content-Type": "text/xml" } }
);
}
The <Stream> element connects the call to Vapi via WebSocket. Vapi handles the conversation and returns audio back to Twilio, which plays it to the caller.
Vapi: The Conversation Engine
Vapi abstracts the STT → LLM → TTS loop. You create an assistant, configure its prompt and voice, and Vapi handles the real-time conversation.
Creating an assistant:
const assistant = await vapi.assistants.create({
name: `${tenant.businessName} Assistant`,
model: {
provider: "openai",
model: "gpt-4o",
systemPrompt: `You are a helpful assistant for ${tenant.businessName}. `,
},
voice: {
provider: "11labs",
voiceId: tenant.voiceId || "rachel",
},
});
Vapi also supports function calling. You define tools the assistant can use — "book appointment," "check order status," "transfer to human" — and Vapi will invoke them via webhook when the conversation calls for it.
const assistant = await vapi.assistants.create({
// ... other config
functions: [
{
name: "bookAppointment",
description: "Book an appointment for the caller",
parameters: {
type: "object",
properties: {
date: { type: "string", description: "ISO date" },
time: { type: "string", description: "HH:MM" },
},
required: ["date", "time"],
},
},
],
});
When the assistant calls bookAppointment, Vapi POSTs to your /api/vapi/webhook endpoint with the function name and arguments. You execute the booking and return the result. Vapi continues the conversation.
Stripe: Billing
Stripe handles subscriptions and metered usage. Each tenant gets a Stripe customer and a subscription with a metered price.
Creating a subscription:
const customer = await stripe.customers.create({
email: tenant.email,
metadata: { tenantId: tenant.id },
});
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: METERED_PRICE_ID }],
});
await db.tenant.update({
where: { id: tenant.id },
data: {
stripeCustomerId: customer.id,
stripeSubscriptionItemId: subscription.items.data[0].id,
},
});
Billing after a call:
const billableMinutes = Math.ceil(durationSeconds / 60);
await stripe.subscriptionItems.createUsageRecord(
tenant.stripeSubscriptionItemId,
{
quantity: billableMinutes,
timestamp: Math.floor(Date.now() / 1000),
action: "increment",
}
);
Stripe automatically invoices the tenant at the end of the billing period based on total usage.
Database Schema (Prisma)
Here's the minimal schema you need:
model Tenant {
id String @id @default(cuid())
email String @unique
businessName String
status String // active, trialing, suspended
stripeCustomerId String?
stripeSubscriptionItemId String?
phoneNumbers PhoneNumber[]
calls Call[]
}
model PhoneNumber {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
e164 String @unique
twilioSid String
vapiAssistantId String
status String // active, inactive
}
model Call {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
twilioCallSid String @unique
duration Int? // seconds
billingStatus String // pending, billed
billedAt DateTime?
createdAt DateTime @default(now())
}
That's it. Three tables. You can add more later (users, roles, transcripts, recordings), but this is enough to handle calls, routing, and billing.
Deploying
I deploy on Cloudflare Pages (Next.js with output: "export" for static) or Vercel (for serverless functions). The webhook endpoints need to be publicly accessible, so you need a real domain — localhost won't work for Twilio webhooks.
For production:
- Buy a domain (I use Cloudflare Registrar)
- Deploy to Cloudflare Pages or Vercel
- Add your production URL to Twilio webhook configs
- Configure Stripe webhook endpoint for subscription events
- Set up environment variables (Twilio, Vapi, Stripe keys)
What This Doesn't Include
This stack gets you to working voice-agent SaaS. It doesn't include:
- Multi-tenant auth (NextAuth + Prisma handles basic auth, but tenant isolation needs middleware)
- Compliance (GDPR, opt-outs, retention — see my previous article)
- Error handling (webhook retries, failed calls, billing discrepancies)
- Observability (logging, alerting, health checks)
- Warm number pools (buying numbers on-demand is slow)
Those are all solvable. But they're not one-afternoon projects.
The Shortcut
If you want the full stack — including auth, compliance, error handling, and warm number pools — without building it yourself, Callforge packages everything above into a deployable boilerplate.
Waitlist: callforge.dev
Or build it yourself. The code isn't complicated. It's just a lot of it.
What's your voice-agent stack? Same four pieces, or something different?
Top comments (0)