Troubleshooting & Polling
Polling
Don't poll in foreground. Long translations (5–30 min) will block the session and burn approval prompts on every status check. Background-poll instead.
CLI mode polling
For a SINGLE language, prefer --wait:
heygen video-translate create -d '...' \
--output-languages "Spanish (Spain)" \
--mode precision \
--enable-speech-enhancement \
--enable-caption \
--enable-dynamic-duration \
--keep-the-same-format \
--wait \
--timeout 30m--wait blocks until success or failure. The CLI uses a server-side polling
strategy with sane backoff. Exit code 0 = succeeded; 4 = timeout (job is
still running, just exceeded --timeout); 1 = failed; check stderr for the
error envelope.
For BATCH translations (multiple languages), you can't --wait on all of them
together. Strategy: submit without --wait, capture the array of
video_translation_ids, then run one backgrounded poll per ID.
# Submit
heygen video-translate create -d '...' \
--output-languages "Spanish (Spain)" "Japanese (Japan)" "German (Germany)" \
--mode precision --enable-speech-enhancement --enable-caption \
--enable-dynamic-duration --keep-the-same-format \
> /tmp/translation-batch.json
# Extract IDs
jq -r '.data.video_translation_ids[]' /tmp/translation-batch.jsonFor each ID, run a backgrounded poll loop in a single shell invocation so the user only approves once per language:
ID="<video-translation-id>"
LANG="Spanish (Spain)"
while true; do
resp=$(heygen video-translate get "$ID" 2>/dev/null)
status=$(echo "$resp" | jq -r '.data.status')
case "$status" in
completed)
url=$(echo "$resp" | jq -r '.data.video_url // empty')
echo "DONE $LANG $url"
break
;;
failed)
msg=$(echo "$resp" | jq -r '.data.failure_message // "unknown"')
echo "FAILED $LANG $msg"
break
;;
esac
sleep 30
donePolling cadence: 30 s for the first 3 minutes, then 60 s. Hard timeout: 60 min.
Harness-specific polling notes
- Claude Code: Backgrounded
Bashcalls inherit session permissions cleanly. Subagents spawned withAgentdo NOT inheritBashapprovals — everyheygencall inside a subagent will fail on permission denial. Use backgrounded bash, not subagents, for translation polling. - OpenClaw: Use
execwithbackground: trueand rely on automatic completion wake. The runtime announces completion when the loop exits. Don't tight-poll the process from the main session. - Cursor / generic CDP harnesses: Backgrounded tasks may be killed when the parent turn ends. Prefer webhook callbacks (
--callback-url) for these environments.
MCP mode polling
If mcp__heygen__get_video_translation is exposed in your toolset, use it. If
not, fall through to CLI for the polling step specifically — that's the one
acceptable place to mix MCP for submission and CLI for status checks.
Webhook callbacks (skip polling)
If the user has a webhook endpoint, set --callback-url and --callback-id
on the create call. HeyGen POSTs to the URL on completion. This is the cleanest
path for production pipelines.
heygen video-translate create -d '...' \
--callback-url "https://example.com/heygen-webhooks" \
--callback-id "session-12345" \
--output-languages "Spanish (Spain)" \
--mode precisionErrors → Action Mapping
Auth errors
exit 3 from CLI / 401 from API:
HEYGEN_API_KEY is missing, expired, or wrong. Action:
- Confirm the env var:
echo "${HEYGEN_API_KEY:0:8}…" - Test with a known-good call:
heygen user me get - If that fails, regenerate the key at https://app.heygen.com/api
heygen auth login worked but a call says unauthorized:
Token cached but rotated server-side. Run heygen auth logout && heygen auth login.
Source video errors
400 "video URL not accessible" / "Could not fetch video":
The URL requires auth, returned HTML, returned wrong MIME, or 404'd. Run a quick
sanity check:
curl -sI "<URL>" | head -5Expect a 200 + Content-Type: video/.... If you see 403, 404, text/html,
or a redirect chain to a login page, ask the user for a public URL or a local
file. For local files, route through heygen asset create --file.
400 "asset_id not found":
The asset_id was for a different account, was deleted, or doesn't exist.
Re-upload via heygen asset create --file <path>.
File >32 MB: HeyGen asset upload caps at 32 MB. Options:
- Re-encode the source at lower bitrate (often a 32 MB file is over-encoded).
- Host the file at a public URL (S3, Bucket, etc.) and pass as
{type:"url"}. - Use a presigned URL if it's behind auth — make sure the URL is reachable without additional headers.
Language errors
400 "language not supported":
The string didn't match the canonical list exactly. Re-run:
heygen video-translate languages list | jq -r '.data.languages[]' > /tmp/heygen-langs.txt
grep -i "<user-said>" /tmp/heygen-langs.txtPresent the closest matches. Common mismatches:
| User said | Canonical |
|---|---|
| "Spanish" | "Spanish (Spain)" |
| "Mexican Spanish" | "Spanish (Mexico)" |
| "Chinese" / "Mandarin" | "Chinese (Mandarin, Simplified)" |
| "Cantonese" | "Chinese (Cantonese, Traditional)" |
| "Portuguese" | "Portuguese (Portugal)" or "Portuguese (Brazil)" — ASK |
| "Arabic" | "Arabic" or one of 19 region variants — ASK |
| "Japanese" | "Japanese (Japan)" or just "Japanese" |
| "French" | "French (France)" |
| "German" | "German (Germany)" |
| "English" | "English (United States)" or just "English" |
Submission errors
failed with failure_message: "Your video's audio is missing or corrupted, please try with another video" (verified empirically):
The source has no audible speech, very corrupted audio, or unsupported codec.
Fails fast (~30 s after submit). Verify by previewing the source. If the user says speech is present, try re-encoding to a clean MP4 with AAC audio (ffmpeg -i input.mp4 -c:v libx264 -c:a aac -b:a 128k output.mp4). For animation / b-roll / silent sources, video translation is the wrong tool — route to a different workflow.
failed with failure_message: "Failed to download video from url, please check the url is valid or the video is public" (verified empirically):
Fails almost instantly on submit. Source URL was 401/403/404, returned HTML instead of a video, redirected to a login page, or had a presigned URL that already expired. HEAD-check first with curl -sI "$URL" — expect 200 + Content-Type: video/.... Ask the user for a public URL or fall back to local-file upload.
failed with failure_message: "speaker detection":
speaker_num mismatched the actual speakers, or audio was too noisy for
diarization. Re-submit with correct speaker count and
enable_speech_enhancement: true.
failed with failure_message: "lip-sync":
Face was not detected or detection was unstable. Likely cause: occlusion,
profile shots, low resolution, or fast cuts. Either:
- Re-submit as
translate_audio_only: true(the user gets translated audio, composites manually). - Tell the user the source isn't viable for visual translation.
Stuck jobs
Stuck >30 min in running:
Most translations finish in 5–15 min; some legitimately take 30–45 min for
long source videos or batched languages. Beyond 60 min, treat as stuck:
heygen video-translate get <id>and capture the response.- Surface to the user: "This translation has been running for over an hour. Backend may be stuck. Want me to delete the job and re-submit, or wait another 30 minutes?"
- If they re-submit,
heygen video-translate delete <id>first.
SRT upload (proofreads workflow)
Preferred: upload the edited SRT as an asset. heygen asset create accepts srt (supported types: png, jpeg, mp4, webm, mp3, wav, pdf, srt). Upload the file, capture .data.asset_id, and pass it to srt update:
ASSET_ID=$(heygen asset create --file /path/to/proofread-edited.srt | jq -r '.data.asset_id')
heygen video-translate proofreads srt update <proofread-id> \
-d "{\"srt\":{\"type\":\"asset_id\",\"asset_id\":\"$ASSET_ID\"}}"Fallback: public URL route. If you'd rather host the SRT, use the URL form:
heygen video-translate proofreads srt update <proofread-id> \
-d '{"srt":{"type":"url","url":"https://example.com/proofread-edited.srt"}}'Working host options: GitHub gists (raw URL), GitHub repo files (raw URL), S3/GCS with public-read or a presigned URL ≥2 h, Vercel/static hosts. The URL must serve application/x-subrip or text/plain with the SRT body — verify with curl -sI. (Non-subtitle content-type mismatches still error on upload.)
Output / delivery errors
completed but video_url is null:
Race condition between job completion and CDN propagation. Retry the get
call after 30 seconds.
video_url returns 403 or expired:
Presigned URLs from HeyGen expire. Re-fetch with heygen video-translate get <id> to get a fresh URL.
Captions in wrong direction (RTL languages): Burned-in captions for Arabic / Hebrew / Urdu / Persian render right-to-left and may collide with source video graphics in the lower-third. Switch to the proofreads workflow and use the SRT as a sidecar caption file.
Lip-sync quality issues
Output looks fine in some shots but bad in others: Source face conditions (occlusion, profile, fast cuts) are degrading lip-sync in those segments. Set expectations upfront in Phase 2 next time. For this output, options:
- Accept it (lip-sync was always going to be best-effort with this source).
- Re-edit the source to use cleaner shots, then re-translate.
- Switch to audio-only translation for use in a different video.
Voice clone sounds off (wrong gender, accent, age):
Almost always a speaker_num issue — the engine separated voices wrong and
clipped a different speaker's audio into the dub. Re-submit with corrected
speaker count, or use proofreads to scrub the SRT and re-dub.
Debug Checklist (run before escalating)
- Auth:
heygen user me getreturns user info ✅ / fails ❌ - CLI version:
heygen --versionreturns v0.0.6 or newer - Languages list:
heygen video-translate languages list | jq '.data.languages | length'returns >0 - Source URL reachable:
curl -sI "<URL>" | head -1returns200 - Source MIME:
curl -sI "<URL>" | grep -i content-typeshowsvideo/... - Recent jobs:
heygen video-translate listto see if past jobs succeeded
If all six pass and the translation still fails: capture the failure_message
from heygen video-translate get <id> and surface it — that's HeyGen-side, not
something the skill can resolve.