
Avatar Video
- 5 installs
- Updated April 14, 2026
- heygen-com/skills-legacy
Creates AI avatar videos with exact control over avatars, voices, scripts, and backgrounds via the HeyGen v3 API.
About
Creates AI avatar videos with precise control over avatars, voices, scripts, and backgrounds using HeyGen's v3 API, including animating a photo into a speaking video. A developer uses it for brand-consistent, exactly-specified avatar video production.
- HeyGen v3 POST /v3/videos with avatar_id or image AssetInput
- Supports Avatar IV photo animation, transparent backgrounds, Remotion
Avatar Video by the numbers
- 5 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,127 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/heygen-com/skills-legacy --skill avatar-videoAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| Last updated | April 14, 2026 |
| Repository | heygen-com/skills-legacy ↗ |
What it does
Creates AI avatar videos with exact control over avatars, voices, scripts, and backgrounds via the HeyGen v3 API.
Files
Avatar Video
Create AI avatar videos with full control over avatars, voices, scripts, and backgrounds using POST /v3/videos. Two creation modes via discriminated union on type:
"type": "avatar"+avatar_id— use a HeyGen avatar from the library"type": "image"+image(AssetInput) — animate any photo via Avatar IV
Authentication
All requests require the X-Api-Key header. Set the HEYGEN_API_KEY environment variable.
curl -X GET "https://api.heygen.com/v3/avatars" \
-H "X-Api-Key: $HEYGEN_API_KEY"Tool Selection
If HeyGen MCP tools are available (mcp__heygen__*), prefer them over direct HTTP API calls — they handle authentication and request formatting automatically.
| Task | MCP Tool | Fallback (Direct API) |
|---|---|---|
| Check video status / get URL | mcp__heygen__get_video | GET /v3/videos/{video_id} |
| List account videos | mcp__heygen__list_videos | GET /v3/videos |
| Delete a video | mcp__heygen__delete_video | DELETE /v3/videos/{video_id} |
Video generation (POST /v3/videos) and avatar/voice listing are done via direct API calls — see reference files below.
Default Workflow
1. List avatar looks — GET /v3/avatars/looks → pick a look, note its id (this is the avatar_id) and default_voice_id. See avatars.md 2. List voices (if needed) — GET /v3/voices → pick a voice matching the avatar's gender/language. See voices.md 3. Write the script — Structure scenes with one concept each. See scripts.md 4. Generate the video — POST /v3/videos with avatar_id, voice_id, script, and optional background per scene. See video-generation.md 5. Poll for completion — GET /v3/videos/{video_id} until status is completed. See video-status.md
Routing: This Skill vs Create Video
This skill = precise control (specific avatar, exact script, custom background). create-video = prompt-based ("make me a video about X", AI handles the rest).
Reference Files
Read these as needed — they contain endpoint details, request/response schemas, and code examples (curl, TypeScript, Python).
Core workflow:
- references/video-generation.md —
POST /v3/videosrequest fields, avatar input modes, voice settings, backgrounds - references/avatars.md —
GET /v3/avatars(groups) andGET /v3/avatars/looks(looks →avatar_id) - references/voices.md —
GET /v3/voiceswith filtering by language, gender, engine - references/video-status.md —
GET /v3/videos/{id}polling patterns and download
Customization:
- references/scripts.md — Script writing, SSML break tags, pacing
- references/backgrounds.md — Solid color and image backgrounds
- references/captions.md — Auto-generated captions/subtitles
- references/text-overlays.md — Text overlays with fonts and positioning
Advanced:
- references/photo-avatars.md — Animate photos via
type: "image"(Avatar IV), AI-generated avatars - references/templates.md — Template listing and variable replacement
- references/remotion-integration.md — Using HeyGen avatars in Remotion compositions
- references/webhooks.md — Webhook endpoints and events
- references/assets.md — Uploading images, videos, audio
- references/dimensions.md — Resolution and aspect ratios
- references/quota.md — Credit system and usage limits
Best Practices
1. Preview avatars before generating — Use GET /v3/avatars/looks and download preview_image_url so the user can see the avatar before committing 2. Use avatar's default voice — Most avatars have a default_voice_id pre-matched for natural results 3. Fallback: match gender manually — If no default voice, ensure avatar and voice genders match 4. Use test mode for development — Set test: true to avoid consuming credits (output will be watermarked) 5. Set generous timeouts — Video generation often takes 5-15 minutes, sometimes longer 6. Validate inputs — Check avatar and voice IDs exist before generating
Asset Upload and Management
HeyGen allows you to upload custom assets (images, videos, audio) for use in video generation, such as backgrounds, talking photo sources, and custom audio.
Upload Flow
Asset uploads are a single-step process: POST the raw file binary directly to the upload endpoint. The Content-Type header must match the file's MIME type.
Uploading an Asset
Endpoint: POST https://upload.heygen.com/v1/asset
Request
| Header | Required | Description |
|---|---|---|
X-Api-Key | ✓ | Your HeyGen API key |
Content-Type | ✓ | MIME type of the file (e.g. image/jpeg) |
The request body is the raw binary file data. No JSON or form fields are needed.
Response
| Field | Type | Description |
|---|---|---|
code | number | Status code (100 = success) |
data.id | string | Unique asset ID for use in video generation |
data.name | string | Asset name |
data.file_type | string | image, video, or audio |
data.url | string | Accessible URL for the uploaded file |
data.image_key | string \ | null |
data.folder_id | string | Folder ID (empty if not in a folder) |
data.meta | string \ | null |
data.created_ts | number | Unix timestamp of creation |
curl
curl -X POST "https://upload.heygen.com/v1/asset" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: image/jpeg" \
--data-binary '@./background.jpg'TypeScript
import fs from "fs";
import path from "path";
interface AssetUploadResponse {
code: number;
data: {
id: string;
name: string;
file_type: string;
url: string;
image_key: string | null;
folder_id: string;
meta: string | null;
created_ts: number;
};
msg: string | null;
message: string | null;
}
async function uploadAsset(filePath: string, contentType: string): Promise<AssetUploadResponse["data"]> {
const resolvedPath = path.resolve(filePath);
const fileBuffer = fs.readFileSync(resolvedPath);
const response = await fetch("https://upload.heygen.com/v1/asset", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": contentType,
},
body: fileBuffer,
});
const json: AssetUploadResponse = await response.json();
if (json.code !== 100) {
throw new Error(json.message ?? "Upload failed");
}
return json.data;
}
// Usage
const asset = await uploadAsset("./background.jpg", "image/jpeg");
console.log(`Uploaded asset: ${asset.id}`);
console.log(`Asset URL: ${asset.url}`);TypeScript (with streams for large files)
import fs from "fs";
import path from "path";
import { stat } from "fs/promises";
async function uploadLargeAsset(filePath: string, contentType: string): Promise<AssetUploadResponse["data"]> {
const resolvedPath = path.resolve(filePath);
const fileStats = await stat(resolvedPath);
const fileStream = fs.createReadStream(resolvedPath);
const response = await fetch("https://upload.heygen.com/v1/asset", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": contentType,
"Content-Length": fileStats.size.toString(),
},
body: fileStream as any,
// @ts-ignore - duplex is needed for streaming
duplex: "half",
});
const json: AssetUploadResponse = await response.json();
if (json.code !== 100) {
throw new Error(json.message ?? "Upload failed");
}
return json.data;
}Python
import requests
import os
def upload_asset(file_path: str, content_type: str) -> dict:
with open(file_path, "rb") as f:
response = requests.post(
"https://upload.heygen.com/v1/asset",
headers={
"X-Api-Key": os.environ["HEYGEN_API_KEY"],
"Content-Type": content_type
},
data=f
)
data = response.json()
if data.get("code") != 100:
raise Exception(data.get("message", "Upload failed"))
return data["data"]
# Usage
asset = upload_asset("./background.jpg", "image/jpeg")
print(f"Uploaded asset: {asset['id']}")
print(f"Asset URL: {asset['url']}")Supported Content Types
| Type | Content-Type | Use Case |
|---|---|---|
| JPEG | image/jpeg | Backgrounds, talking photos |
| PNG | image/png | Backgrounds, overlays |
| MP4 | video/mp4 | Video backgrounds |
| WebM | video/webm | Video backgrounds |
| MP3 | audio/mpeg | Custom audio input |
| WAV | audio/wav | Custom audio input |
Uploading from URL
If your asset is already hosted online:
async function uploadFromUrl(sourceUrl: string, contentType: string): Promise<AssetUploadResponse["data"]> {
// 1. Validate and download the file
const url = new URL(sourceUrl);
if (url.protocol !== "https:") {
throw new Error("Only HTTPS URLs are supported");
}
const sourceResponse = await fetch(sourceUrl);
const buffer = Buffer.from(await sourceResponse.arrayBuffer());
// 2. Upload directly to HeyGen
const response = await fetch("https://upload.heygen.com/v1/asset", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": contentType,
},
body: buffer,
});
const json: AssetUploadResponse = await response.json();
if (json.code !== 100) {
throw new Error(json.message ?? "Upload failed");
}
return json.data;
}Using Uploaded Assets
As Background Image
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Hello, this is a video with a custom background!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "image",
url: asset.url, // Use the URL from the upload response
},
},
],
};As Talking Photo Source
const talkingPhotoConfig = {
video_inputs: [
{
character: {
type: "talking_photo",
talking_photo_id: asset.id, // Use the ID from the upload response
},
voice: {
type: "text",
input_text: "Hello from my talking photo!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
},
],
};As Audio Input
const audioConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "audio",
audio_url: asset.url, // Use the URL from the upload response
},
},
],
};Complete Upload Workflow
async function createVideoWithCustomBackground(
backgroundPath: string,
script: string
): Promise<string> {
// 1. Upload background
console.log("Uploading background...");
const background = await uploadAsset(backgroundPath, "image/jpeg");
// 2. Create video config
const config = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: script,
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "image",
url: background.url,
},
},
],
dimension: { width: 1920, height: 1080 },
};
// 3. Generate video
console.log("Generating video...");
const response = await fetch("https://api.heygen.com/v3/videos", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "avatar" as const,
avatar_id: avatarId,
script: script,
voice_id: voiceId,
background: { type: "image", asset_id: bgAssetId },
resolution: "1080p",
aspect_ratio: "16:9",
}),
});
const { data } = await response.json();
return data.video_id;
}Asset Limitations
- File size: 10MB maximum
- Image dimensions: Recommended to match video dimensions
- Audio duration: Should match expected video length
- Retention: Assets may be deleted after a period of inactivity
Best Practices
1. Optimize images - Resize to match video dimensions before uploading 2. Use appropriate formats - JPEG for photos, PNG for graphics with transparency 3. Validate before upload - Check file type and size locally first 4. Handle upload errors - Implement retry logic for failed uploads 5. Cache asset IDs - Reuse assets across multiple video generations
HeyGen Avatars
The v3 API organizes avatars into two levels:
- Groups represent a character (e.g., "Josh", "Angela"). A group has a name, preview images, and a default voice.
- Looks represent a specific outfit, style, or pose within a group. Each look has an
idthat you pass asavatar_idwhen creating a video viaPOST /v3/videos.
A single group can have multiple looks. For example, the "Josh" group might have looks for business attire, casual wear, and seasonal outfits.
Previewing Avatars Before Generation
Always preview avatars before generating a video to ensure they match user preferences. Both groups and looks include preview URLs that can be opened directly in the browser.
Preview Fields
| Level | Field | Description |
|---|---|---|
| Group | preview_image_url | Static image of the character (publicly accessible) |
| Group | preview_video_url | Short video clip showing the character |
| Look | preview_image_url | Static image of this specific look/outfit |
| Look | preview_video_url | Short video clip of this look in action |
Both URLs are publicly accessible -- no authentication needed to view them.
Workflow: Preview Before Generate
1. List avatar groups -- browse available characters 2. List looks for a group -- see available outfits/styles 3. Show preview URLs to user -- share preview_image_url for visual check 4. User selects a look by name or ID 5. Use `look.id` as `avatar_id` and look.default_voice_id as voice_id in POST /v3/videos
Listing Avatar Groups
List all avatar groups (characters) available to your account.
curl
# List public avatar groups
curl -X GET "https://api.heygen.com/v3/avatars?ownership=public&limit=10" \
-H "X-Api-Key: $HEYGEN_API_KEY"
# List your private avatar groups
curl -X GET "https://api.heygen.com/v3/avatars?ownership=private&limit=10" \
-H "X-Api-Key: $HEYGEN_API_KEY"TypeScript
interface AvatarGroupItem {
id: string;
name: string;
preview_image_url: string | null;
preview_video_url: string | null;
gender: string | null;
created_at: number;
looks_count: number;
default_voice_id: string | null;
}
interface AvatarGroupsResponse {
data: AvatarGroupItem[];
has_more: boolean;
next_token: string | null;
}
async function listAvatarGroups(
ownership: "public" | "private" = "public",
limit: number = 50,
token?: string
): Promise<AvatarGroupsResponse> {
const params = new URLSearchParams({
ownership,
limit: limit.toString(),
});
if (token) {
params.set("token", token);
}
const response = await fetch(
`https://api.heygen.com/v3/avatars?${params}`,
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
if (!response.ok) {
throw new Error(`Failed to list avatar groups: ${response.status}`);
}
return response.json();
}
// Usage
const { data: groups, has_more, next_token } = await listAvatarGroups("public", 10);
for (const group of groups) {
console.log(`${group.name} (${group.gender}) - ${group.looks_count} looks`);
console.log(` ID: ${group.id}`);
console.log(` Preview: ${group.preview_image_url}`);
}Python
import requests
import os
def list_avatar_groups(
ownership: str = "public",
limit: int = 50,
token: str | None = None,
) -> dict:
params = {"ownership": ownership, "limit": limit}
if token:
params["token"] = token
response = requests.get(
"https://api.heygen.com/v3/avatars",
headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]},
params=params,
)
response.raise_for_status()
return response.json()
# Usage
result = list_avatar_groups("public", limit=10)
for group in result["data"]:
print(f"{group['name']} ({group['gender']}) - {group['looks_count']} looks")
print(f" ID: {group['id']}")
print(f" Preview: {group['preview_image_url']}")Response Format
{
"data": [
{
"id": "ag_abc123",
"name": "Josh",
"preview_image_url": "https://files.heygen.ai/...",
"preview_video_url": "https://files.heygen.ai/...",
"gender": "male",
"created_at": 1710000000,
"looks_count": 3,
"default_voice_id": "1bd001e7e50f421d891986aad5158bc8"
},
{
"id": "ag_def456",
"name": "Angela",
"preview_image_url": "https://files.heygen.ai/...",
"preview_video_url": "https://files.heygen.ai/...",
"gender": "female",
"created_at": 1710100000,
"looks_count": 2,
"default_voice_id": "2d5b0e6a8c3f47d9a1b2c3d4e5f60718"
}
],
"has_more": true,
"next_token": "eyJsYXN0X2lkIjoiYWdfZGVmNDU2In0="
}Get Avatar Group Details
Retrieve details for a single avatar group by its ID.
curl
curl -X GET "https://api.heygen.com/v3/avatars/ag_abc123" \
-H "X-Api-Key: $HEYGEN_API_KEY"TypeScript
async function getAvatarGroup(groupId: string): Promise<AvatarGroupItem> {
const response = await fetch(
`https://api.heygen.com/v3/avatars/${groupId}`,
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
if (!response.ok) {
throw new Error(`Failed to get avatar group: ${response.status}`);
}
return response.json();
}Python
def get_avatar_group(group_id: str) -> dict:
response = requests.get(
f"https://api.heygen.com/v3/avatars/{group_id}",
headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]},
)
response.raise_for_status()
return response.json()Listing Avatar Looks
Looks are the specific outfits, styles, or poses within a group. The look id is what you pass as avatar_id to POST /v3/videos.
curl
# List all public looks
curl -X GET "https://api.heygen.com/v3/avatars/looks?ownership=public&limit=20" \
-H "X-Api-Key: $HEYGEN_API_KEY"
# List looks for a specific group
curl -X GET "https://api.heygen.com/v3/avatars/looks?group_id=ag_abc123&limit=20" \
-H "X-Api-Key: $HEYGEN_API_KEY"
# List only studio avatars
curl -X GET "https://api.heygen.com/v3/avatars/looks?avatar_type=studio_avatar&ownership=public&limit=20" \
-H "X-Api-Key: $HEYGEN_API_KEY"Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
group_id | string | No | Filter looks by avatar group |
avatar_type | string | No | "studio_avatar", "video_avatar", or "photo_avatar" |
ownership | string | No | "public" or "private" |
limit | integer | No | 1-50, number of results per page |
token | string | No | Cursor token for pagination |
TypeScript
interface AvatarLookItem {
id: string;
name: string;
avatar_type: "studio_avatar" | "video_avatar" | "photo_avatar";
group_id: string | null;
preview_image_url: string | null;
preview_video_url: string | null;
gender: string | null;
tags: string[];
default_voice_id: string | null;
supported_api_engines: string[];
}
interface AvatarLooksResponse {
data: AvatarLookItem[];
has_more: boolean;
next_token: string | null;
}
async function listAvatarLooks(options: {
group_id?: string;
avatar_type?: "studio_avatar" | "video_avatar" | "photo_avatar";
ownership?: "public" | "private";
limit?: number;
token?: string;
} = {}): Promise<AvatarLooksResponse> {
const params = new URLSearchParams();
if (options.group_id) params.set("group_id", options.group_id);
if (options.avatar_type) params.set("avatar_type", options.avatar_type);
if (options.ownership) params.set("ownership", options.ownership);
if (options.limit) params.set("limit", options.limit.toString());
if (options.token) params.set("token", options.token);
const response = await fetch(
`https://api.heygen.com/v3/avatars/looks?${params}`,
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
if (!response.ok) {
throw new Error(`Failed to list avatar looks: ${response.status}`);
}
return response.json();
}
// List all public looks
const { data: looks } = await listAvatarLooks({ ownership: "public", limit: 20 });
// List looks for a specific group
const { data: groupLooks } = await listAvatarLooks({ group_id: "ag_abc123" });
// List only studio avatars
const { data: studioLooks } = await listAvatarLooks({
avatar_type: "studio_avatar",
ownership: "public",
});Python
def list_avatar_looks(
group_id: str | None = None,
avatar_type: str | None = None,
ownership: str | None = None,
limit: int = 50,
token: str | None = None,
) -> dict:
params: dict = {"limit": limit}
if group_id:
params["group_id"] = group_id
if avatar_type:
params["avatar_type"] = avatar_type
if ownership:
params["ownership"] = ownership
if token:
params["token"] = token
response = requests.get(
"https://api.heygen.com/v3/avatars/looks",
headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]},
params=params,
)
response.raise_for_status()
return response.json()
# List all public looks
result = list_avatar_looks(ownership="public", limit=20)
for look in result["data"]:
print(f"{look['name']} ({look['avatar_type']}) - ID: {look['id']}")
# List looks for a specific group
result = list_avatar_looks(group_id="ag_abc123")
# List only studio avatars
result = list_avatar_looks(avatar_type="studio_avatar", ownership="public")Response Format
{
"data": [
{
"id": "lk_josh_business",
"name": "Josh - Business",
"avatar_type": "studio_avatar",
"group_id": "ag_abc123",
"preview_image_url": "https://files.heygen.ai/...",
"preview_video_url": "https://files.heygen.ai/...",
"gender": "male",
"tags": ["business", "professional"],
"default_voice_id": "1bd001e7e50f421d891986aad5158bc8",
"supported_api_engines": ["avatar_4_quality", "avatar_4_turbo"]
},
{
"id": "lk_josh_casual",
"name": "Josh - Casual",
"avatar_type": "studio_avatar",
"group_id": "ag_abc123",
"preview_image_url": "https://files.heygen.ai/...",
"preview_video_url": "https://files.heygen.ai/...",
"gender": "male",
"tags": ["casual", "lifestyle"],
"default_voice_id": "1bd001e7e50f421d891986aad5158bc8",
"supported_api_engines": ["avatar_4_quality", "avatar_4_turbo"]
}
],
"has_more": false,
"next_token": null
}Get Look Details
Retrieve details for a single look by its ID.
curl
curl -X GET "https://api.heygen.com/v3/avatars/looks/lk_josh_business" \
-H "X-Api-Key: $HEYGEN_API_KEY"TypeScript
async function getAvatarLook(lookId: string): Promise<AvatarLookItem> {
const response = await fetch(
`https://api.heygen.com/v3/avatars/looks/${lookId}`,
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
if (!response.ok) {
throw new Error(`Failed to get avatar look: ${response.status}`);
}
return response.json();
}
// Usage
const look = await getAvatarLook("lk_josh_business");
console.log(`${look.name} (${look.avatar_type})`);
console.log(`Engines: ${look.supported_api_engines.join(", ")}`);
console.log(`Default voice: ${look.default_voice_id}`);Python
def get_avatar_look(look_id: str) -> dict:
response = requests.get(
f"https://api.heygen.com/v3/avatars/looks/{look_id}",
headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]},
)
response.raise_for_status()
return response.json()
# Usage
look = get_avatar_look("lk_josh_business")
print(f"{look['name']} ({look['avatar_type']})")
print(f"Engines: {', '.join(look['supported_api_engines'])}")
print(f"Default voice: {look['default_voice_id']}")Avatar Types
The avatar_type field on looks distinguishes three categories:
| Type | Description | Best For |
|---|---|---|
studio_avatar | Professionally produced studio-quality avatars | Corporate videos, product demos, training content |
video_avatar | Avatars trained from uploaded video footage | Custom brand representatives, personalized content |
photo_avatar | Avatars generated from a still photo | Quick prototyping, low-effort personalization |
Filtering by Type
// Get only studio avatars (highest quality)
const { data: studioLooks } = await listAvatarLooks({
avatar_type: "studio_avatar",
ownership: "public",
});
// Get only photo avatars (from uploaded photos)
const { data: photoLooks } = await listAvatarLooks({
avatar_type: "photo_avatar",
ownership: "private",
});# Get only video avatars (trained from video footage)
result = list_avatar_looks(avatar_type="video_avatar", ownership="private")Using Avatar's Default Voice
Each look has a default_voice_id that is pre-matched for natural results. This is the recommended approach rather than manually selecting voices.
Recommended Flow
1. GET /v3/avatars/looks -> Pick a look
2. Use look.id as avatar_id -> Pass to POST /v3/videos
3. Use look.default_voice_id -> Pass as voice_id to POST /v3/videosComplete Example: Generate Video with a Look's Default Voice
TypeScript
async function generateWithDefaultVoice(
lookId: string,
script: string
): Promise<string> {
// 1. Get look details to find default voice
const look = await getAvatarLook(lookId);
if (!look.default_voice_id) {
throw new Error(`Look ${look.name} has no default voice`);
}
console.log(`Using ${look.name} with default voice: ${look.default_voice_id}`);
// 2. Generate video with the look's default voice
const response = await fetch("https://api.heygen.com/v3/videos", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
video_inputs: [{
character: {
type: "avatar",
avatar_id: look.id, // The look ID is the avatar_id
avatar_style: "normal",
},
voice: {
type: "text",
input_text: script,
voice_id: look.default_voice_id,
},
}],
dimension: { width: 1920, height: 1080 },
}),
});
if (!response.ok) {
throw new Error(`Video generation failed: ${response.status}`);
}
const { data } = await response.json();
return data.video_id;
}Python
def generate_with_default_voice(look_id: str, script: str) -> str:
# 1. Get look details to find default voice
look = get_avatar_look(look_id)
if not look.get("default_voice_id"):
raise ValueError(f"Look {look['name']} has no default voice")
print(f"Using {look['name']} with default voice: {look['default_voice_id']}")
# 2. Generate video with the look's default voice
response = requests.post(
"https://api.heygen.com/v3/videos",
headers={
"X-Api-Key": os.environ["HEYGEN_API_KEY"],
"Content-Type": "application/json",
},
json={
"video_inputs": [{
"character": {
"type": "avatar",
"avatar_id": look["id"], # The look ID is the avatar_id
"avatar_style": "normal",
},
"voice": {
"type": "text",
"input_text": script,
"voice_id": look["default_voice_id"],
},
}],
"dimension": {"width": 1920, "height": 1080},
},
)
response.raise_for_status()
return response.json()["data"]["video_id"]Why Use Default Voice?
1. Guaranteed gender match -- avatar and voice are pre-paired 2. Natural lip sync -- default voices are optimized for the avatar 3. Simpler code -- no need to fetch and match voices separately 4. Better quality -- HeyGen has tested this combination
Filtering and Searching
The v3 API supports server-side filtering across multiple dimensions.
By Group
// Get all looks for a specific character
const { data: looks } = await listAvatarLooks({ group_id: "ag_abc123" });By Avatar Type
// Only studio-quality avatars
const { data: looks } = await listAvatarLooks({ avatar_type: "studio_avatar" });By Ownership
// Public avatars provided by HeyGen
const { data: publicLooks } = await listAvatarLooks({ ownership: "public" });
// Your custom/private avatars
const { data: privateLooks } = await listAvatarLooks({ ownership: "private" });By Gender (Client-Side)
Gender is available on the response but not as a query parameter. Filter client-side:
const { data: looks } = await listAvatarLooks({ ownership: "public" });
const femaleLooks = looks.filter((look) => look.gender === "female");
const maleLooks = looks.filter((look) => look.gender === "male");result = list_avatar_looks(ownership="public")
female_looks = [look for look in result["data"] if look["gender"] == "female"]
male_looks = [look for look in result["data"] if look["gender"] == "male"]Combining Filters
// Private studio avatars for a specific group
const { data: looks } = await listAvatarLooks({
group_id: "ag_abc123",
avatar_type: "studio_avatar",
ownership: "private",
limit: 10,
});Search by Name (Client-Side)
function searchLooksByName(looks: AvatarLookItem[], query: string): AvatarLookItem[] {
const lowerQuery = query.toLowerCase();
return looks.filter((look) =>
look.name.toLowerCase().includes(lowerQuery)
);
}
const { data: allLooks } = await listAvatarLooks({ ownership: "public" });
const results = searchLooksByName(allLooks, "josh");Cursor-Based Pagination
Both the groups and looks endpoints use cursor-based pagination. The response includes has_more (boolean) and next_token (string or null). Pass next_token as the token query parameter to fetch the next page.
TypeScript
async function listAllAvatarLooks(
options: { ownership?: "public" | "private"; avatar_type?: string } = {}
): Promise<AvatarLookItem[]> {
const allLooks: AvatarLookItem[] = [];
let token: string | undefined;
do {
const response = await listAvatarLooks({
...options,
limit: 50,
token,
});
allLooks.push(...response.data);
token = response.has_more && response.next_token
? response.next_token
: undefined;
} while (token);
return allLooks;
}
// Fetch every public look across all pages
const allPublicLooks = await listAllAvatarLooks({ ownership: "public" });
console.log(`Total public looks: ${allPublicLooks.length}`);Python
def list_all_avatar_looks(
ownership: str | None = None,
avatar_type: str | None = None,
) -> list[dict]:
all_looks: list[dict] = []
token: str | None = None
while True:
result = list_avatar_looks(
ownership=ownership,
avatar_type=avatar_type,
limit=50,
token=token,
)
all_looks.extend(result["data"])
if not result["has_more"] or not result.get("next_token"):
break
token = result["next_token"]
return all_looks
# Fetch every public look across all pages
all_public_looks = list_all_avatar_looks(ownership="public")
print(f"Total public looks: {len(all_public_looks)}")curl (Manual Pagination)
# First page
curl -X GET "https://api.heygen.com/v3/avatars/looks?ownership=public&limit=50" \
-H "X-Api-Key: $HEYGEN_API_KEY"
# Next page (use next_token from previous response)
curl -X GET "https://api.heygen.com/v3/avatars/looks?ownership=public&limit=50&token=eyJsYXN0X2lkIjoiLi4uIn0=" \
-H "X-Api-Key: $HEYGEN_API_KEY"Selecting the Right Avatar
Avatar Categories
HeyGen avatars fall into distinct categories. Match the category to your use case:
| Category | Best For |
|---|---|
| Business/Professional | Corporate videos, product demos, training |
| Casual/Friendly | Social media, informal content |
| Themed/Seasonal | Specific campaigns, seasonal content |
| Expressive | Engaging storytelling, dynamic content |
Selection Guidelines
For business/professional content:
- Choose looks with neutral attire (business casual or formal)
- Avoid themed or seasonal looks (holiday costumes, casual clothing)
- Preview the look to verify professional appearance
- Consider your audience demographics when selecting gender and appearance
- Prefer
studio_avatartype for highest quality
For casual/social content:
- More flexibility in look choice
- Themed looks can work for specific campaigns
- Match avatar energy to content tone
Common Mistakes to Avoid
1. Using themed looks for business content -- a holiday-themed look appears unprofessional in a product demo 2. Not previewing before generation -- always check the preview URL to verify appearance 3. Mismatched voice gender -- always use the look's default_voice_id or match genders manually 4. Using a group ID instead of a look ID -- POST /v3/videos requires a look id, not a group id
Selection Checklist
Before generating a video:
- [ ] Previewed look image/video in browser
- [ ] Look appearance matches content tone (professional vs casual)
- [ ] Voice gender matches avatar gender
- [ ] Using
default_voice_idwhen available - [ ] Passing the look
id(not the groupid) asavatar_id - [ ] Checked
supported_api_enginesif targeting a specific rendering engine
Best Practices
1. Always use look IDs for video generation. The look id is the avatar_id you pass to POST /v3/videos. Group IDs are for browsing and organization only. 2. Prefer `default_voice_id`. Each look has a pre-matched voice. Use it unless the user explicitly requests a different voice. 3. Preview before generating. Share preview_image_url with the user to confirm the look matches their expectations. 4. Use server-side filters. Filter by ownership, avatar_type, and group_id at the API level rather than fetching everything and filtering client-side. 5. Paginate large result sets. Use limit and token to page through results. The maximum limit is 50. 6. Check `supported_api_engines`. Some looks support avatar_4_quality (higher quality, slower) and avatar_4_turbo (faster). Choose based on your latency and quality requirements. 7. Cache group and look lists. Avatar catalogs change infrequently. Cache the results for a reasonable period to reduce API calls.
Video Backgrounds
HeyGen supports various background types to customize the appearance of your avatar videos.
Background Types
| Type | Description |
|---|---|
color | Solid color background |
image | Static image background |
video | Looping video background |
Color Backgrounds
The simplest option - use a solid color:
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Hello with a colored background!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "color",
value: "#FFFFFF", // White background
},
},
],
};Common Color Values
| Color | Hex Value | Use Case |
|---|---|---|
| White | #FFFFFF | Clean, professional |
| Black | #000000 | Dramatic, cinematic |
| Blue | #0066CC | Corporate, trustworthy |
| Green | #00FF00 | Chroma key (for compositing) |
| Gray | #808080 | Neutral, modern |
Using Transparent/Green Screen
For compositing in post-production:
background: {
type: "color",
value: "#00FF00", // Green screen
}Image Backgrounds
Use a static image as background:
From URL
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Check out this custom background!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "image",
url: "https://example.com/my-background.jpg",
},
},
],
};From Uploaded Asset
First upload your image, then use the asset URL:
// 1. Upload the image
const assetId = await uploadFile("./background.jpg", "image/jpeg");
// 2. Use in video config
const videoConfig = {
video_inputs: [
{
character: {...},
voice: {...},
background: {
type: "image",
url: `https://files.heygen.ai/asset/${assetId}`,
},
},
],
};Image Requirements
- Formats: JPEG, PNG
- Recommended size: Match video dimensions (e.g., 1920x1080 for 1080p)
- Aspect ratio: Should match video aspect ratio
- File size: Under 10MB recommended
Video Backgrounds
Use a looping video as background:
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Dynamic video background!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "video",
url: "https://example.com/background-loop.mp4",
},
},
],
};Video Requirements
- Format: MP4 (H.264 codec recommended)
- Looping: Video will loop if shorter than avatar content
- Audio: Background video audio is typically muted
- File size: Under 100MB recommended
Different Backgrounds Per Scene
Use different backgrounds for each scene:
const multiBackgroundConfig = {
video_inputs: [
// Scene 1: Office background
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Let me start with an introduction.",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "image",
url: "https://example.com/office-bg.jpg",
},
},
// Scene 2: Product showcase
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "closeUp",
},
voice: {
type: "text",
input_text: "Now let me show you our product.",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "image",
url: "https://example.com/product-bg.jpg",
},
},
// Scene 3: Call to action
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Get started today!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "color",
value: "#1a1a2e",
},
},
],
};Background Helper Functions
TypeScript
type BackgroundType = "color" | "image" | "video";
interface Background {
type: BackgroundType;
value?: string;
url?: string;
}
function createColorBackground(hexColor: string): Background {
return { type: "color", value: hexColor };
}
function createImageBackground(imageUrl: string): Background {
return { type: "image", url: imageUrl };
}
function createVideoBackground(videoUrl: string): Background {
return { type: "video", url: videoUrl };
}
// Preset backgrounds
const backgrounds = {
white: createColorBackground("#FFFFFF"),
black: createColorBackground("#000000"),
greenScreen: createColorBackground("#00FF00"),
corporate: createColorBackground("#0066CC"),
};Best Practices
1. Match dimensions - Background should match video dimensions 2. Consider avatar position - Leave space where avatar will appear 3. Use contrasting colors - Ensure avatar is visible against background 4. Optimize file sizes - Compress images/videos for faster processing 5. Test with green screen - For professional post-production workflows 6. Keep backgrounds simple - Avoid distracting elements behind the avatar
Common Issues
Background Not Showing
// Wrong: missing url/value
background: {
type: "image"
}
// Correct
background: {
type: "image",
url: "https://example.com/bg.jpg"
}Aspect Ratio Mismatch
If your background doesn't match the video dimensions, it may be cropped or stretched. Always match your background aspect ratio to your video dimensions:
// For 1920x1080 video
// Use 1920x1080 background image
// For 1080x1920 portrait video
// Use 1080x1920 background imageVideo Background Audio
Background video audio is typically muted to avoid conflicting with the avatar's voice. If you need background music, add it as a separate audio track in post-production.
Video Captions
HeyGen can automatically generate captions (subtitles) for your videos, improving accessibility and engagement.
Enabling Captions
Captions can be enabled when generating a video:
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Hello! This video will have automatic captions.",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
},
],
// Caption settings (availability varies by plan)
caption: true,
};Caption Configuration Options
interface CaptionConfig {
// Enable/disable captions
enabled: boolean;
// Caption style
style?: {
font_family?: string;
font_size?: number;
font_color?: string;
background_color?: string;
position?: "top" | "bottom";
};
// Language for caption generation
language?: string;
}Caption Styles
Basic Captions
const config = {
video_inputs: [...],
caption: true, // Enable with default styling
};Styled Captions
const config = {
video_inputs: [...],
caption: {
enabled: true,
style: {
font_family: "Arial",
font_size: 32,
font_color: "#FFFFFF",
background_color: "rgba(0, 0, 0, 0.7)",
position: "bottom",
},
},
};Multi-Language Captions
For videos in different languages, captions are generated based on the voice language:
// Spanish video with Spanish captions
const spanishConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "¡Hola! Este video tendrá subtítulos en español.",
voice_id: "spanish_voice_id",
},
},
],
caption: true,
};Working with SRT Files
SRT File Format
Standard SRT format:
1
00:00:00,000 --> 00:00:03,000
Hello! This video will have
2
00:00:03,000 --> 00:00:06,000
automatic captions generated.
3
00:00:06,000 --> 00:00:09,000
They sync with the audio.Using Custom SRT
For video translation, you can provide your own SRT:
const translationConfig = {
input_video_id: "original_video_id",
output_languages: ["es-ES", "fr-FR"],
srt_key: "path/to/custom.srt", // Custom SRT file
srt_role: "input", // "input" or "output"
};Caption Positioning
Bottom (Default)
Standard position for most videos:
caption: {
enabled: true,
style: {
position: "bottom"
}
}Top
For videos where bottom space is occupied:
caption: {
enabled: true,
style: {
position: "top"
}
}Accessibility Best Practices
1. Always enable captions - Improves accessibility for deaf/hard-of-hearing viewers 2. Use high contrast - White text on dark background or vice versa 3. Readable font size - At least 24px for standard video, larger for mobile 4. Don't cover important content - Position captions away from key visual elements 5. Sync timing - Ensure captions match audio timing accurately
Caption Helper Functions
interface CaptionStyle {
font_family: string;
font_size: number;
font_color: string;
background_color: string;
position: "top" | "bottom";
}
const captionPresets: Record<string, CaptionStyle> = {
default: {
font_family: "Arial",
font_size: 32,
font_color: "#FFFFFF",
background_color: "rgba(0, 0, 0, 0.7)",
position: "bottom",
},
minimal: {
font_family: "Arial",
font_size: 28,
font_color: "#FFFFFF",
background_color: "transparent",
position: "bottom",
},
bold: {
font_family: "Arial",
font_size: 36,
font_color: "#FFFFFF",
background_color: "rgba(0, 0, 0, 0.9)",
position: "bottom",
},
branded: {
font_family: "Roboto",
font_size: 30,
font_color: "#00D1FF",
background_color: "rgba(26, 26, 46, 0.9)",
position: "bottom",
},
};
function createCaptionConfig(preset: keyof typeof captionPresets) {
return {
enabled: true,
style: captionPresets[preset],
};
}Social Media Caption Considerations
TikTok / Instagram Reels
- Position captions in center or upper portion
- Avoid bottom 20% (covered by UI elements)
- Use larger font sizes for mobile viewing
const socialCaptions = {
enabled: true,
style: {
font_size: 42,
position: "top", // Avoid bottom UI elements
},
};YouTube
- Standard bottom captions work well
- YouTube also supports closed captions upload
- Captions highly recommended (many watch without sound)
- Professional styling preferred
Limitations
- Caption styles may be limited depending on your subscription tier
- Some advanced caption features may require the web interface
- Multi-speaker caption detection may have limited availability
- Caption accuracy depends on audio quality and speech clarity
Integration with Video Translation
When using video translation, captions are automatically handled:
// Video translation includes caption generation
const translationConfig = {
input_video_id: "original_video_id",
output_languages: ["es-ES"],
// Captions generated in target language
};See the video-translate skill for more details on video translation.
Video Dimensions and Resolution
HeyGen supports various video dimensions and aspect ratios to fit different platforms and use cases.
Standard Resolutions
Landscape (16:9)
| Resolution | Width | Height | Use Case |
|---|---|---|---|
| 720p | 1280 | 720 | Standard quality, faster processing |
| 1080p | 1920 | 1080 | High quality, most common |
Portrait (9:16)
| Resolution | Width | Height | Use Case |
|---|---|---|---|
| 720p | 720 | 1280 | Mobile-first content |
| 1080p | 1080 | 1920 | High quality vertical |
Square (1:1)
| Resolution | Width | Height | Use Case |
|---|---|---|---|
| 720p | 720 | 720 | Social media posts |
| 1080p | 1080 | 1080 | High quality square |
Setting Dimensions
TypeScript
// Landscape 1080p
const landscapeConfig = {
video_inputs: [...],
dimension: {
width: 1920,
height: 1080
}
};
// Portrait 1080p
const portraitConfig = {
video_inputs: [...],
dimension: {
width: 1080,
height: 1920
}
};
// Square 1080p
const squareConfig = {
video_inputs: [...],
dimension: {
width: 1080,
height: 1080
}
};curl
# Landscape 1080p
curl -X POST "https://api.heygen.com/v3/videos" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "avatar",
"avatar_id": "YOUR_AVATAR_LOOK_ID",
"script": "Hello!",
"voice_id": "YOUR_VOICE_ID",
"resolution": "1080p",
"aspect_ratio": "16:9"
}'Dimension Helper Functions
type AspectRatio = "16:9" | "9:16" | "1:1" | "4:3" | "4:5";
type Quality = "720p" | "1080p";
interface Dimensions {
width: number;
height: number;
}
function getDimensions(aspectRatio: AspectRatio, quality: Quality): Dimensions {
const configs: Record<AspectRatio, Record<Quality, Dimensions>> = {
"16:9": {
"720p": { width: 1280, height: 720 },
"1080p": { width: 1920, height: 1080 },
},
"9:16": {
"720p": { width: 720, height: 1280 },
"1080p": { width: 1080, height: 1920 },
},
"1:1": {
"720p": { width: 720, height: 720 },
"1080p": { width: 1080, height: 1080 },
},
"4:3": {
"720p": { width: 960, height: 720 },
"1080p": { width: 1440, height: 1080 },
},
"4:5": {
"720p": { width: 576, height: 720 },
"1080p": { width: 864, height: 1080 },
},
};
return configs[aspectRatio][quality];
}
// Usage
const youTubeDimensions = getDimensions("16:9", "1080p");
const tikTokDimensions = getDimensions("9:16", "1080p");
const instagramDimensions = getDimensions("1:1", "1080p");Platform-Specific Recommendations
YouTube
const youtubeConfig = {
video_inputs: [...],
dimension: { width: 1920, height: 1080 }, // 16:9 landscape
};TikTok / Instagram Reels / YouTube Shorts
const shortFormConfig = {
video_inputs: [...],
dimension: { width: 1080, height: 1920 }, // 9:16 portrait
};Instagram Feed Post
const instagramFeedConfig = {
video_inputs: [...],
dimension: { width: 1080, height: 1080 }, // 1:1 square
};const linkedinConfig = {
video_inputs: [...],
dimension: { width: 1920, height: 1080 }, // 16:9 landscape preferred
};Twitter/X
const twitterConfig = {
video_inputs: [...],
dimension: { width: 1280, height: 720 }, // 16:9, 720p is common
};Avatar IV Dimensions
For Avatar IV (photo-based avatars), dimensions are set via orientation:
type VideoOrientation = "portrait" | "landscape" | "square";
function getAvatarIVDimensions(orientation: VideoOrientation): Dimensions {
switch (orientation) {
case "portrait":
return { width: 720, height: 1280 };
case "landscape":
return { width: 1280, height: 720 };
case "square":
return { width: 720, height: 720 };
}
}Custom Dimensions
HeyGen supports custom dimensions within limits:
const customConfig = {
video_inputs: [...],
dimension: {
width: 1600,
height: 900 // Custom 16:9 at non-standard resolution
}
};Dimension Constraints
- Minimum: 128px on any side
- Maximum: 4096px on any side
- Must be even numbers: Both width and height must be divisible by 2
function validateDimensions(width: number, height: number): boolean {
if (width < 128 || height < 128) {
throw new Error("Dimensions must be at least 128px");
}
if (width > 4096 || height > 4096) {
throw new Error("Dimensions cannot exceed 4096px");
}
if (width % 2 !== 0 || height % 2 !== 0) {
throw new Error("Dimensions must be even numbers");
}
return true;
}Resolution vs. Credit Cost
Higher resolutions may consume more credits:
| Resolution | Relative Cost |
|---|---|
| 720p | Base rate |
| 1080p | ~1.5x base rate |
Consider using 720p for drafts and testing, then 1080p for final output.
Background Considerations
Match background image/video dimensions to your video dimensions:
// For 1080p landscape video
const config = {
video_inputs: [
{
character: {...},
voice: {...},
background: {
type: "image",
url: "https://example.com/1920x1080-background.jpg" // Match video dimensions
}
}
],
dimension: { width: 1920, height: 1080 }
};Creating a Video Config Factory
interface VideoConfigOptions {
script: string;
avatarId: string;
voiceId: string;
platform: "youtube" | "tiktok" | "instagram_feed" | "instagram_story" | "linkedin";
quality?: "720p" | "1080p";
}
function createVideoConfig(options: VideoConfigOptions) {
const platformDimensions: Record<string, Dimensions> = {
youtube: { width: 1920, height: 1080 },
tiktok: { width: 1080, height: 1920 },
instagram_feed: { width: 1080, height: 1080 },
instagram_story: { width: 1080, height: 1920 },
linkedin: { width: 1920, height: 1080 },
};
const dimension = platformDimensions[options.platform];
// Scale down for 720p if requested
if (options.quality === "720p") {
dimension.width = Math.round((dimension.width * 720) / 1080);
dimension.height = Math.round((dimension.height * 720) / 1080);
}
return {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: options.avatarId,
avatar_style: "normal",
},
voice: {
type: "text",
input_text: options.script,
voice_id: options.voiceId,
},
},
],
dimension,
};
}
// Usage
const tiktokVideo = createVideoConfig({
script: "Hey everyone! Check this out!",
avatarId: "josh_lite3_20230714",
voiceId: "1bd001e7e50f421d891986aad5158bc8",
platform: "tiktok",
quality: "1080p",
});Photo Avatars
Animate a static photo into a speaking video. Use POST /v3/videos with "type": "image" and a nested image field (AssetInput discriminated union: { "type": "url", "url": "..." } or { "type": "asset_id", "asset_id": "..." }) — this uses Avatar IV technology automatically for high-quality results, no avatar group creation needed.
Direct Image-to-Video (v3 API)
Request Fields
| Field | Type | Req | Description |
|---|---|---|---|
type | string | ✓ | Must be "image" |
image | AssetInput | ✓ | Image to animate. Either { "type": "url", "url": "..." } for a public URL, or { "type": "asset_id", "asset_id": "..." } for an uploaded asset. |
script | string | ✓ | Text for the avatar to speak |
voice_id | string | ✓ | Voice to use |
motion_prompt | string | Natural-language prompt controlling body motion (photo avatars only) | |
expressiveness | string | "high", "medium", or "low" (photo avatars only, defaults to "low") | |
aspect_ratio | string | "16:9" or "9:16" | |
resolution | string | "1080p" or "720p" | |
remove_background | boolean | Remove background from the photo |
AssetInput is a discriminated union on the type field:
- URL variant:
{ "type": "url", "url": "https://example.com/photo.jpg" } - Asset ID variant:
{ "type": "asset_id", "asset_id": "uploaded_asset_id" }
curl Example
curl -X POST "https://api.heygen.com/v3/videos" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "image",
"image": { "type": "url", "url": "https://example.com/portrait.jpg" },
"script": "Hello! This is a high-quality photo avatar video.",
"voice_id": "1bd001e7e50f421d891986aad5158bc8",
"aspect_ratio": "16:9",
"resolution": "1080p"
}'Response:
{
"error": null,
"data": {
"video_id": "abc123def456"
}
}TypeScript Example
type AssetInput =
| { type: "url"; url: string }
| { type: "asset_id"; asset_id: string };
interface PhotoVideoRequest {
type: "image";
image: AssetInput;
script: string;
voice_id: string;
motion_prompt?: string;
expressiveness?: "high" | "medium" | "low";
aspect_ratio?: "16:9" | "9:16";
resolution?: "1080p" | "720p";
remove_background?: boolean;
}
interface VideoResponse {
error: string | null;
data: {
video_id: string;
};
}
interface VideoStatusResponse {
error: string | null;
data: {
video_id: string;
status: "pending" | "processing" | "completed" | "failed";
video_url?: string;
thumbnail_url?: string;
duration?: number;
error?: string;
};
}
async function createPhotoVideo(config: PhotoVideoRequest): Promise<string> {
const response = await fetch("https://api.heygen.com/v3/videos", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
const json: VideoResponse = await response.json();
if (json.error) {
throw new Error(json.error);
}
return json.data.video_id;
}
async function pollVideoStatus(videoId: string): Promise<string> {
for (let i = 0; i < 120; i++) {
const response = await fetch(
`https://api.heygen.com/v3/videos/${videoId}`,
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
const json: VideoStatusResponse = await response.json();
if (json.data.status === "completed") {
return json.data.video_url!;
}
if (json.data.status === "failed") {
throw new Error(json.data.error ?? "Video generation failed");
}
await new Promise((r) => setTimeout(r, 5000));
}
throw new Error("Video generation timed out");
}
// Usage
const videoId = await createPhotoVideo({
type: "image",
image: { type: "url", url: "https://example.com/portrait.jpg" },
script: "Hello! This is a high-quality photo avatar video.",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
aspect_ratio: "16:9",
resolution: "1080p",
});
const videoUrl = await pollVideoStatus(videoId);
console.log("Video ready:", videoUrl);Python Example
import requests
import os
import time
def create_photo_video(
script: str,
voice_id: str,
image_url: str | None = None,
image_asset_id: str | None = None,
motion_prompt: str | None = None,
expressiveness: str | None = None,
aspect_ratio: str = "16:9",
resolution: str = "1080p",
remove_background: bool = False,
) -> str:
api_key = os.environ["HEYGEN_API_KEY"]
# Build the AssetInput discriminated union
if image_url:
image_input = {"type": "url", "url": image_url}
elif image_asset_id:
image_input = {"type": "asset_id", "asset_id": image_asset_id}
else:
raise ValueError("Provide either image_url or image_asset_id")
payload: dict = {
"type": "image",
"image": image_input,
"script": script,
"voice_id": voice_id,
"aspect_ratio": aspect_ratio,
"resolution": resolution,
}
if motion_prompt:
payload["motion_prompt"] = motion_prompt
if expressiveness:
payload["expressiveness"] = expressiveness
if remove_background:
payload["remove_background"] = True
resp = requests.post(
"https://api.heygen.com/v3/videos",
headers={
"X-Api-Key": api_key,
"Content-Type": "application/json",
},
json=payload,
)
data = resp.json()
if data.get("error"):
raise Exception(data["error"])
return data["data"]["video_id"]
def poll_video_status(video_id: str) -> str:
api_key = os.environ["HEYGEN_API_KEY"]
for _ in range(120):
resp = requests.get(
f"https://api.heygen.com/v3/videos/{video_id}",
headers={"X-Api-Key": api_key},
)
data = resp.json()["data"]
if data["status"] == "completed":
return data["video_url"]
if data["status"] == "failed":
raise Exception(data.get("error", "Video generation failed"))
time.sleep(5)
raise Exception("Video generation timed out")
# Usage
video_id = create_photo_video(
image_url="https://example.com/portrait.jpg",
script="Hello! This is a high-quality photo avatar video.",
voice_id="1bd001e7e50f421d891986aad5158bc8",
)
video_url = poll_video_status(video_id)
print(f"Video ready: {video_url}")Motion Prompts and Expressiveness
Use motion_prompt and expressiveness to control how the photo avatar moves and emotes:
curl -X POST "https://api.heygen.com/v3/videos" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "image",
"image": { "type": "url", "url": "https://example.com/portrait.jpg" },
"script": "Let me tell you about our exciting new product launch.",
"voice_id": "1bd001e7e50f421d891986aad5158bc8",
"motion_prompt": "nodding head and smiling, gesturing with hands while speaking",
"expressiveness": "high",
"aspect_ratio": "16:9"
}'| Expressiveness | Description |
|---|---|
low | Subtle, minimal movement (default) |
medium | Natural, moderate movement |
high | Energetic, pronounced movement |
Uploading Images
If your image is not publicly accessible via URL, upload it first using the asset upload endpoint. Then use the returned asset ID with the asset_id variant of the image field.
Endpoint: POST https://upload.heygen.com/v1/asset
curl -X POST "https://upload.heygen.com/v1/asset" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: image/jpeg" \
--data-binary '@./portrait.jpg'Response:
{
"code": 100,
"data": {
"id": "741299e941764988b432ed3a6757878f",
"name": "741299e941764988b432ed3a6757878f",
"file_type": "image",
"url": "https://resource2.heygen.ai/image/.../original.jpg",
"image_key": "image/741299e941764988b432ed3a6757878f/original.jpg"
}
}Then use the asset id in POST /v3/videos:
curl -X POST "https://api.heygen.com/v3/videos" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "image",
"image": { "type": "asset_id", "asset_id": "741299e941764988b432ed3a6757878f" },
"script": "Hello from an uploaded photo!",
"voice_id": "1bd001e7e50f421d891986aad5158bc8"
}'See assets.md for full upload details.
AI-Generated Photo Avatars
Generate synthetic photo avatars from text descriptions instead of uploading a photo.
Endpoint: POST https://api.heygen.com/v2/photo_avatar/photo/generate
Note: This endpoint is still v2 (no v3 equivalent). After generating an image, use the resulting image URL inPOST /v3/videoswith"type": "image"and"image": { "type": "url", "url": "..." }for high-quality video generation.
IMPORTANT: All 8 fields are REQUIRED. The API will reject requests missing any field.
When a user asks to "generate an AI avatar of a professional man", you need to ask for or select values for ALL fields below.
Required Fields (ALL must be provided)
| Field | Type | Allowed Values |
|---|---|---|
name | string | Name for the generated avatar |
age | enum | "Young Adult", "Early Middle Age", "Late Middle Age", "Senior", "Unspecified" |
gender | enum | "Woman", "Man", "Unspecified" |
ethnicity | enum | "White", "Black", "Asian American", "East Asian", "South East Asian", "South Asian", "Middle Eastern", "Pacific", "Hispanic", "Unspecified" |
orientation | enum | "square", "horizontal", "vertical" |
pose | enum | "half_body", "close_up", "full_body" |
style | enum | "Realistic", "Pixar", "Cinematic", "Vintage", "Noir", "Cyberpunk", "Unspecified" |
appearance | string | Text prompt describing appearance (clothing, mood, lighting, etc). Max 1000 chars |
curl Example
curl -X POST "https://api.heygen.com/v2/photo_avatar/photo/generate" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Sarah Product Demo",
"age": "Young Adult",
"gender": "Woman",
"ethnicity": "White",
"orientation": "horizontal",
"pose": "half_body",
"style": "Realistic",
"appearance": "Professional woman with a friendly smile, wearing a navy blue blazer over a white blouse, soft studio lighting, clean neutral background"
}'Response:
{
"error": null,
"data": {
"generation_id": "6a7f7f2795de4599bec7cf1e06babe30"
}
}Check Generation Status
Endpoint: GET https://api.heygen.com/v2/photo_avatar/generation/{generation_id}
The response includes multiple generated images to choose from:
{
"error": null,
"data": {
"id": "6a7f7f2795de4599bec7cf1e06babe30",
"status": "success",
"image_url_list": [
"https://resource2.heygen.ai/photo_generation/.../image1.jpg",
"https://resource2.heygen.ai/photo_generation/.../image2.jpg",
"https://resource2.heygen.ai/photo_generation/.../image3.jpg",
"https://resource2.heygen.ai/photo_generation/.../image4.jpg"
],
"image_key_list": [
"photo_generation/.../image1.jpg",
"photo_generation/.../image2.jpg",
"photo_generation/.../image3.jpg",
"photo_generation/.../image4.jpg"
]
}
}AI Photo to Video (Recommended Flow)
Generate an AI photo, then use the resulting image URL directly in POST /v3/videos:
// 1. Generate AI photo
const genResponse = await fetch(
"https://api.heygen.com/v2/photo_avatar/photo/generate",
{
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Product Demo Host",
age: "Young Adult",
gender: "Woman",
ethnicity: "Unspecified",
orientation: "horizontal",
pose: "half_body",
style: "Realistic",
appearance:
"Professional woman, navy blazer, friendly smile, soft lighting",
}),
}
);
const { data: genData } = await genResponse.json();
const generationId = genData.generation_id;
// 2. Poll until generation completes
let imageUrl: string | undefined;
for (let i = 0; i < 60; i++) {
const statusResp = await fetch(
`https://api.heygen.com/v2/photo_avatar/generation/${generationId}`,
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
const statusJson = await statusResp.json();
if (statusJson.data.status === "success") {
imageUrl = statusJson.data.image_url_list[0];
break;
}
if (statusJson.data.status === "failed") {
throw new Error("Photo generation failed");
}
await new Promise((r) => setTimeout(r, 5000));
}
if (!imageUrl) throw new Error("Photo generation timed out");
// 3. Create video using v3 API with the generated image URL
const videoResp = await fetch("https://api.heygen.com/v3/videos", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "image" as const,
image: { type: "url" as const, url: imageUrl },
script: "Welcome to our product demo!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
aspect_ratio: "16:9",
resolution: "1080p",
}),
});
const { data: videoData } = await videoResp.json();
console.log("Video ID:", videoData.video_id);Pre-Generation Checklist
Before calling the AI generation API, ensure you have values for ALL fields:
| # | Field | Question to Ask / Default |
|---|---|---|
| 1 | name | What should we call this avatar? |
| 2 | age | Young Adult / Early Middle Age / Late Middle Age / Senior? |
| 3 | gender | Woman / Man? |
| 4 | ethnicity | Which ethnicity? (see enum values above) |
| 5 | orientation | horizontal (landscape) / vertical (portrait) / square? |
| 6 | pose | half_body (recommended) / close_up / full_body? |
| 7 | style | Realistic (recommended) / Cinematic / other? |
| 8 | appearance | Describe clothing, expression, lighting, background |
If the user only provides a vague request like "create a professional looking man", ask them to specify the missing fields OR make reasonable defaults (e.g., "Early Middle Age", "Realistic" style, "half_body" pose, "horizontal" orientation).
Appearance Prompt Tips
The appearance field is a text prompt - be descriptive:
Good prompts:
- "Professional woman with shoulder-length brown hair, wearing a light blue button-down shirt, warm friendly smile, soft studio lighting, clean white background"
- "Young man with short black hair, casual tech startup style, wearing a dark hoodie, confident expression, modern office background with plants"
Avoid:
- Vague descriptions: "a nice person"
- Conflicting attributes
- Requesting specific real people
Legacy: Creating Photo Avatar Groups
Note: This is the legacy workflow. For most use cases, prefer the direct"type": "image"approach withPOST /v3/videosdescribed above. The avatar group workflow is more complex and does not produce better results.
The legacy workflow is: Upload Image -> Create Avatar Group -> Use in Video
Step 1: Upload the Image
Upload a portrait photo using the asset upload endpoint (see Uploading Images above).
Step 2: Create Photo Avatar Group
Use the image_key from the upload response to create a photo avatar group.
Endpoint: POST https://api.heygen.com/v2/photo_avatar/avatar_group/create
curl -X POST "https://api.heygen.com/v2/photo_avatar/avatar_group/create" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image_key": "image/741299e941764988b432ed3a6757878f/original.jpg",
"name": "My Photo Avatar"
}'| Field | Type | Req | Description |
|---|---|---|---|
image_key | string | ✓ | S3 image key from upload response |
name | string | ✓ | Display name for the avatar |
generation_id | string | If using AI-generated photo |
Response:
{
"error": null,
"data": {
"id": "045c260bc0364727b2cbe50442c3a5bf",
"image_url": "https://files2.heygen.ai/...",
"created_at": 1771798135.777256,
"name": "My Photo Avatar",
"status": "pending",
"group_id": "045c260bc0364727b2cbe50442c3a5bf",
"is_motion": false,
"business_type": "uploaded"
}
}Step 3: Wait for Processing
Poll GET /v2/photo_avatar/{id} until status is "completed".
Step 4: Use in Video Generation
Use the photo avatar look ID as avatar_id in POST /v3/videos:
curl -X POST "https://api.heygen.com/v3/videos" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "avatar",
"avatar_id": "045c260bc0364727b2cbe50442c3a5bf",
"script": "Hello! This is my photo avatar speaking.",
"voice_id": "1bd001e7e50f421d891986aad5158bc8",
"aspect_ratio": "16:9"
}'Adding Photos to an Existing Group
Endpoint: POST https://api.heygen.com/v2/photo_avatar/avatar_group/add
| Field | Type | Req | Description |
|---|---|---|---|
group_id | string | ✓ | Existing avatar group ID |
image_keys | string[] | ✓ | Array of S3 image keys |
name | string | ✓ | Name for the new looks |
Training a Photo Avatar Group
Endpoint: POST https://api.heygen.com/v2/photo_avatar/train
curl -X POST "https://api.heygen.com/v2/photo_avatar/train" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"group_id": "045c260bc0364727b2cbe50442c3a5bf"}'Check training status: GET /v2/photo_avatar/train/status/{group_id}
Managing Photo Avatars
Get Photo Avatar Details
Endpoint: GET https://api.heygen.com/v2/photo_avatar/{id}
async function getPhotoAvatar(id: string): Promise<any> {
const response = await fetch(
`https://api.heygen.com/v2/photo_avatar/${id}`,
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
return response.json();
}Delete Photo Avatar
Endpoint: DELETE https://api.heygen.com/v2/photo_avatar/{id}
async function deletePhotoAvatar(id: string): Promise<void> {
const response = await fetch(
`https://api.heygen.com/v2/photo_avatar/${id}`,
{
method: "DELETE",
headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! },
}
);
if (!response.ok) {
throw new Error("Failed to delete photo avatar");
}
}Delete Photo Avatar Group
Endpoint: DELETE https://api.heygen.com/v2/photo_avatar_group/{group_id}
async function deletePhotoAvatarGroup(groupId: string): Promise<void> {
const response = await fetch(
`https://api.heygen.com/v2/photo_avatar_group/${groupId}`,
{
method: "DELETE",
headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! },
}
);
if (!response.ok) {
throw new Error("Failed to delete photo avatar group");
}
}Photo Requirements
Technical Requirements
| Aspect | Requirement |
|---|---|
| Format | JPEG, PNG |
| Resolution | Minimum 512x512px |
| File size | Under 10MB |
| Face visibility | Clear, front-facing |
Quality Guidelines
1. Lighting - Even, natural lighting on face 2. Expression - Neutral or slight smile 3. Background - Simple, uncluttered 4. Face position - Centered, not cut off 5. Clarity - Sharp, in focus 6. Angle - Straight-on or slight angle
Best Practices
1. Use `POST /v3/videos` with `"type": "image"` for the best quality - This is the recommended path and automatically uses Avatar IV technology 2. Use `"image": { "type": "asset_id", ... }` for private images - Upload first via POST upload.heygen.com/v1/asset, then pass the asset ID in the image field 3. Use high-quality photos - Better input = better output 4. Front-facing portraits work best - Clear face visibility produces the most natural animation 5. Use `motion_prompt` for natural movement - Describe the body language you want (e.g., "nodding and gesturing") 6. Start with `expressiveness: "low"` - Increase to "medium" or "high" only if you want more energetic movement 7. Avoid the legacy avatar group workflow - The direct "type": "image" approach is simpler and produces equivalent or better results
API Reference
| Endpoint | Method | Description |
|---|---|---|
/v3/videos | POST | Recommended - Create video from photo with "type": "image" and nested image AssetInput |
/v3/videos/{video_id} | GET | Poll video generation status |
upload.heygen.com/v1/asset | POST | Upload image (returns asset id and image_key) |
/v2/photo_avatar/photo/generate | POST | Generate AI photo from text description |
/v2/photo_avatar/generation/{id} | GET | Check AI photo generation status |
/v2/photo_avatar/{id} | GET | Get photo avatar details/status |
/v2/photo_avatar/{id} | DELETE | Delete photo avatar |
/v2/photo_avatar_group/{id} | DELETE | Delete avatar group |
/v2/photo_avatar/avatar_group/create | POST | Legacy: Create photo avatar group from image_key |
/v2/photo_avatar/avatar_group/add | POST | Legacy: Add photos to existing group |
/v2/photo_avatar/train | POST | Legacy: Train avatar group |
/v2/photo_avatar/train/status/{group_id} | GET | Legacy: Check training status |
Limitations
- Photo quality significantly affects output
- Side-profile photos have limited support
- Full-body photos may not animate properly
- Some expressions may look unnatural
- Processing time varies by complexity
HeyGen Quota and Credits
HeyGen uses a credit-based system for video generation. Understanding quota management helps prevent failed video generation requests.
Checking Remaining Quota
curl
curl -X GET "https://api.heygen.com/v2/user/remaining_quota" \
-H "X-Api-Key: $HEYGEN_API_KEY"TypeScript
interface QuotaResponse {
error: null | string;
data: {
remaining_quota: number;
used_quota: number;
};
}
const response = await fetch("https://api.heygen.com/v2/user/remaining_quota", {
headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! },
});
const { data }: QuotaResponse = await response.json();
console.log(`Remaining credits: ${data.remaining_quota}`);Python
import requests
import os
response = requests.get(
"https://api.heygen.com/v2/user/remaining_quota",
headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]}
)
data = response.json()["data"]
print(f"Remaining credits: {data['remaining_quota']}")Response Format
{
"error": null,
"data": {
"remaining_quota": 450,
"used_quota": 50
}
}Credit Consumption
Different operations consume different amounts of credits:
| Operation | Credit Cost | Notes |
|---|---|---|
| Standard video (1 min) | ~1 credit per minute | Varies by resolution |
| 720p video | Base rate | Standard quality |
| 1080p video | ~1.5x base rate | Higher quality |
| Video translation | Varies | Depends on video length |
| Streaming avatar | Per session | Real-time usage |
Pre-Generation Quota Check
Always verify sufficient quota before generating videos:
async function generateVideoWithQuotaCheck(videoConfig: VideoConfig) {
// Check quota first
const quotaResponse = await fetch(
"https://api.heygen.com/v2/user/remaining_quota",
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
const { data: quota } = await quotaResponse.json();
// Estimate required credits (rough estimate: 1 credit per minute)
const estimatedMinutes = videoConfig.estimatedDuration / 60;
const requiredCredits = Math.ceil(estimatedMinutes);
if (quota.remaining_quota < requiredCredits) {
throw new Error(
`Insufficient credits. Need ${requiredCredits}, have ${quota.remaining_quota}`
);
}
// Proceed with video generation
return generateVideo(videoConfig);
}Quota Management Best Practices
1. Monitor Usage Regularly
async function logQuotaUsage() {
const response = await fetch(
"https://api.heygen.com/v2/user/remaining_quota",
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
const { data } = await response.json();
console.log({
remaining: data.remaining_quota,
used: data.used_quota,
percentUsed: (
(data.used_quota / (data.remaining_quota + data.used_quota)) *
100
).toFixed(1),
});
}2. Set Up Alerts
const QUOTA_WARNING_THRESHOLD = 50;
async function checkQuotaWithAlert() {
const response = await fetch(
"https://api.heygen.com/v2/user/remaining_quota",
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
const { data } = await response.json();
if (data.remaining_quota < QUOTA_WARNING_THRESHOLD) {
// Send alert (email, Slack, etc.)
await sendAlert(`Low HeyGen quota: ${data.remaining_quota} credits remaining`);
}
return data;
}3. Use Test Mode for Development
When available, use test mode to avoid consuming credits during development:
const videoConfig = {
test: true, // Use test mode during development
video_inputs: [...],
};
// Test videos may have watermarks but don't consume creditsSubscription Tiers
Different subscription tiers have different quota allocations and features:
| Tier | Features |
|---|---|
| Free | Limited credits, basic features |
| Creator | More credits, standard avatars |
| Team | Higher limits, team collaboration |
| Enterprise | Custom limits, API access, priority support |
API access typically requires Enterprise tier or higher.
Error Handling for Quota Issues
async function handleQuotaError(error: any) {
if (error.message.includes("quota") || error.message.includes("credit")) {
console.error("Quota exceeded. Consider:");
console.error("1. Upgrading your subscription");
console.error("2. Waiting for quota reset");
console.error("3. Purchasing additional credits");
// Check current quota
const quota = await getQuota();
console.error(`Current remaining: ${quota.remaining_quota}`);
}
throw error;
}HeyGen + Remotion Integration
This guide covers workflows for generating HeyGen avatar videos and using them in Remotion compositions.
Quick Start
// 1. Get avatar with default voice
const avatar = await getAvatarDetails(avatarId);
// 2. Generate video (MP4 with background - most common)
const videoId = await generateVideo({
video_inputs: [{
character: { type: "avatar", avatar_id: avatar.id, avatar_style: "normal" },
voice: { type: "text", input_text: script, voice_id: avatar.default_voice_id },
background: { type: "color", value: "#1a1a2e" },
}],
dimension: { width: 1920, height: 1080 },
});
// 3. Poll for completion (10-15+ min)
// 4. Use in Remotion with motion graphics overlaid on topOverview
A typical workflow: 1. Generate avatar video with HeyGen 2. Wait for completion and get video URL 3. Download or use URL directly in Remotion 4. Compose with other elements (backgrounds, overlays, animations)
Choosing the Right Output Format
| Your Composition | Recommended | Why |
|---|---|---|
| Avatar as presenter with overlays | MP4 + background | Simpler, overlays go on top |
| Loom-style (avatar over screen recording) | WebM + closeUp, mask in Remotion | Need transparency, apply circle mask in CSS |
| Avatar overlaid ON other video/content | WebM (transparent) | Need to see through to content behind |
| Full-screen avatar | MP4 + background | Standard approach |
Use MP4 with background for most cases. Use WebM when you need to see content behind the avatar.
Note: WebM only supports normal and closeUp styles. For circular framing, use CSS border-radius: 50% in Remotion.
Recommended: Parallel Development Workflow
HeyGen video generation takes 10-15+ minutes. Don't wait - work in parallel:
1. Start HeyGen generation - save video_id to a file, exit immediately 2. Build Remotion composition - use a placeholder or the avatar's preview_video_url (a short loop) 3. Check HeyGen status periodically or when done building 4. Swap placeholder for real video URL once ready
Estimate duration from script: ~150 words/minute speech rate, so wordCount / 150 * 60 * fps gives approximate frames.
Composition tip: Design components to work with or without the avatar video, so motion graphics can be tested independently.
Dimension Alignment
Critical: Match HeyGen output dimensions to your Remotion composition.
Common Dimension Presets
// Shared dimension constants for both HeyGen and Remotion
const DIMENSIONS = {
landscape_1080p: { width: 1920, height: 1080 },
landscape_720p: { width: 1280, height: 720 },
portrait_1080p: { width: 1080, height: 1920 },
portrait_720p: { width: 720, height: 1280 },
square_1080p: { width: 1080, height: 1080 },
square_720p: { width: 720, height: 720 },
} as const;
type DimensionPreset = keyof typeof DIMENSIONS;HeyGen Video Generation
// Generate HeyGen video with specific dimensions
async function generateHeyGenVideo(
script: string,
avatarId: string,
voiceId: string,
preset: DimensionPreset
): Promise<string> {
const dimension = DIMENSIONS[preset];
const response = await fetch("https://api.heygen.com/v3/videos", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "avatar" as const,
avatar_id: avatarId,
script,
voice_id: voiceId,
background: {
type: "color",
value: "#00FF00", // Green screen for compositing
},
resolution: preset === "landscape_1080p" ? "1080p" : "720p",
aspect_ratio: preset.startsWith("portrait") ? "9:16" : "16:9",
}),
});
const { data } = await response.json();
return data.video_id;
}Remotion Composition Setup
// remotion/src/Root.tsx
import { Composition } from "remotion";
import { AvatarComposition } from "./AvatarComposition";
const DIMENSIONS = {
landscape_1080p: { width: 1920, height: 1080 },
// ... same as above
};
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="AvatarVideo"
component={AvatarComposition}
durationInFrames={300} // Will be set dynamically
fps={30}
width={DIMENSIONS.landscape_1080p.width}
height={DIMENSIONS.landscape_1080p.height}
defaultProps={{
avatarVideoUrl: "",
}}
/>
</>
);
};Generating Avatar Video for Remotion
Standard: MP4 with Background
Most Remotion compositions work best with MP4 + background. Overlays and motion graphics go on top:
async function generateAvatarForRemotion(
script: string,
avatarId: string,
voiceId: string,
options: {
style?: "normal" | "closeUp" | "circle";
backgroundColor?: string;
} = {}
): Promise<string> {
const { style = "normal", backgroundColor = "#1a1a2e" } = options;
const response = await fetch("https://api.heygen.com/v3/videos", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "avatar" as const,
avatar_id: avatarId,
script,
voice_id: voiceId,
background: {
type: "color",
value: backgroundColor,
},
resolution: "1080p",
aspect_ratio: "16:9",
}),
});
const { data } = await response.json();
return data.video_id;
}Transparent Background (WebM)
Only use when you need to see content behind the avatar (e.g., avatar overlaid on screen recording):
// Use POST /v3/videos with remove_background: true for transparent background
const response = await fetch("https://api.heygen.com/v3/videos", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "avatar" as const,
avatar_id: avatarId,
script,
voice_id: voiceId,
remove_background: true, // Transparent background
resolution: "1080p",
aspect_ratio: "16:9",
}),
});Using HeyGen Video in Remotion
Important: Use OffthreadVideo for Frame-Accurate Rendering
Always use `OffthreadVideo` instead of `Video` for HeyGen avatar videos. The basic Video component uses the browser's video decoder which isn't frame-accurate, causing jitter during rendering. OffthreadVideo extracts frames via FFmpeg for smooth, accurate playback.
OffthreadVideo is included in the core remotion package - no additional install needed.
Basic Usage
// remotion/src/AvatarComposition.tsx
import { OffthreadVideo, useVideoConfig } from "remotion";
interface AvatarCompositionProps {
avatarVideoUrl: string;
}
export const AvatarComposition: React.FC<AvatarCompositionProps> = ({
avatarVideoUrl,
}) => {
return (
<div style={{ flex: 1, backgroundColor: "#1a1a2e" }}>
<OffthreadVideo
src={avatarVideoUrl}
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
</div>
);
};WebM with Transparent Background (Recommended)
Using WebM from /v1/video.webm (this endpoint remains at v1 — no v3 equivalent yet) - no chroma keying needed:
import { OffthreadVideo, AbsoluteFill, Sequence } from "remotion";
export const AvatarWithMotionGraphics: React.FC<{
avatarWebmUrl: string
}> = ({ avatarWebmUrl }) => {
return (
<AbsoluteFill>
{/* Layer 1: Your background/content */}
<AbsoluteFill style={{ backgroundColor: "#1a1a2e" }}>
<YourMotionGraphics />
</AbsoluteFill>
{/* Layer 2: Avatar with transparent background - use OffthreadVideo for frame-accurate rendering */}
<OffthreadVideo
src={avatarWebmUrl}
transparent
style={{
position: "absolute",
bottom: 0,
right: 0,
width: "50%",
height: "auto",
}}
/>
{/* Layer 3: Overlays on top of avatar */}
<Sequence from={30}>
<AnimatedTitle text="Welcome!" />
</Sequence>
</AbsoluteFill>
);
};Loom-Style: Circle Avatar Over Screen Recording
Use closeUp style + WebM, then apply circular mask in Remotion:
import { OffthreadVideo, AbsoluteFill } from "remotion";
export const LoomStyleComposition: React.FC<{
screenRecordingUrl: string;
avatarWebmUrl: string; // Generated with avatar_style: "closeUp" via /v1/video.webm (still v1)
}> = ({ screenRecordingUrl, avatarWebmUrl }) => {
return (
<AbsoluteFill>
{/* Screen recording fills the frame */}
<OffthreadVideo src={screenRecordingUrl} style={{ width: "100%", height: "100%" }} />
{/* Avatar with circular mask - transparent bg shows screen behind */}
<OffthreadVideo
src={avatarWebmUrl}
transparent
style={{
position: "absolute",
bottom: 40,
left: 40,
width: 180,
height: 180,
borderRadius: "50%", // Circular mask applied in CSS
overflow: "hidden",
objectFit: "cover",
}}
/>
</AbsoluteFill>
);
};Note: WebM doesn't support circle style - use normal or closeUp and apply circular masking via CSS.
Legacy: Green Screen with Chroma Key
If using MP4 with green background (not recommended - use WebM instead):
// Note: True chroma key requires WebGL or post-processing
// WebM transparent background is much simpler
<OffthreadVideo
src={avatarVideoUrl}
style={{
mixBlendMode: "multiply", // Basic compositing only
}}
/>Layered Composition
import { OffthreadVideo, Sequence, useVideoConfig, Img } from "remotion";
interface LayeredAvatarProps {
avatarVideoUrl: string;
backgroundUrl: string;
logoUrl: string;
title: string;
}
export const LayeredAvatarComposition: React.FC<LayeredAvatarProps> = ({
avatarVideoUrl,
backgroundUrl,
logoUrl,
title,
}) => {
const { fps } = useVideoConfig();
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
{/* Layer 1: Background */}
<Img
src={backgroundUrl}
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
{/* Layer 2: Avatar video - use OffthreadVideo to prevent jitter */}
<OffthreadVideo
src={avatarVideoUrl}
style={{
position: "absolute",
bottom: 0,
right: 0,
width: "40%",
height: "auto",
}}
/>
{/* Layer 3: Title (appears after 1 second) */}
<Sequence from={fps}>
<div
style={{
position: "absolute",
top: 50,
left: 50,
color: "white",
fontSize: 48,
fontWeight: "bold",
}}
>
{title}
</div>
</Sequence>
{/* Layer 4: Logo */}
<Img
src={logoUrl}
style={{
position: "absolute",
top: 20,
right: 20,
width: 100,
height: "auto",
}}
/>
</div>
);
};Complete Workflow
Generate and Compose
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
async function generateAvatarVideoForRemotion(
script: string,
outputPath: string
) {
// 1. Generate HeyGen video
console.log("Generating HeyGen avatar video...");
const videoId = await generateHeyGenVideo(
script,
"josh_lite3_20230714",
"1bd001e7e50f421d891986aad5158bc8",
"landscape_1080p"
);
// 2. Wait for completion
console.log("Waiting for HeyGen video...");
const avatarVideoUrl = await waitForVideo(videoId);
console.log(`HeyGen video ready: ${avatarVideoUrl}`);
// 3. Get video duration for Remotion
const avatarDuration = await getVideoDuration(avatarVideoUrl);
const durationInFrames = Math.ceil(avatarDuration * 30); // 30 fps
// 4. Bundle Remotion project
console.log("Bundling Remotion project...");
const bundleLocation = await bundle({
entryPoint: "./remotion/src/index.ts",
});
// 5. Select composition
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "AvatarVideo",
inputProps: {
avatarVideoUrl,
},
});
// 6. Render final video
console.log("Rendering final composition...");
await renderMedia({
composition: {
...composition,
durationInFrames,
},
serveUrl: bundleLocation,
codec: "h264",
outputLocation: outputPath,
inputProps: {
avatarVideoUrl,
},
});
console.log(`Final video rendered: ${outputPath}`);
return outputPath;
}Dynamic Duration with calculateMetadata
// remotion/src/AvatarComposition.tsx
import { CalculateMetadataFunction } from "remotion";
export const calculateAvatarMetadata: CalculateMetadataFunction<
AvatarCompositionProps
> = async ({ props }) => {
// Fetch video duration from HeyGen video
const duration = await getVideoDurationInSeconds(props.avatarVideoUrl);
return {
durationInFrames: Math.ceil(duration * 30),
fps: 30,
width: 1920,
height: 1080,
};
};
// In Root.tsx
<Composition
id="AvatarVideo"
component={AvatarComposition}
calculateMetadata={calculateAvatarMetadata}
defaultProps={{
avatarVideoUrl: "",
}}
/>Best Practices
1. Use Green Screen for Flexibility
Generate HeyGen videos with green screen background when you want to composite:
background: {
type: "color",
value: "#00FF00", // Pure green for chroma key
}2. Match Frame Rates
HeyGen default is 25 fps. Consider this when setting Remotion fps:
// Option 1: Match HeyGen's 25 fps
fps: 25
// Option 2: Use 30 fps with playback rate adjustment
<OffthreadVideo
src={avatarVideoUrl}
playbackRate={25/30} // Slow down slightly to match
/>3. URL vs Download: When to Use Each
Use URL directly when:
- Previewing in Remotion Studio (
npm run dev) - URL won't expire before render completes
- You want faster iteration during development
// Direct URL usage - simpler, faster for dev
<OffthreadVideo src={avatarVideoUrl} />Download first when:
- URL has expiration (HeyGen URLs expire after ~24 hours)
- Rendering will happen later or repeatedly
- Network reliability is a concern
- You need offline rendering
// Download with retry for reliability
async function downloadVideoWithRetry(
url: string,
outputPath: string,
maxRetries = 5
): Promise<string> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const buffer = await response.arrayBuffer();
await fs.promises.writeFile(outputPath, Buffer.from(buffer));
return outputPath;
} catch (error) {
const delay = 2000 * Math.pow(2, attempt);
console.log(`Retry ${attempt + 1}/${maxRetries} in ${delay}ms...`);
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error("Download failed after retries");
}
// Use local file in Remotion
const localPath = await downloadVideoWithRetry(avatarVideoUrl, "./public/avatar.mp4");Hybrid approach (recommended for production):
// Save both URL and local path in metadata
const metadata = {
videoUrl: result.video_url, // For quick preview
localPath: "./public/avatar.mp4", // For reliable rendering
expiresAt: Date.now() + 24 * 60 * 60 * 1000, // URL expiration
};
// In Remotion component, prefer local if available
const videoSrc = fs.existsSync(localPath) ? staticFile("avatar.mp4") : avatarVideoUrl;4. Handle Avatar Positioning
Common avatar positions in compositions:
const AVATAR_POSITIONS = {
fullscreen: { width: "100%", height: "100%", position: "center" },
bottomRight: { width: "40%", bottom: 0, right: 0 },
bottomLeft: { width: "40%", bottom: 0, left: 0 },
pictureInPicture: { width: "25%", bottom: 20, right: 20 },
leftThird: { width: "33%", left: 0, height: "100%" },
};Output Formats
HeyGen Output
- Format: MP4 (H.264)
- Audio: AAC
- Resolution: As specified in request
Remotion Output
- Codec: H.264 (default), VP8, VP9, ProRes
- Match or exceed HeyGen quality settings
await renderMedia({
codec: "h264",
crf: 18, // High quality
// ...
});Troubleshooting
Video Not Playing in Remotion
1. Check URL accessibility (CORS issues) 2. Verify video format compatibility 3. Try downloading locally first
Dimension Mismatch
Ensure both HeyGen and Remotion use identical dimensions:
// Shared config
const VIDEO_CONFIG = {
width: 1920,
height: 1080,
fps: 30,
};
// HeyGen
dimension: { width: VIDEO_CONFIG.width, height: VIDEO_CONFIG.height }
// Remotion
<Composition width={VIDEO_CONFIG.width} height={VIDEO_CONFIG.height} />Video Jitter During Rendering
If avatar video appears jittery or stuttery in rendered output:
1. Use `OffthreadVideo` instead of `Video` - The basic Video component uses the browser's video decoder which isn't frame-accurate 2. Update imports (no additional install needed - it's in core remotion):
// Before (causes jitter)
import { Video } from "remotion";
// After (frame-accurate)
import { OffthreadVideo } from "remotion";3. For WebM with transparency, add the transparent prop:
<OffthreadVideo src={avatarWebmUrl} transparent />Audio Sync Issues
If avatar audio drifts:
- Verify source video frame rate
- Check for encoding issues
- Consider re-encoding with consistent settings
Writing Scripts for HeyGen Videos
Scripts for AI avatar videos have different requirements than scripts for human presenters. This guide covers best practices for writing scripts that sound natural and render well.
Script Basics
Speech Rate and Duration
Typical speech is approximately 150 words per minute at normal speed (1.0x). Use this as a rough estimate for planning script length.
| Script Length | Approximate Duration |
|---|---|
| 75 words | 30 seconds |
| 150 words | 1 minute |
| 300 words | 2 minutes |
| 450 words | 3 minutes |
| 750 words | 5 minutes |
// Estimate video duration from script
function estimateDuration(script: string, speed: number = 1.0): number {
const words = script.split(/\s+/).filter(w => w.length > 0).length;
const wordsPerMinute = 150 * speed;
return words / wordsPerMinute * 60; // seconds
}
// Estimate frames for Remotion
function estimateFrames(script: string, fps: number = 30, speed: number = 1.0): number {
const durationSeconds = estimateDuration(script, speed);
return Math.ceil(durationSeconds * fps);
}Sentence Structure
Keep sentences short. AI voices handle shorter sentences more naturally.
| Guideline | Example |
|---|---|
| Good: 10-20 words per sentence | "Our platform helps teams collaborate. It syncs in real-time across all devices." |
| Avoid: 30+ word run-on sentences | "Our platform helps teams collaborate more effectively by providing real-time synchronization across all devices while also offering offline support and automatic conflict resolution." |
Punctuation Affects Delivery
| Punctuation | Effect |
|---|---|
Period . | Full stop, natural pause |
Comma , | Brief pause |
Question mark ? | Rising intonation |
Exclamation ! | Emphasis (use sparingly) |
Ellipsis ... | Trailing off, slight pause |
Adding Pauses with Break Tags
Use SSML-style <break> tags for precise pause control:
<break time="Xs"/>Where X is seconds (e.g., 0.5s, 1s, 1.5s, 2s).
Formatting Rules
| Rule | Correct | Incorrect |
|---|---|---|
| Space before tag | word <break time="1s"/> | word<break time="1s"/> |
| Space after tag | <break time="1s"/> word | <break time="1s"/>word |
| Use seconds with "s" | <break time="1.5s"/> | <break time="1500ms"/> |
| Self-closing tag | <break time="1s"/> | <break time="1s"></break> |
When to Use Pauses
| Situation | Recommended Pause | Example |
|---|---|---|
| After greeting | 0.5-1s | Hello! <break time="0.5s"/> Welcome to... |
| Between sections | 1-1.5s | ...that's feature one. <break time="1.5s"/> Now let's look at... |
| Before key point | 0.5s | The most important thing is <break time="0.5s"/> consistency. |
| For dramatic effect | 1.5-2s | And the winner is... <break time="2s"/> you! |
| After question | 1s | Sound good? <break time="1s"/> Let's get started. |
| List items | 0.5s | First, speed. <break time="0.5s"/> Second, reliability. |
Pause Duration Guide
| Duration | Feel | Use For |
|---|---|---|
| 0.3-0.5s | Brief breath | Between clauses, light emphasis |
| 0.5-1s | Natural pause | Sentence breaks, transitions |
| 1-1.5s | Deliberate pause | Section changes, setup for key points |
| 1.5-2s | Dramatic | Reveals, important announcements |
| 2s+ | Long pause | Use sparingly, can feel unnatural |
Examples
// Section transitions
const script = `
Welcome to our product overview. <break time="1s"/>
Today I'll cover three key features. <break time="0.5s"/>
First, let's look at the dashboard. <break time="1.5s"/>
As you can see, it's designed for simplicity. <break time="0.5s"/>
Every action is just one click away.
`;
// Building suspense
const announcement = `
We've been working on something special. <break time="1s"/>
After months of development... <break time="1.5s"/>
I'm excited to announce <break time="0.5s"/> our new AI assistant.
`;
// List with rhythm
const features = `
Our platform offers three core benefits. <break time="0.5s"/>
Speed. <break time="0.5s"/>
Reliability. <break time="0.5s"/>
And simplicity. <break time="1s"/>
Let me show you each one.
`;Consecutive Breaks
Multiple consecutive breaks are combined:
// These two breaks:
"Hello <break time=\"1s\"/> <break time=\"0.5s\"/> world"
// Are treated as a single 1.5s pauseScript Structure Templates
Product Demo (60 seconds, ~150 words)
const productDemo = `
Hi, I'm [Name], and I'm excited to show you [Product]. <break time="1s"/>
[Product] helps you [main benefit] in just [timeframe]. <break time="0.5s"/>
Here's how it works. <break time="1s"/>
First, [step 1]. <break time="0.5s"/>
Then, [step 2]. <break time="0.5s"/>
And finally, [step 3]. <break time="1s"/>
What used to take [old time] now takes [new time]. <break time="0.5s"/>
Ready to get started? <break time="0.5s"/>
Visit [website] today.
`;Tutorial Introduction (90 seconds, ~225 words)
const tutorial = `
Welcome to this tutorial on [topic]. <break time="0.5s"/>
I'm [Name], and I'll guide you through everything you need to know. <break time="1s"/>
By the end of this video, you'll be able to [outcome 1], [outcome 2], and [outcome 3]. <break time="1s"/>
Let's start with the basics. <break time="1.5s"/>
[Section 1 content - 2-3 sentences] <break time="1s"/>
Now that you understand [concept], let's move on to [next topic]. <break time="1.5s"/>
[Section 2 content - 2-3 sentences] <break time="1s"/>
And finally, let's cover [last topic]. <break time="1.5s"/>
[Section 3 content - 2-3 sentences] <break time="1s"/>
That's everything you need to get started. <break time="0.5s"/>
If you have questions, leave a comment below. <break time="0.5s"/>
Thanks for watching!
`;Announcement (30 seconds, ~75 words)
const announcement = `
Big news! <break time="0.5s"/>
We're thrilled to announce [announcement]. <break time="1s"/>
This means [benefit 1] and [benefit 2] for all our users. <break time="0.5s"/>
Starting [date], you'll be able to [new capability]. <break time="1s"/>
Head to [location] to learn more. <break time="0.5s"/>
We can't wait to hear what you think!
`;Writing Tips for AI Voices
Do
- Write conversationally - Read it aloud to check flow
- Use contractions - "We're" not "We are", "It's" not "It is"
- Break up long sentences - Split at natural pause points
- Spell out abbreviations - "API" may sound like "a pee eye"
- Add pauses for emphasis - Guide the listener's attention
- End sections clearly - Don't trail off mid-thought
Avoid
- Jargon without context - Explain technical terms
- Long parentheticals - Move to separate sentences
- Ambiguous pronunciations - "read" (present) vs "read" (past)
- Excessive exclamation marks - One per script is usually enough
- Run-on sentences - Break into digestible chunks
- Dense information - Space out facts with pauses
Pronunciation Hints
For words that might be mispronounced, spell phonetically or add hints:
// Technical terms
const script1 = "Our API (A-P-I) handles authentication...";
// Ambiguous words
const script2 = "I read (red) the documentation yesterday...";
// Brand names
const script3 = "Welcome to HeyGen (hey-jen)...";Multi-Scene Scripts
When splitting scripts across scenes (for different backgrounds or avatars):
const multiSceneVideo = {
video_inputs: [
{
// Scene 1: Introduction
character: { type: "avatar", avatar_id: "josh_lite3_20230714", avatar_style: "normal" },
voice: {
type: "text",
input_text: "Welcome to our quarterly update. <break time=\"1s\"/> I'm Josh, and I'll walk you through the highlights.",
voice_id: "voice_id_here",
},
background: { type: "color", value: "#1a1a2e" },
},
{
// Scene 2: Main content (different background)
character: { type: "avatar", avatar_id: "josh_lite3_20230714", avatar_style: "normal" },
voice: {
type: "text",
input_text: "Let's start with revenue. <break time=\"0.5s\"/> We grew 25 percent quarter over quarter. <break time=\"1s\"/> Here's what drove that growth.",
voice_id: "voice_id_here",
},
background: { type: "image", url: "https://..." },
},
// ... more scenes
],
};Scene Transition Tips
- End each scene with a complete thought
- Start new scenes with brief context
- Maintain consistent tone across scenes
- Use pauses at scene starts to let visuals register
Testing Your Script
Before generating the full video:
1. Read aloud - Time yourself, check for awkward phrasing 2. Count words - Verify expected duration 3. Check break tags - Ensure proper spacing and syntax 4. Preview with short clip - Generate a 10-second test if unsure about pronunciation
// Test a small portion first
const testScript = script.split('.').slice(0, 2).join('.') + '.';
const testVideoId = await generateVideo({
video_inputs: [{
character: { type: "avatar", avatar_id: avatarId, avatar_style: "normal" },
voice: { type: "text", input_text: testScript, voice_id: voiceId },
}],
dimension: { width: 1280, height: 720 }, // Lower res for test
});Voice Speed Adjustment
Adjust delivery speed in the voice configuration:
voice: {
type: "text",
input_text: script,
voice_id: "voice_id",
speed: 1.1, // Slightly faster (range: 0.5 - 2.0)
}| Speed | Effect | Use Case |
|---|---|---|
| 0.8-0.9 | Slower, deliberate | Complex topics, older audiences |
| 1.0 | Normal | General use |
| 1.1-1.2 | Slightly faster | Energetic content, younger audiences |
| 1.3+ | Fast | Use sparingly, may reduce clarity |
See voices.md for full voice configuration options.
Video Templates
HeyGen templates allow you to create reusable video structures with variable placeholders, enabling personalized video generation at scale.
Listing Templates
curl
curl -X GET "https://api.heygen.com/v2/templates" \
-H "X-Api-Key: $HEYGEN_API_KEY"TypeScript
interface Template {
template_id: string;
name: string;
thumbnail_url: string;
variables: TemplateVariable[];
}
interface TemplateVariable {
name: string;
type: "text" | "image" | "audio";
properties?: {
max_length?: number;
default_value?: string;
};
}
interface TemplatesResponse {
error: null | string;
data: {
templates: Template[];
};
}
async function listTemplates(): Promise<Template[]> {
const response = await fetch("https://api.heygen.com/v2/templates", {
headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! },
});
const json: TemplatesResponse = await response.json();
if (json.error) {
throw new Error(json.error);
}
return json.data.templates;
}Python
import requests
import os
def list_templates() -> list:
response = requests.get(
"https://api.heygen.com/v2/templates",
headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]}
)
data = response.json()
if data.get("error"):
raise Exception(data["error"])
return data["data"]["templates"]Response Format
{
"error": null,
"data": {
"templates": [
{
"template_id": "template_abc123",
"name": "Product Announcement",
"thumbnail_url": "https://files.heygen.ai/...",
"variables": [
{
"name": "product_name",
"type": "text",
"properties": {
"max_length": 50
}
},
{
"name": "presenter_script",
"type": "text",
"properties": {
"max_length": 500
}
},
{
"name": "product_image",
"type": "image"
}
]
}
]
}
}Getting Template Details
curl
curl -X GET "https://api.heygen.com/v2/template/{template_id}" \
-H "X-Api-Key: $HEYGEN_API_KEY"TypeScript
async function getTemplate(templateId: string): Promise<Template> {
const response = await fetch(
`https://api.heygen.com/v2/template/${templateId}`,
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
const json = await response.json();
if (json.error) {
throw new Error(json.error);
}
return json.data;
}Generating Video from Template
Request Fields
| Field | Type | Req | Description |
|---|---|---|---|
variables | object | ✓ | Key-value pairs matching template variables |
test | boolean | Test mode (watermarked, no credits) | |
title | string | Video name for organization | |
callback_id | string | Custom ID for webhook tracking | |
callback_url | string | URL for completion notification |
Note: The variables object keys must match the template's defined variable names. Check template details to see which variables are defined.
curl
curl -X POST "https://api.heygen.com/v2/template/{template_id}/generate" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"test": false,
"variables": {
"product_name": "SuperWidget Pro",
"presenter_script": "Introducing our latest innovation!",
"product_image": "https://example.com/product.jpg"
}
}'TypeScript
interface TemplateGenerateRequest {
variables: Record<string, string>; // Required
test?: boolean;
title?: string;
callback_id?: string;
callback_url?: string;
}
interface TemplateGenerateResponse {
error: null | string;
data: {
video_id: string;
};
}
async function generateFromTemplate(
templateId: string,
variables: Record<string, string>,
test: boolean = false
): Promise<string> {
const response = await fetch(
`https://api.heygen.com/v2/template/${templateId}/generate`,
{
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ test, variables }),
}
);
const json: TemplateGenerateResponse = await response.json();
if (json.error) {
throw new Error(json.error);
}
return json.data.video_id;
}Python
def generate_from_template(template_id: str, variables: dict, test: bool = False) -> str:
response = requests.post(
f"https://api.heygen.com/v2/template/{template_id}/generate",
headers={
"X-Api-Key": os.environ["HEYGEN_API_KEY"],
"Content-Type": "application/json"
},
json={
"test": test,
"variables": variables
}
)
data = response.json()
if data.get("error"):
raise Exception(data["error"])
return data["data"]["video_id"]Variable Types
Text Variables
For dynamic text content:
const variables = {
customer_name: "John Smith",
product_name: "SuperWidget Pro",
price: "$99.99",
cta_text: "Order Now!",
};Image Variables
For dynamic images (backgrounds, product shots):
const variables = {
product_image: "https://example.com/product.jpg",
logo: "https://example.com/logo.png",
background: "https://example.com/bg.jpg",
};Audio Variables
For custom audio content:
const variables = {
background_music: "https://example.com/music.mp3",
custom_voiceover: "https://example.com/voiceover.mp3",
};Batch Video Generation
Generate multiple personalized videos from a template:
interface PersonalizationData {
name: string;
email: string;
company: string;
customMessage: string;
}
async function batchGenerateVideos(
templateId: string,
recipients: PersonalizationData[]
): Promise<string[]> {
const videoIds: string[] = [];
for (const recipient of recipients) {
const variables = {
recipient_name: recipient.name,
company_name: recipient.company,
personalized_message: recipient.customMessage,
};
const videoId = await generateFromTemplate(templateId, variables);
videoIds.push(videoId);
// Rate limiting: add delay between requests
await new Promise((r) => setTimeout(r, 1000));
}
return videoIds;
}
// Usage
const recipients = [
{
name: "John Smith",
email: "john@example.com",
company: "Acme Inc",
customMessage: "Thanks for your interest in our product!",
},
{
name: "Jane Doe",
email: "jane@example.com",
company: "Tech Corp",
customMessage: "We'd love to show you a demo!",
},
];
const videoIds = await batchGenerateVideos("template_abc123", recipients);Template Validation
Validate variables before generating:
function validateTemplateVariables(
template: Template,
variables: Record<string, string>
): { valid: boolean; errors: string[] } {
const errors: string[] = [];
for (const templateVar of template.variables) {
const value = variables[templateVar.name];
// Check if required variable is provided
if (!value) {
errors.push(`Missing required variable: ${templateVar.name}`);
continue;
}
// Check text length limits
if (templateVar.type === "text" && templateVar.properties?.max_length) {
if (value.length > templateVar.properties.max_length) {
errors.push(
`Variable "${templateVar.name}" exceeds max length of ${templateVar.properties.max_length}`
);
}
}
// Validate image URLs
if (templateVar.type === "image") {
try {
new URL(value);
} catch {
errors.push(`Variable "${templateVar.name}" is not a valid URL`);
}
}
}
return {
valid: errors.length === 0,
errors,
};
}Complete Template Workflow
async function createPersonalizedVideo(
templateId: string,
personalization: Record<string, string>
): Promise<string> {
// 1. Get template details
const template = await getTemplate(templateId);
console.log(`Using template: ${template.name}`);
// 2. Validate variables
const validation = validateTemplateVariables(template, personalization);
if (!validation.valid) {
throw new Error(`Validation errors: ${validation.errors.join(", ")}`);
}
// 3. Generate video
console.log("Generating video...");
const videoId = await generateFromTemplate(templateId, personalization);
console.log(`Video ID: ${videoId}`);
// 4. Wait for completion
const videoUrl = await waitForVideo(videoId);
console.log(`Video ready: ${videoUrl}`);
return videoUrl;
}
// Usage
const videoUrl = await createPersonalizedVideo("template_abc123", {
customer_name: "John Smith",
product_name: "SuperWidget Pro",
offer_details: "Get 20% off your first order!",
});Best Practices
1. Design for flexibility - Create templates with generic placeholders 2. Set reasonable limits - Define max lengths for text variables 3. Validate inputs - Check variable values before generating 4. Use test mode - Test with test: true to verify before production 5. Implement rate limiting - Add delays for batch generation 6. Cache template data - Reduce API calls by caching template details 7. Error handling - Gracefully handle generation failures
Use Cases
- Sales outreach - Personalized prospect videos
- Customer onboarding - Welcome videos with customer name
- Product updates - Announcements with dynamic content
- Training - Customized training modules
- Marketing campaigns - Targeted promotional videos
Text Overlays
Add text overlays to your HeyGen videos for titles, captions, lower thirds, and other on-screen text elements.
Basic Text Overlay
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Welcome to our presentation!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "color",
value: "#1a1a2e",
},
},
],
// Text overlay configuration (if supported in your API tier)
// Note: Availability varies by plan
};Text Overlay Configuration
Text overlays typically support these properties:
interface TextOverlay {
text: string;
x: number; // X position (pixels or percentage)
y: number; // Y position (pixels or percentage)
width?: number; // Text box width
height?: number; // Text box height
font_family?: string;
font_size?: number;
font_color?: string;
background_color?: string;
text_align?: "left" | "center" | "right";
duration?: {
start: number; // Start time in seconds
end: number; // End time in seconds
};
}Positioning Text
Coordinate System
- Origin: Top-left corner (0, 0)
- X-axis: Increases to the right
- Y-axis: Increases downward
- Units: Typically pixels or percentage of video dimensions
Common Positions
For a 1920x1080 video:
| Position | X | Y | Description |
|---|---|---|---|
| Top-left | 50 | 50 | Upper left corner |
| Top-center | 960 | 50 | Top center |
| Top-right | 1870 | 50 | Upper right corner |
| Center | 960 | 540 | Dead center |
| Bottom-left | 50 | 1030 | Lower third left |
| Bottom-center | 960 | 1030 | Lower third center |
Position Helper Function
interface Position {
x: number;
y: number;
}
function getTextPosition(
location: "top-left" | "top-center" | "top-right" | "center" | "bottom-left" | "bottom-center" | "bottom-right",
videoWidth: number,
videoHeight: number,
padding: number = 50
): Position {
const positions: Record<string, Position> = {
"top-left": { x: padding, y: padding },
"top-center": { x: videoWidth / 2, y: padding },
"top-right": { x: videoWidth - padding, y: padding },
"center": { x: videoWidth / 2, y: videoHeight / 2 },
"bottom-left": { x: padding, y: videoHeight - padding },
"bottom-center": { x: videoWidth / 2, y: videoHeight - padding },
"bottom-right": { x: videoWidth - padding, y: videoHeight - padding },
};
return positions[location];
}Font Styling
Available Font Properties
const textStyle = {
font_family: "Arial",
font_size: 48,
font_color: "#FFFFFF",
font_weight: "bold",
background_color: "rgba(0, 0, 0, 0.5)",
text_align: "center",
};Common Font Families
| Font | Style | Use Case |
|---|---|---|
| Arial | Sans-serif | Clean, universal |
| Helvetica | Sans-serif | Modern, professional |
| Times New Roman | Serif | Traditional, formal |
| Georgia | Serif | Elegant, readable |
| Roboto | Sans-serif | Modern, digital |
| Open Sans | Sans-serif | Friendly, accessible |
Common Text Overlay Patterns
Title Card
const titleOverlay = {
text: "Product Demo",
x: 960,
y: 540,
font_family: "Arial",
font_size: 72,
font_color: "#FFFFFF",
text_align: "center",
duration: {
start: 0,
end: 3,
},
};Lower Third (Name/Title)
const lowerThirdOverlay = {
text: "John Smith\nCEO, Company Inc.",
x: 100,
y: 900,
font_family: "Arial",
font_size: 36,
font_color: "#FFFFFF",
background_color: "rgba(0, 102, 204, 0.9)",
text_align: "left",
duration: {
start: 2,
end: 8,
},
};Call to Action
const ctaOverlay = {
text: "Visit example.com",
x: 960,
y: 1000,
font_family: "Arial",
font_size: 42,
font_color: "#FFD700",
text_align: "center",
duration: {
start: 25,
end: 30,
},
};Creating Text Overlay Templates
interface TextOverlayTemplate {
name: string;
style: Partial<TextOverlay>;
}
const templates: TextOverlayTemplate[] = [
{
name: "title",
style: {
font_family: "Arial",
font_size: 72,
font_color: "#FFFFFF",
text_align: "center",
},
},
{
name: "subtitle",
style: {
font_family: "Arial",
font_size: 42,
font_color: "#CCCCCC",
text_align: "center",
},
},
{
name: "lower-third",
style: {
font_family: "Arial",
font_size: 36,
font_color: "#FFFFFF",
background_color: "rgba(0, 0, 0, 0.7)",
text_align: "left",
},
},
{
name: "caption",
style: {
font_family: "Arial",
font_size: 32,
font_color: "#FFFFFF",
background_color: "rgba(0, 0, 0, 0.5)",
text_align: "center",
},
},
];
function createTextOverlay(
text: string,
templateName: string,
position: Position,
duration?: { start: number; end: number }
): TextOverlay {
const template = templates.find((t) => t.name === templateName);
if (!template) {
throw new Error(`Template "${templateName}" not found`);
}
return {
text,
x: position.x,
y: position.y,
...template.style,
duration,
};
}Timing Text Overlays
Coordinate text appearance with your script:
// Script with timing markers
const script = `
Hello and welcome. [0:00 - 0:03]
Let me show you our features. [0:03 - 0:08]
First, we have analytics. [0:08 - 0:15]
Get started today! [0:15 - 0:20]
`;
// Matching text overlays
const overlays = [
{
text: "Welcome",
duration: { start: 0, end: 3 },
...titleStyle,
},
{
text: "Feature Overview",
duration: { start: 3, end: 8 },
...subtitleStyle,
},
{
text: "Analytics Dashboard",
duration: { start: 8, end: 15 },
...lowerThirdStyle,
},
{
text: "www.example.com",
duration: { start: 15, end: 20 },
...ctaStyle,
},
];Best Practices
1. Readability - Use sufficient contrast between text and background 2. Size - Ensure text is large enough to read on mobile devices 3. Duration - Give viewers enough time to read (rule of thumb: 3 seconds minimum) 4. Positioning - Don't overlap with the avatar's face 5. Consistency - Use consistent fonts and styles throughout 6. Accessibility - Consider color-blind friendly palettes
Limitations
- Text overlay support varies by subscription tier
- Some advanced styling options may not be available via API
- Complex animations may require post-production tools
- For auto-generated captions, see captions.md