The Bug That Looked Like a Design Decision
For weeks, dozens of blog posts on a HeyGen-powered video platform showed a blank space where the video player should have been. No error. No broken image icon. Just nothing. The CDN MP4 existed. The HeyGen render had completed. The database had a valid URL. And yet: blank page.
This is the story of how a single mismatched status string caused every video to silently disappear, and exactly how we fixed it.
What HeyGen Sends vs. What We Expected
HeyGen's webhook payload includes a status field. When a video finishes rendering, that field arrives as 'completed'.
Our Next.js frontend was written to render the video player only when status === 'ready'.
Those two strings never matched. Not once.
Every time HeyGen fired a webhook, our handler wrote videoStatus: 'completed' into MongoDB. The Next.js page checked for 'ready', found something else, and rendered nothing. The player component never mounted. No console error, because the code path was technically correct. It just never reached the render branch.
The result: dozens of posts with a valid The first step was writing down the actual states the system was moving through versus the states we intended. Intended finite state machine: Actual states in MongoDB after weeks of webhook writes: We defined a TypeScript enum that every writer had to import: Then we updated three places that were writing status to MongoDB: 1. The HeyGen webhook handler 2. The finalize function (called after manual review) was updated to write 3. The retry handler was writing Fixing the writers only helped future webhooks. The existing documents in MongoDB still had After that query ran, every post with a valid CDN URL immediately became visible. The Next.js pages re-rendered on the next request and the players mounted correctly. No redeployment needed. No content changes. Just the status string corrected in the database. While we were in the webhook handler, we found a second problem. HeyGen's We added a URL validation step before the player component mounts: If the check returns This also means the system now rejects expired A few things made this bug hard to catch: The only way we caught it was by querying MongoDB directly and noticing that This class of bug appears whenever two systems use the same concept with different vocabulary and there is no translation layer between them. HeyGen owns its status strings. We own ours. The webhook handler is the boundary, and it should have been doing the translation from day one. The fix is not complicated: define your internal states explicitly, map external values to them at the entry point, and never let a third-party string propagate into your own database. A TypeScript enum enforced at compile time would have caught this before it shipped. This is the kind of integration work the team at Savage Digital Solutions (savagesolutions.io) runs into regularly when connecting AI video tools to production content pipelines. The tools are capable. The gaps are almost always in the plumbing between them. Explore more articles on workflow automation and digital transformation.files2.heygen.ai MP4 URL sitting in the database and a blank Mapping the Broken State Machine
none → pending → ready | failednone → pending → completed'ready' never appeared in the database. Not in a single document. The finalize function, the retry handler, and the webhook writer were all using different strings with no shared enum.The Fix: One Source of Truth for Status
export enum VideoStatus {
None = 'none',
Pending = 'pending',
Ready = 'ready',
Failed = 'failed',
}// Before
await db.collection('posts').updateOne(
{ heygenJobId: payload.video_id },
{ $set: { videoStatus: payload.status } } // wrote 'completed'
);
// After
const statusMap: Record<string, VideoStatus> = {
completed: VideoStatus.Ready,
failed: VideoStatus.Failed,
};
const normalized = statusMap[payload.status] ?? VideoStatus.Pending;
await db.collection('posts').updateOne(
{ heygenJobId: payload.video_id },
{ $set: { videoStatus: normalized } }
);VideoStatus.Ready instead of the string 'ready' it had been writing inconsistently.'pending' as a raw string. Updated to VideoStatus.Pending.Bulk-Updating the Existing Documents
videoStatus: 'completed' on dozens of posts. We ran a targeted update:db.collection('posts').updateMany(
{ videoStatus: 'completed' },
{ $set: { videoStatus: 'ready' } }
);Preventing Dead Players from Expired URLs
files2.heygen.ai URLs expire. If a post sat in 'completed' limbo long enough, the URL in the database was no longer valid. Rendering the player with a dead URL would show a broken player, which is worse than showing nothing because it signals to the reader that something went wrong.async function isVideoUrlAlive(url: string): Promise<boolean> {
try {
const res = await fetch(url, { method: 'HEAD' });
return res.ok;
} catch {
return false;
}
}false, the component renders a static thumbnail with a "Video processing" label instead of a broken player. The post still reads correctly. The reader is not confused.files2.heygen.ai URLs at render time rather than silently displaying a broken embed.Why This Stayed Hidden So Long
if (status === 'ready') evaluated to false and the component returned null. Perfectly valid React.videoStatus field with a value. Nothing was null or missing.'ready' appeared zero times while 'completed' appeared dozens of times.The Broader Pattern
Key Takeaways
status: 'completed'. If your app expects 'ready', every video silently disappears.none, pending, ready, failed) and import it everywhere status is written to MongoDB.updateMany to correct existing documents. Future webhooks will not fix historical data.files2.heygen.ai URLs expire. Validate the URL with a HEAD request before mounting the player. Render a fallback rather than a broken embed.false, not a missing component. Query the database directly when the UI gives you nothing to debug.Want to Learn More?
