DEV Community

mattewens
mattewens

Posted on

The One-Click Deploy Voice Agent Stack

The Stack

Every voice-agent SaaS I've built uses the same four pieces:

  1. Next.js — the app layer, API routes, and dashboard
  2. Twilio — phone numbers, call routing, SMS
  3. Vapi — the conversation engine (STT → LLM → TTS)
  4. 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)
Enter fullscreen mode Exit fullscreen mode

The flow:

  1. Someone calls a Twilio number
  2. Twilio POSTs to your /api/twilio/voice endpoint
  3. Your app looks up which tenant owns that number
  4. Your app returns TwiML that connects the call to a Vapi assistant
  5. Vapi handles the conversation (transcribe → LLM → speak)
  6. When the call ends, Twilio POSTs to /api/twilio/call-status
  7. 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
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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" } }
  );
}
Enter fullscreen mode Exit fullscreen mode

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",
  },
});
Enter fullscreen mode Exit fullscreen mode

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"],
      },
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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",
  }
);
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Buy a domain (I use Cloudflare Registrar)
  2. Deploy to Cloudflare Pages or Vercel
  3. Add your production URL to Twilio webhook configs
  4. Configure Stripe webhook endpoint for subscription events
  5. 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)