DEV Community

mattewens
mattewens

Posted on

I Rebuilt My Vapi Backend Three Times - Here's What I Learned

I rebuilt my Vapi backend three times before I admitted the problem was not Vapi. The problem was the system around it.

The first version was a demo backend. It created an assistant, accepted a webhook, and stored a few call records. That was enough to impress myself and completely insufficient for a customer. The second version added auth and Stripe, but tenant boundaries were bolted on after the fact. The third version finally treated voice infrastructure like SaaS infrastructure.

That sounds obvious once you say it. It was not obvious when the prototype was working and I wanted to move fast.

The demo backend trap

A Vapi prototype can be small. You need an assistant config, a phone number, a webhook URL, and some glue. You can get something speaking to a caller in an afternoon.

The trap is that the prototype teaches you the wrong shape for the app. A single assistant becomes a global assistant. A single webhook handler becomes a pile of conditional logic. A single Stripe customer becomes a note in your database that you promise to clean up later.

Later usually means after the first real customer has already created data you cannot casually migrate.

The mistake I made was starting with calls. The better starting point is tenants.

Every object needs an owner

Voice SaaS gets messy when a call, phone number, assistant, tool, transcript, invoice item, and webhook event are not all tied back to the same account. You need that relationship before you need a clever prompt editor.

The architecture that held up looked like this:

User -> Tenant -> Assistant -> Phone Number -> Call -> Usage Event -> Invoice Item

Vapi sits in the middle of the runtime path, but your app owns the commercial and permission model. That distinction matters. Vapi can run the call. Your backend needs to decide who is allowed to edit the assistant, who pays for the minutes, and where the post-call data lands.

A simple route shape helped:

app.post('/api/tenants/:tenantId/assistants', requireUser, async (req, res) => {
  const tenant = await db.tenant.findFirst({
    where: { id: req.params.tenantId, users: { some: { id: req.user.id } } }
  })

  if (tenant === null) return res.status(404).json({ error: 'tenant_not_found' })

  const assistant = await vapi.assistants.create({
    name: req.body.name,
    model: { provider: 'openai', model: 'gpt-4o-mini' },
    transcriber: { provider: 'deepgram' },
    voice: { provider: '11labs', voiceId: req.body.voiceId }
  })

  await db.assistant.create({
    data: { tenantId: tenant.id, vapiAssistantId: assistant.id, name: req.body.name }
  })

  res.json({ id: assistant.id })
})
Enter fullscreen mode Exit fullscreen mode

The important part is not the SDK call. It is the tenant check before the SDK call and the local record after it.

Webhooks are not logs

My first webhook handler wrote whatever came in to a JSON column and moved on. That felt flexible. It was really just postponing decisions.

A production webhook needs three boring properties: verification, idempotency, and routing. Verification keeps random internet noise out. Idempotency stops retries from double billing. Routing maps the event back to the tenant before any side effect happens.

app.post('/webhooks/vapi', express.raw({ type: 'application/json' }), async (req, res) => {
  const event = JSON.parse(req.body.toString())
  const eventId = event.id || event.message?.call?.id + ':' + event.message?.type

  const exists = await db.webhookEvent.findUnique({ where: { eventId } })
  if (exists) return res.status(200).json({ ok: true })

  const callId = event.message?.call?.id
  const call = await db.call.findFirst({ where: { vapiCallId: callId } })

  await db.webhookEvent.create({ data: { eventId, callId, payload: event } })

  if (event.message?.type === 'end-of-call-report' && call) {
    await db.call.update({
      where: { id: call.id },
      data: { endedAt: new Date(), summary: event.message.summary || null }
    })
  }

  res.json({ ok: true })
})
Enter fullscreen mode Exit fullscreen mode

This is less exciting than a dashboard. It is also what separates a tool from a product.

Billing has to follow the call lifecycle

Metered billing is easy to describe and annoying to implement late. You need to know when usage is final, what unit you bill on, and how you handle provider retries. I found it cleaner to create internal usage events first, then sync those to Stripe.

Do not use Stripe as your source of truth for raw call state. Let Stripe be the billing rail. Your database should know the call duration, tenant, provider call ID, billing status, and invoice item reference.

A practical pattern is:

  1. Store call start when Vapi creates or reports the call.
  2. Store call end and duration from the end-of-call event.
  3. Create one usage event with a deterministic idempotency key.
  4. Sync to Stripe once.
  5. Mark the usage event as billed.

That lets you retry billing safely without rewriting call history.

Prompt templates are product surface, not config

In version one, prompts lived inside code. In version two, they lived in a text box. Both were wrong.

Customers do not want raw prompt control as much as builders think they do. They want safe knobs: greeting, business facts, escalation rules, booking rules, disallowed claims, tone, and fallback behaviour. Store those as structured fields and compile the assistant prompt from them.

That gives you versioning, previews, and rollback. It also makes support easier because you can see which field caused the assistant to behave differently.

The rebuild I would avoid now

If I were starting again, I would not begin with the voice provider integration. I would begin with the boring SaaS skeleton:

  • tenants and memberships
  • assistant records mapped to provider IDs
  • phone number ownership
  • webhook event storage
  • usage events
  • Stripe customer mapping
  • audit logs for assistant changes

Then I would add Vapi as a provider module, not as the centre of the whole app.

That sounds slower for the first demo. It is faster by the time the second customer asks for their own phone number, their own prompt, their own billing, and their own call history.

The main lesson

Voice AI demos are deceptively close to products. The gap is not speech quality. The gap is ownership, billing, retries, permissions, and lifecycle state.

If you are building on Vapi, Retell, Bland, or any similar provider, treat the provider as runtime infrastructure. Your app still needs to be the system of record.

I rebuilt this stack enough times that I packaged the pattern into Callforge. If you would rather skip the rebuilds, the public preview is at https://callforge.dev/preview.html.

Top comments (0)