I build VoiceDash, a white-label portal where agencies show their own clients the calls their AI voice agents handled: transcripts, recordings, analytics, all under the agency's brand. The product has exactly one job that the client actually notices. When they open the dashboard, every call should be there.
For a long time, my answer to "how do calls get in?" was "the webhook". Retell posts a call event, I store a row, the dashboard reads the table. There was also a Sync Calls button for the rare case where something got missed.
Last week I spent a day admitting that this design was backwards. A webhook tells you something happened. It does not tell you that you have everything. Those are different promises, and only the second one is what my users care about.
Nothing caught fire. The failures were all quiet:
Every one of these came from the same assumption: that the webhook stream is the source of truth and everything else is a patch. Flip it, and most of them disappear.
Retell has a paginated list-calls API. That is the ledger. The webhook is a fast-path notification that lets a call appear within seconds, but correctness has to come from reconciling against the list.
The naive version of reconciliation is "re-read everything every time", which is fine for an agent with 40 calls and terrible for one with 40,000. So each agent now carries one extra column:
callsSyncedUntil DateTime?
It means: every call that started before this moment is already stored. A sync reads calls oldest first from just behind that point and moves it forward after every page.
// Everything up to the newest call on this page has now been read, in
// order, from a point that was already complete.
const starts = calls.map((c) => c?.start_timestamp).filter((t): t is number => typeof t === "number");
if (starts.length > 0) await advanceWatermark(opts.agentId, new Date(Math.max(...starts)));
The ordering is the whole trick. If you read newest first, a sync that dies on page three leaves you with a hole you cannot describe: you have the newest 300 calls and no idea where the gap starts. Read oldest first from a point that was already complete, and after every page the claim "everything before X is stored" is still true. An interrupted sync is not a failure, it is just a sync that has not finished, and the next one resumes where it stopped.
The one exception is an agent with no watermark at all, a fresh connection. There I read a single page newest first before switching to ascending, purely so the dashboard fills in immediately instead of starting with the oldest calls it has.
It only moves forward. Two syncs for the same agent can overlap: a page open in two tabs, the onboarding import still running in the background. So the update is conditional:
async function advanceWatermark(agentId: string, until: Date) {
// Only ever moves forward, including when two syncs of one agent overlap.
await prisma.agent.updateMany({
where: { id: agentId, OR: [{ callsSyncedUntil: null }, { callsSyncedUntil: { lt: until } }] },
data: { callsSyncedUntil: until },
});
}
No lock needed: a slower sync finishing late cannot drag the watermark backwards.
Every sync re-reads a little behind it. A call that is still in progress, or that Retell lists a bit late, could otherwise fall just before the watermark and never be read. So the read starts two hours early:
const OVERLAP_MS = 2 * 60 * 60 * 1000;
Re-reading is cheap because storing is idempotent. Calls the agent already has are skipped, with one exception: a row stored mid-call (duration still zero) gets its final duration, transcript and recording when the finished version shows up. A routine sync is still one request.
Importing a long history inside an HTTP request is how you get a timeout halfway through onboarding. So the sync takes a budget and returns where it stopped:
export interface SyncResult {
/** Calls newly added to this agent. */
imported: number;
/** False when the time budget ran out before every call was read. */
complete: boolean;
/** Where to continue when not complete. */
resume?: SyncResume;
}
Connecting an agent now imports for up to 20 seconds before responding, and the onboarding confirmation says how many past calls came in. If the history is longer than that, the rest finishes in the background with Next.js after(), using the resume cursor. Because the watermark advances per page, even a background run that gets killed leaves the next sync a correct place to start.
I also changed Sync Calls to report calls it actually added ("Imported 3 new calls", or "No new calls") instead of everything Retell returned, most of which was already there.
Once a routine sync costs one request, there is no reason to make a human trigger it. The analytics and conversations pages now use a small hook:
sync({ evenIfHidden: true });
const timer = setInterval(() => sync(), SYNC_EVERY_MS);
document.addEventListener("visibilitychange", onVisibilityChange);
It syncs when the page opens, every minute while the tab is visible, and when the tab comes back into view (unless the last sync was under 15 seconds ago). A running flag stops overlapping requests from the same tab.
The evenIfHidden on the first call came from a follow-up commit the same evening. I had originally gated every sync on document.visibilityState === "visible", which meant a dashboard opened in a background tab, a middle click or a restored session, showed stored calls and skipped its opening sync until someone switched to it. Opening a page should always sync once. Only the repeat should wait for visibility.
After each sync the page refreshes in place: numbers update without placeholders, new calls join the list without losing loaded pages or the conversation you have open. The button is still there as a fallback that re-reads the whole history, but it is no longer part of the normal path.
The multi-workspace bug was not a sync bug. It was a schema bug. platformCallId was @unique, which encodes "a call exists once in VoiceDash". The real rule is "a call exists once per agent", because each workspace is a separate customer with its own view.
// One copy of a call per agent: a Retell agent connected in several
// workspaces shows its calls in every one of them.
@@unique([agentId, platformCallId])
@@index([platformCallId])
Retell still sends webhooks to only one address, the most recently connected workspace. The others get each call from their own automatic sync, through their own API key, which can only list calls its own Retell account owns. That last part matters: copying rows across workspaces would have been easier, and it would have been a tenant isolation bug.
Two smaller pieces came with it. Deleting the agent that owns the webhook now hands it to the newest remaining agent for the same Retell agent, but only if the webhook still points at the deleted one, so a webhook someone has set up elsewhere since is left alone. And the migration ran in two steps around the deploy: new indexes first, then dropping the old unique index once no running code depended on it. It also reset every watermark to null, because calls skipped earlier ("another workspace already has this") now belonged to every agent and needed reading again. The watermark design made that a one-line UPDATE instead of a backfill script.
I wrote earlier about having five code paths write the same subscription row on purpose. This is the same instinct from the other side: do not trust any single delivery path to be complete, and make the convergent path cheap enough to run all the time.
If you are building client portals on top of Retell, this is what VoiceDash does under the hood: voice-dash.com.