DEV Community

Nabeel Hassan
Nabeel Hassan

Posted on

Undefined Is Not Null: Notes From Merging Two Voice APIs Into One Table

I build VoiceDash, a white-label portal that agencies put in front of their own clients. The agency runs AI voice agents on Retell or Vapi. Their client logs into something that looks like the agency's own product and sees calls, transcripts, recordings and summaries, with no mention of me or of the underlying voice platform anywhere.

Everyone assumes the hard part is the branding. It is not. The branding is a logo, a color and a custom domain. The hard part is a seam nobody sees: two providers, two completely different webhook payloads, and one table that the entire dashboard reads from. That seam is a few hundred lines of unglamorous code, and it has taught me more than anything else in the product.

Two payloads that agree on almost nothing

Retell hands me a call like this: the call object lives under body.call, the event name under body.event. The identifier is call_id. The start time is start_timestamp, epoch milliseconds. Duration comes precomputed as duration_ms. The transcript is transcript_object, an array of { role, content } where the bot is called agent, and if the workspace scrubs PII I get scrubbed_transcript_with_tool_calls instead. The hangup reason is disconnection_reason. The audio is at recording_url, or scrubbed_recording_url.

Vapi hands me the same conceptual thing like this: the call lives under body.message.call, the event type under body.message.type. The identifier is id. The timestamps are startedAt and endedAt as ISO strings, and there is no duration field at all, so I subtract the two myself. The transcript is artifact.messages, where the bot is called assistant and tool call rows are interleaved with the human turns, so they have to be filtered out. The hangup reason is endedReason. The audio is at artifact.recordingUrl.

Neither design is wrong. They are two teams that made reasonable decisions on different days. But if any of those differences reach my React components, every feature I build afterwards pays a tax forever.

One table, and a rule about it

Everything lands in a single Conversation row: platformCallId, startedAt, endedAt, duration, endReason, transcript as JSON, recordingUrl, plus the fields that belong to us rather than the provider (tags, note, summary, evaluation).

The rule I hold to is simple. Nothing above the storage layer is allowed to know which provider a call came from. The day a component branches on platform === "RETELL", a provider detail has escaped, and it never goes back in.

Resolve the tenant before you trust the payload

The very first thing the handler does, before it looks at a transcript, is work out whose call this is:

let agent = null;
if (agentId) {
  agent = await prisma.agent.findUnique({ where: { id: agentId } });
} else if (call.agent_id) {
  agent = await prisma.agent.findFirst({
    where: { platformAgentId: call.agent_id, platform: "RETELL" },
  });
}
if (!agent) {
  return NextResponse.json({ error: "Agent not found" }, { status: 404 });
}
Enter fullscreen mode Exit fullscreen mode

Two paths, on purpose. When an agency connects an agent we generate a webhook URL with ?agentId= already on it, which is the fast and unambiguous path. The fallback matches the provider's own agent id, scoped to that platform, for agents that were imported or wired up by hand. If neither resolves, I store nothing.

In a multi-tenant system the tenant is not a field in the payload. It is a decision I make about the payload, and it has to be the first decision, because every write after it is scoped by the answer.

Log that it arrived before you try to understand it

Immediately after the tenant resolves, before any parsing, a WebhookLog row gets written with the event name and the platform call id. "Did the event arrive" and "did we understand the event" are two different questions, and at two in the morning you want to answer them separately. Without that row, a provider-side delivery failure and a mapping bug on my side look identical: no data in the dashboard.

Idempotency comes from the natural key

The write is an upsert keyed on platformCallId, which is unique in the schema. Not on an event id, not on a row id of mine.

That matters because the same call reaches me more than once by design. Providers retry when they do not get a fast 200. A single call produces several events. My backfill re-reads calls that already arrived by webhook. All three paths converge on one upsert that is safe to run any number of times, with no distributed lock and no dedupe table.

Undefined is not null

