> ## Documentation Index
> Fetch the complete documentation index at: https://developers.heygen.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Direct Upload to S3

> Upload large files straight to S3 with a presigned URL: up to 200 MB per video, image, or PDF, 1.5 GB per audio file, and 1 GiB per HyperFrames zip. Three calls: create the upload, PUT the bytes, complete.

![](https://mintcdn.com/heygen-1fa696a7/hfMXXwJzjE7vBSYZ/images/theme/research-7.webp?fit=max\&auto=format\&n=hfMXXwJzjE7vBSYZ\&q=85\&s=02bcf90d74d8b9887ce224de6b8a130b)

Direct upload sends file bytes from your client straight to S3 through a presigned URL, so they never pass through the HeyGen API. Use it for any file over the [simple upload](https://developers.heygen.com/docs/upload-assets.md) limit, for avatar training footage, and for HyperFrames project zips. The result is an ordinary `asset_id` you can pass to any endpoint that accepts [assets](https://developers.heygen.com/assets.md).

**1. Create the upload**

[`POST /v3/assets/direct-uploads`](https://developers.heygen.com/reference/create-asset-upload) with the file's name, MIME type, and exact byte size. Returns an `asset_id`, a presigned `upload_url`, and `upload_headers`.

**2. PUT the bytes to S3**

`PUT` the raw file to `upload_url` with every header from `upload_headers`, within 24 hours.

**3. Complete the upload**

[`POST /v3/assets/{asset_id}/complete`](https://developers.heygen.com/reference/complete-asset-upload). The `asset_id` is usable as soon as this returns.

To upload many files at once, use the [batch variant](https://developers.heygen.com/batch-assets.md): up to 100 upload slots in one call and one complete call for the whole batch.

## Size limits

| File type | `content_type` | Max size |
| - | - | - |
| Video | `video/mp4`, `video/webm`, `video/quicktime` (.mov) | 200 MB (209,715,200 bytes) |
| Image | `image/png`, `image/jpeg`, `image/webp`, `image/heic`, `image/heif` | 200 MB (209,715,200 bytes) |
| Document | `application/pdf` | 200 MB (209,715,200 bytes) |
| Subtitles | `application/x-subrip` (.srt) | 200 MB (209,715,200 bytes) |
| Audio | `audio/mpeg`, `audio/mp3`, `audio/wav`, `audio/x-wav`, `audio/webm` | 1.5 GB (1,500,000,000 bytes) |
| HyperFrames project | `application/zip` | 1 GiB (1,073,741,824 bytes) |

The limit for your file is also returned as `max_bytes` when you create the upload. A larger `size_bytes` is rejected at that step, before any bytes move.

## Step 1: Create the upload

```bash theme={null}
curl -X POST "https://api.heygen.com/v3/assets/direct-uploads" \
  -H "X-Api-Key: $HEYGEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "training-footage.mp4",
    "content_type": "video/mp4",
    "size_bytes": 157286400
  }'
```

### Request body

| Parameter | Type | Required | Description |
| - | - | - | - |
| `filename` | string | Yes | Original filename, kept as the asset name. The stored file's extension comes from `content_type`. |
| `content_type` | string | Yes | MIME type of the file, from the [size limits](#size-limits) table. SVG is rejected. Checked against the stored bytes when you complete the upload. |
| `size_bytes` | integer | Yes | Exact size of the file in bytes, greater than 0. It is signed into `upload_url`, so the `PUT` must send exactly this many bytes. |
| `checksum_sha256` | string | No | SHA-256 of the file as 64 hex characters. S3 then rejects a `PUT` whose bytes do not match. See [Verify file integrity](#verify-file-integrity). |

Send an [`Idempotency-Key`](https://developers.heygen.com/reference/create-asset-upload) header to retry this call safely: a repeat within 24 hours returns the same `asset_id` and `upload_url`.

### Response

```json theme={null}
{
  "data": {
    "asset_id": "0104ec0f73f4422090eeddd72164f68d",
    "upload_url": "https://heygen-resources-prod.s3-accelerate.amazonaws.com/...",
    "upload_headers": {
      "content-type": "video/mp4",
      "x-amz-server-side-encryption": "AES256"
    },
    "expires_in_seconds": 86400,
    "max_bytes": 209715200,
    "status": "pending_upload"
  }
}
```

| Field | Type | Description |
| - | - | - |
| `asset_id` | string | The asset's identifier. Usable after [Step 3](#step-3-complete-the-upload). |
| `upload_url` | string | Presigned S3 URL. `PUT` the raw file bytes here. |
| `upload_headers` | object | Headers to send on the `PUT` exactly as returned. |
| `expires_in_seconds` | integer | Seconds until `upload_url` expires: 86,400 (24 hours). |
| `max_bytes` | integer | Size limit for this `content_type`. |
| `status` | string | `pending_upload`. |

## Step 2: PUT the bytes to S3

```bash theme={null}
curl -X PUT "$UPLOAD_URL" \
  -H "content-type: video/mp4" \
  -H "x-amz-server-side-encryption: AES256" \
  --upload-file ./training-footage.mp4
```

S3 returns `200` with an empty body when the upload succeeds. The request goes to S3, not to HeyGen, so it follows S3's rules:

* **Send every header in `upload_headers`, with its exact value.** They are part of the URL's signature. Read them from the response rather than hard-coding them, because the set changes with your request (a `checksum_sha256` adds `x-amz-checksum-sha256`).
* **Send exactly `size_bytes` bytes** in a single `PUT`. Chunked or multipart uploads are not supported on this URL.
* **Do not add your HeyGen API key** or any other header. Extra signed-looking headers can break the signature.
* **Finish within `expires_in_seconds`.** After 24 hours, create a new upload.

If the `PUT` fails, S3 returns an XML error. `SignatureDoesNotMatch` (403) means a header is missing or changed, or the body size differs from `size_bytes`. A failed `PUT` can be retried against the same `upload_url` until it expires.

## Step 3: Complete the upload

```bash theme={null}
curl -X POST "https://api.heygen.com/v3/assets/$ASSET_ID/complete" \
  -H "X-Api-Key: $HEYGEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

The request body is required. Send `{}`, or `{"checksum_sha256": "<hex>"}` to cross-check the stored bytes.

```json Response theme={null}
{
  "data": {
    "asset_id": "0104ec0f73f4422090eeddd72164f68d",
    "url": "https://resource2.heygen.ai/video/0104ec0f73f4422090eeddd72164f68d/original.mp4",
    "mime_type": "video/mp4",
    "size_bytes": 157286400,
    "status": "processing"
  }
}
```

| Field | Type | Description |
| - | - | - |
| `asset_id` | string | The asset's identifier, ready to use. |
| `url` | string | Public URL of the stored file. |
| `mime_type` | string | MIME type detected from the stored bytes. |
| `size_bytes` | integer | Size of the stored file in bytes. |
| `status` | string | `processing`. Pass the `asset_id` to your next request right away; you do not need to wait for this to change. |

Complete is idempotent: calling it again returns the same asset. HeyGen reads the stored bytes at this step, so a file whose contents do not match a supported type is rejected here even if the `PUT` succeeded.

## Full example

```bash curl theme={null}
FILE=./training-footage.mp4
SIZE=$(stat -f%z "$FILE")  # use `stat -c%s` on Linux

# Step 1: Create the upload
INIT=$(curl -s -X POST "https://api.heygen.com/v3/assets/direct-uploads" \
  -H "X-Api-Key: $HEYGEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"filename\": \"training-footage.mp4\", \"content_type\": \"video/mp4\", \"size_bytes\": $SIZE}")

ASSET_ID=$(echo "$INIT" | jq -r '.data.asset_id')
UPLOAD_URL=$(echo "$INIT" | jq -r '.data.upload_url')

# Step 2: PUT the bytes with every header from upload_headers
HEADERS=()
while IFS= read -r h; do HEADERS+=(-H "$h"); done \
  < <(echo "$INIT" | jq -r '.data.upload_headers | to_entries[] | "\(.key): \(.value)"')
curl -sf -X PUT "$UPLOAD_URL" "${HEADERS[@]}" --upload-file "$FILE"

# Step 3: Complete
curl -X POST "https://api.heygen.com/v3/assets/$ASSET_ID/complete" \
  -H "X-Api-Key: $HEYGEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```python Python theme={null}
import os
import requests

API = "https://api.heygen.com/v3/assets"
HEADERS = {"X-Api-Key": os.environ["HEYGEN_API_KEY"]}
file_path = "training-footage.mp4"

# Step 1: Create the upload
init = requests.post(
    f"{API}/direct-uploads",
    headers=HEADERS,
    json={
        "filename": os.path.basename(file_path),
        "content_type": "video/mp4",
        "size_bytes": os.path.getsize(file_path),
    },
)
init.raise_for_status()
upload = init.json()["data"]

# Step 2: PUT the bytes with every header from upload_headers
with open(file_path, "rb") as f:
    put = requests.put(upload["upload_url"], data=f, headers=upload["upload_headers"])
put.raise_for_status()

# Step 3: Complete
done = requests.post(f"{API}/{upload['asset_id']}/complete", headers=HEADERS, json={})
done.raise_for_status()
print(done.json()["data"]["asset_id"])
```

```javascript Node.js theme={null}
import fs from "node:fs";

const API = "https://api.heygen.com/v3/assets";
const headers = { "X-Api-Key": process.env.HEYGEN_API_KEY, "Content-Type": "application/json" };
const filePath = "./training-footage.mp4";

// Step 1: Create the upload
const init = await fetch(`${API}/direct-uploads`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    filename: "training-footage.mp4",
    content_type: "video/mp4",
    size_bytes: fs.statSync(filePath).size,
  }),
});
const upload = (await init.json()).data;

// Step 2: PUT the bytes with every header from upload_headers
const put = await fetch(upload.upload_url, {
  method: "PUT",
  headers: upload.upload_headers,
  body: fs.readFileSync(filePath),
});
if (!put.ok) throw new Error(`S3 PUT failed: ${put.status} ${await put.text()}`);

// Step 3: Complete
const done = await fetch(`${API}/${upload.asset_id}/complete`, {
  method: "POST",
  headers,
  body: "{}",
});
console.log((await done.json()).data.asset_id);
```

## Verify file integrity

Pass the file's SHA-256 as hex when you create the upload. HeyGen adds an `x-amz-checksum-sha256` header to `upload_headers`, and S3 rejects any `PUT` whose bytes do not match.

```bash theme={null}
SHA=$(shasum -a 256 ./training-footage.mp4 | cut -d' ' -f1)

curl -X POST "https://api.heygen.com/v3/assets/direct-uploads" \
  -H "X-Api-Key: $HEYGEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"filename\": \"training-footage.mp4\", \"content_type\": \"video/mp4\", \"size_bytes\": $SIZE, \"checksum_sha256\": \"$SHA\"}"
```

Send the returned `upload_headers` on the `PUT` as usual. You can pass the same hex value to the complete call as a final check.

## Upload a HyperFrames project

Zip the project and upload it with `content_type: "application/zip"` (up to 1 GiB). Pass the `asset_id` as the `project` of [`POST /v3/hyperframes/renders`](https://developers.heygen.com/hyperframes). Reuse the same `asset_id` for every render of that project.

## Errors

| Step | Status | Error | Fix |
| - | - | - | - |
| Create | `400` | `invalid_parameter` | `content_type` is outside the [size limits](#size-limits) table or is SVG, `size_bytes` is 0 or over `max_bytes`, or `checksum_sha256` is not 64 hex characters. |
| PUT | `403` | `SignatureDoesNotMatch` (S3 XML) | Send every `upload_headers` entry unchanged, send exactly `size_bytes` bytes, and add no other headers. |
| PUT | `403` | `AccessDenied`, request expired (S3 XML) | `upload_url` is older than 24 hours. Create a new upload. |
| Complete | `409` | `resource_not_ready` | The `PUT` has not finished. Retry after it returns `200`. |
| Complete | `400` | `invalid_parameter` | The stored bytes do not match `content_type` or a type in the [size limits](#size-limits) table, the upload is more than 24 hours old, or `checksum_sha256` differs from the one sent at create. Create a new upload. |
| Complete | `404` | `asset_not_found` | Unknown `asset_id`, or one from another workspace. |

See [Error codes](https://developers.heygen.com/docs/error-codes.md) for the response envelope.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.