This is the part I would tattoo on the inside of my eyelids. Here is the update half of that upsert:

update: {
  duration: call.duration_ms ? Math.round(call.duration_ms / 1000) : undefined,
  endReason: call.disconnection_reason || call.call_status,
  transcript: transcript.length > 0 ? transcript : undefined,
  recordingUrl: recordingUrl || undefined,
},
Enter fullscreen mode Exit fullscreen mode

Those undefineds look like defensive clutter. They are the most load-bearing characters in the file. In Prisma, undefined means do not touch this column, and null means write null over whatever is there.

The naive version is transcript: transcript, and it is wrong for a reason that took me a while to internalize: a later event routinely knows less than an earlier one. Recording URLs appear only after processing finishes. Some event types carry no transcript at all. Write the payload straight through and a perfectly good transcript gets replaced with [] the moment a lower-information event shows up, and the dashboard silently loses a call it had a minute earlier.

Late does not mean complete. Merge events by how much they know, not by when they showed up.

The update block is an ownership list

The other half of that lesson is what is deliberately missing from the update block: tags, note, summary, evaluation, autoTags. Those columns are ours, not the provider's. The summary and evaluation get written minutes later by a separate analysis pass over the transcript. The tags and notes are typed by a human sitting in the portal. If a retried webhook overwrote them, one duplicate delivery would erase a week of somebody's work.

So the shape ended up being: create sets everything, update sets only the columns the provider is the source of truth for. That list is worth writing down explicitly, once, before you need it.

Fire and forget, deliberately

After the row is written, two things happen without an await: the AI analysis call, and the fan-out to any forwarding webhook URLs the agency configured, each with a .catch(() => {}) on the end. The handler returns success immediately.

That is a real tradeoff and I will not pretend otherwise. A failed analysis is silent right now: no retry, no dead letter queue, no alarm. What it buys is a handler that answers in around a hundred milliseconds instead of holding the connection open for the length of a model call, and providers punish slow handlers by retrying, which turns one slow call into five duplicate deliveries. Losing a summary is recoverable, because the transcript is already in the row and the analysis can be rerun. A retry storm at peak hours is not. The proper fix is a queue, and it is on the list.

Webhooks are not a delivery guarantee

There is a second path into the same table: an authenticated endpoint that pulls the last hundred calls from the provider's list API for one agent and runs them through the same upsert.

I did not build it for a hypothetical. Deploys happen mid-call. Keys get rotated. A handler times out and the provider gives up after its retries. Anything that assumes webhooks always land will quietly develop holes in its history, and a client scrolling their call list is exactly who finds those holes first.

Two honest notes. That backfill is a button today, and the syncMode field on the agent record is sitting in the schema waiting for the scheduled version. And the Retell mapping now lives in two files, once in the webhook handler and once in the backfill, which means they have to agree forever, by hand. The Platform enum already has a third value in it, ElevenLabs, with no handler behind it yet, and that third provider is what will finally force the extraction. If I were starting this seam over I would write the mapper first, as a plain function from provider payload to normalized shape, before writing either route. Not for elegance. Because a pure function is trivially testable and an API route handler really is not.

What I would hand to anyone building the same seam

  1. Design the normalized shape before you read a single provider's docs. Let the providers argue with your model, not the other way around.
  2. Resolve the tenant first, and refuse the request if you cannot. Nothing unattributed gets stored.
  3. Get idempotency from a natural key that the provider already guarantees is unique.
  4. Use undefined for do not touch, and decide explicitly which columns the provider owns and which ones are yours.
  5. Record arrival separately from interpretation.
  6. Build the backfill on day one, and route it through the same write path as the live one.

None of this is exciting work, and that is close to the point. The boring layer in the middle is the reason the interesting features upstream get to stay simple, and it is the first thing I would build again if I started over tomorrow.

VoiceDash is at voice-dash.com if you want to see what all that plumbing holds up.

Top comments (0)