---
title: "Edit a timeline through the GPT-Video API"
description: "Prepare a Vibe Editing project, read its timeline, cut and remove segments, change the captions and export the result as an MP4 file."
url: "https://www.gpt-video.com/developers/guides/edit-a-timeline"
---

# Edit a timeline

A Vibe Editing project turns a whole video into an edit that you change with a list of operations. You can cut and reorder segments, add texts and b-roll, set captions and change the shape. This guide prepares an edit, changes it twice and exports it as an MP4 file.

Follow the steps in order. A step that needs an id or a number from an earlier answer reads it from a variable that the step before filled in. Change the values in the request bodies to your own. The examples read your key from the `GPT_VIDEO_API_KEY` variable, as [Authentication](/developers/authentication.md) explains.

You need a key and a video that is already uploaded. [Upload a video file](/developers/guides/upload-a-video.md) shows how to upload one.

## Find your video

[`list_assets`](/developers/api/list-assets.md) (`GET /v1/assets`). List your videos.

```bash title="curl"
# This command needs jq to read the answer.
ASSET_ID=$(curl -sS "https://api.gpt-video.com/v1/assets" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY" | jq -r '.data[0].id')
```

```ts title="TypeScript"
const res = await fetch("https://api.gpt-video.com/v1/assets", {
  method: "GET",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
  },
});
const data = await res.json();
const assetId = data.data[0].id;
```

```python title="Python"
import os

import requests

r = requests.get(
    "https://api.gpt-video.com/v1/assets",
    headers={
        "Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}",
    },
)
data = r.json()
asset_id = data["data"][0]["id"]
```

**Example response**

`GET /v1/assets`

```json title="HTTP 200"
{
  "data": [
    {
      "id": "ast_8e21b0d94c6f4a17b358",
      "kind": "video",
      "title": "Product demo",
      "filename": "demo.mp4",
      "origin": "upload",
      "mimeType": "video/mp4",
      "sizeBytes": 50331648,
      "durationSec": 120,
      "width": 1920,
      "height": 1080,
      "fps": 30,
      "hasAudio": true,
      "createdAt": "2026-09-01T09:05:00.000Z",
      "deletesAt": "2026-09-08T09:05:00.000Z"
    },
    {
      "id": "ast_3f9a1c7e5b2d4086a1c4",
      "kind": "video",
      "title": "Interview with a founder",
      "filename": "interview.mp4",
      "origin": "upload",
      "mimeType": "video/mp4",
      "sizeBytes": 734003200,
      "durationSec": 1800,
      "width": 1920,
      "height": 1080,
      "fps": 30,
      "hasAudio": true,
      "createdAt": "2026-09-01T09:00:00.000Z",
      "deletesAt": null
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

The list shows your videos with the newest first, so the first one is the video you uploaded last. The example keeps its id in `assetId`. If you uploaded something else since, use the id the upload guide gave you.

## Create the project

[`create_vibe_edit_project`](/developers/api/create-vibe-edit-project.md) (`POST /v1/projects/vibe-edit`). Create a Vibe Editing project.

```bash title="curl"
# This command needs jq to read the answer.
# Create the key once. Send this same key again if you retry this request.
IDEMPOTENCY_KEY=$(uuidgen)
PROJECT_ID=$(curl -sS -X POST "https://api.gpt-video.com/v1/projects/vibe-edit" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "sourceAssetId": "'"$ASSET_ID"'",
    "language": "en",
    "captionStyle": "word-pop",
    "aspect": "9:16",
    "title": "Product demo, edited"
  }' | jq -r '.project.id')
```

```ts title="TypeScript"
// Create the key once. Send this same key again if you retry this request.
const idempotencyKey = crypto.randomUUID();
const res = await fetch("https://api.gpt-video.com/v1/projects/vibe-edit", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
    "Idempotency-Key": idempotencyKey,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "sourceAssetId": assetId,
    "language": "en",
    "captionStyle": "word-pop",
    "aspect": "9:16",
    "title": "Product demo, edited"
  }),
});
const data = await res.json();
const projectId = data.project.id;
```

```python title="Python"
import os
import uuid

import requests

# Create the key once. Send this same key again if you retry this request.
idempotency_key = str(uuid.uuid4())
r = requests.post(
    "https://api.gpt-video.com/v1/projects/vibe-edit",
    headers={
        "Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}",
        "Idempotency-Key": idempotency_key,
    },
    json={
        "sourceAssetId": asset_id,
        "language": "en",
        "captionStyle": "word-pop",
        "aspect": "9:16",
        "title": "Product demo, edited"
    },
)
data = r.json()
project_id = data["project"]["id"]
```

**Example response**

`POST /v1/projects/vibe-edit`

```json title="HTTP 201"
{
  "project": {
    "id": "prj_6e2b9d14a7c3f0581e2d",
    "kind": "vibeedit",
    "title": "Product demo, edited",
    "status": "draft",
    "sourceAssetId": "ast_8e21b0d94c6f4a17b358",
    "compositionId": null,
    "settings": null,
    "missingSettings": [],
    "splitScreen": null,
    "streamer": null,
    "redditStory": null,
    "fakeText": null,
    "vibeEdit": {
      "sourceAssetId": "ast_8e21b0d94c6f4a17b358",
      "language": "en",
      "captionStyle": "word-pop",
      "aspect": "9:16",
      "vocabulary": null,
      "prepared": false
    },
    "errorMessage": null,
    "createdAt": "2026-09-01T11:00:00.000Z",
    "updatedAt": "2026-09-01T11:00:00.000Z"
  }
}
```

The body sets the language of the video, the caption style and the shape the edit starts with. `aspect` can keep the shape of the video or make it vertical, square or wide. [get_setting_options](/developers/api/get-setting-options.md) (`GET /v1/reference/setting-options`) lists the caption styles. This call uses no credits.

The example creates the `Idempotency-Key` once, before the request. If the request times out, send it again with the same key and you get the same project back. A new key would create a second project.

## Check the price

[`estimate_analysis`](/developers/api/estimate-analysis.md) (`POST /v1/projects/{projectId}/analyze/estimate`). Price an analysis before starting it.

```bash title="curl"
curl -X POST "https://api.gpt-video.com/v1/projects/$PROJECT_ID/analyze/estimate" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY"
```

```ts title="TypeScript"
const res = await fetch(`https://api.gpt-video.com/v1/projects/${projectId}/analyze/estimate`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
  },
});
const data = await res.json();
console.log(data);
```

```python title="Python"
import os

import requests

r = requests.post(
    f"https://api.gpt-video.com/v1/projects/{project_id}/analyze/estimate",
    headers={
        "Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}",
    },
)
data = r.json()
print(data)
```

**Example response**

`POST /v1/projects/prj_7c2e9a4f1b3d4e8096a5/analyze/estimate`

```json title="HTTP 200"
{
  "estimate": {
    "credits": 30,
    "creditsToCharge": 30,
    "sourceDurationSec": 1800,
    "runningJobId": null,
    "creditsAvailable": 640,
    "planRequired": false,
    "enough": true,
    "payer": "plan",
    "bonusRemaining": null
  }
}
```

`estimate.credits` is the price of preparing the edit. It depends on the length of the video. `estimate.enough` says whether the account can pay it now. If it is false, stop and read [Handle running out of credits](/developers/guides/out-of-credits.md).

## Prepare the edit

[`prepare_timeline`](/developers/api/prepare-timeline.md) (`POST /v1/projects/{projectId}/timeline/prepare`). Prepare an edit.

```bash title="curl"
# This command needs jq to read the answer.
# Create the key once. Send this same key again if you retry this request.
IDEMPOTENCY_KEY=$(uuidgen)
JOB_ID=$(curl -sS -X POST "https://api.gpt-video.com/v1/projects/$PROJECT_ID/timeline/prepare" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" | jq -r '.job.id')
```

```ts title="TypeScript"
// Create the key once. Send this same key again if you retry this request.
const idempotencyKey = crypto.randomUUID();
const res = await fetch(`https://api.gpt-video.com/v1/projects/${projectId}/timeline/prepare`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
    "Idempotency-Key": idempotencyKey,
  },
});
const data = await res.json();
const jobId = data.job.id;
```

```python title="Python"
import os
import uuid

import requests

# Create the key once. Send this same key again if you retry this request.
idempotency_key = str(uuid.uuid4())
r = requests.post(
    f"https://api.gpt-video.com/v1/projects/{project_id}/timeline/prepare",
    headers={
        "Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}",
        "Idempotency-Key": idempotency_key,
    },
)
data = r.json()
job_id = data["job"]["id"]
```

**Example response**

`POST /v1/projects/prj_37573e451e0ff7f863c8/timeline/prepare`

```json title="HTTP 202"
{
  "job": {
    "id": "job_d41c7e0a95b3f26c8e17",
    "kind": "analysis",
    "status": "queued",
    "progress": 0,
    "errorMessage": null,
    "projectId": "prj_37573e451e0ff7f863c8",
    "compositionId": null,
    "exportId": null,
    "assetId": null,
    "toolRunId": null,
    "turnId": null,
    "createdAt": "2026-09-01T11:10:00.000Z",
    "startedAt": null,
    "finishedAt": null
  },
  "creditsCharged": 2,
  "reused": false,
  "paidWith": "plan"
}
```

The video is transcribed and analyzed once, and the edit is built from it. The credits are charged when the preparation starts. The answer comes back at once with the job.

An edit is prepared once and is never charged again. Calling this on a prepared edit answers `CONFLICT`. If the request times out, send it again with the same `Idempotency-Key`. A retry never charges twice.

## Wait for the preparation

[`get_job`](/developers/api/get-job.md) (`GET /v1/jobs/{jobId}`). Wait for a job to finish.

```bash title="curl"
curl "https://api.gpt-video.com/v1/jobs/$JOB_ID?wait=25" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY"
```

```ts title="TypeScript"
const res = await fetch(`https://api.gpt-video.com/v1/jobs/${jobId}?wait=25`, {
  method: "GET",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
  },
});
const data = await res.json();
console.log(data);
```

```python title="Python"
import os

import requests

r = requests.get(
    f"https://api.gpt-video.com/v1/jobs/{job_id}?wait=25",
    headers={
        "Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}",
    },
)
data = r.json()
print(data)
```

**Example response**

`GET /v1/jobs/job_6c1e8b4a2f0d4a97e3b5?wait=25`

```json title="HTTP 200"
{
  "job": {
    "id": "job_6c1e8b4a2f0d4a97e3b5",
    "kind": "analysis",
    "status": "done",
    "progress": 1,
    "errorMessage": null,
    "projectId": "prj_7c2e9a4f1b3d4e8096a5",
    "compositionId": null,
    "exportId": null,
    "assetId": null,
    "toolRunId": null,
    "turnId": null,
    "createdAt": "2026-09-01T09:11:00.000Z",
    "startedAt": "2026-09-01T09:12:00.000Z",
    "finishedAt": "2026-09-01T09:40:00.000Z"
  }
}
```

The `wait` value, up to 25 seconds, holds the request until the job ends or the wait time passes. While `status` is `queued` or `running`, send the same request again. When it is `done`, read the edit. When it is `failed`, `errorMessage` says what happened. [Wait for long jobs](/developers/jobs.md) explains this loop.

## Read the edit

[`get_timeline`](/developers/api/get-timeline.md) (`GET /v1/projects/{projectId}/timeline`). Get an edit.

```bash title="curl"
# This command needs jq to read the answer.
RESPONSE=$(curl -sS "https://api.gpt-video.com/v1/projects/$PROJECT_ID/timeline" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY")
REVISION=$(printf '%s' "$RESPONSE" | jq -r '.timeline.revision')
FIRST_SEGMENT_ID=$(printf '%s' "$RESPONSE" | jq -r '.timeline.segments[0].id')
SECOND_SEGMENT_ID=$(printf '%s' "$RESPONSE" | jq -r '.timeline.segments[1].id')
```

```ts title="TypeScript"
const res = await fetch(`https://api.gpt-video.com/v1/projects/${projectId}/timeline`, {
  method: "GET",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
  },
});
const data = await res.json();
const revision = data.timeline.revision;
const firstSegmentId = data.timeline.segments[0].id;
const secondSegmentId = data.timeline.segments[1].id;
```

```python title="Python"
import os

import requests

r = requests.get(
    f"https://api.gpt-video.com/v1/projects/{project_id}/timeline",
    headers={
        "Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}",
    },
)
data = r.json()
revision = data["timeline"]["revision"]
first_segment_id = data["timeline"]["segments"][0]["id"]
second_segment_id = data["timeline"]["segments"][1]["id"]
```

**Example response**

`GET /v1/projects/prj_b9c1f9c7873dcef51a57/timeline`

```json title="HTTP 200"
{
  "timeline": {
    "id": "tml_bbe061171a8229f8daed",
    "projectId": "prj_b9c1f9c7873dcef51a57",
    "compositionId": "cmp_cfbdd85eaaff301fa997",
    "revision": 1,
    "durationSec": 60,
    "aspect": "9:16",
    "reframe": "off",
    "captions": {
      "enabled": true,
      "style": "bold-karaoke"
    },
    "music": null,
    "fadeInSec": 0,
    "fadeOutSec": 0,
    "clips": [
      {
        "assetId": "ast_3f9a1c7e5b2d4086a1c4",
        "title": "Interview with a founder",
        "durationSec": 1800,
        "state": "ready"
      }
    ],
    "segments": [
      {
        "id": "seg_7279fbb508f4a22e65a4",
        "assetId": "ast_3f9a1c7e5b2d4086a1c4",
        "sourceStartSec": 120,
        "sourceEndSec": 150,
        "zoom": 1,
        "muted": false,
        "volume": 1,
        "outputStartSec": 0,
        "outputEndSec": 30
      },
      {
        "id": "seg_a9dcee59c1fb9edb6751",
        "assetId": "ast_3f9a1c7e5b2d4086a1c4",
        "sourceStartSec": 300,
        "sourceEndSec": 330,
        "zoom": 1,
        "muted": false,
        "volume": 1,
        "outputStartSec": 30,
        "outputEndSec": 60
      }
    ],
    "texts": [
      {
        "id": "ovl_49f5239b9cd73496225d",
        "text": "Paid before the product",
        "preset": "headline",
        "outputStartSec": 0,
        "durationSec": 3,
        "xPercent": null,
        "yPercent": null
      }
    ],
    "broll": [
      {
        "id": "brl_f44d18e008ec4009bf10",
        "assetId": "ast_8ac5d8b97546145cdbf6",
        "outputStartSec": 12,
        "durationSec": 4,
        "fit": "cover"
      }
    ],
    "diagnostics": []
  }
}
```

The edit lists its videos, its segments, its texts, its b-roll and its captions. Every change you send names the `revision` you read here. If the edit changed after that, nothing is written and the answer is `CONFLICT`. Read the edit again and send your change with the new revision.

Segments, texts and b-roll are named by the ids this answer shows. The example keeps the ids of the first two segments. An edit with only one segment has no second id, so choose the ids that fit your edit.

Times come in two bases. `sourceStartSec` and `sourceEndSec` are seconds of the source video. `outputStartSec` and the other output times are seconds of the video that comes out. If `NOT_READY` comes back, the preparation is still running. Wait the seconds in `Retry-After` and read again.

## Cut the edit

[`apply_timeline_operations`](/developers/api/apply-timeline-operations.md) (`POST /v1/projects/{projectId}/timeline/operations`). Cut a segment in two and remove another.

```bash title="curl"
# This command needs jq to read the answer.
# Create the key once. Send this same key again if you retry this request.
IDEMPOTENCY_KEY=$(uuidgen)
RESPONSE=$(curl -sS -X POST "https://api.gpt-video.com/v1/projects/$PROJECT_ID/timeline/operations" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "expectedRevision": '"$REVISION"',
    "operations": [
      {
        "op": "split_segment",
        "segmentId": "'"$FIRST_SEGMENT_ID"'",
        "atSourceSec": 135
      },
      {
        "op": "remove_segments",
        "segmentIds": [
          "'"$SECOND_SEGMENT_ID"'"
        ]
      }
    ]
  }')
REVISION=$(printf '%s' "$RESPONSE" | jq -r '.timeline.revision')
COMPOSITION_ID=$(printf '%s' "$RESPONSE" | jq -r '.compositionId')
```

```ts title="TypeScript"
// Create the key once. Send this same key again if you retry this request.
const idempotencyKey = crypto.randomUUID();
const res = await fetch(`https://api.gpt-video.com/v1/projects/${projectId}/timeline/operations`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
    "Idempotency-Key": idempotencyKey,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "expectedRevision": revision,
    "operations": [
      {
        "op": "split_segment",
        "segmentId": firstSegmentId,
        "atSourceSec": 135
      },
      {
        "op": "remove_segments",
        "segmentIds": [
          secondSegmentId
        ]
      }
    ]
  }),
});
const data = await res.json();
const revision = data.timeline.revision;
const compositionId = data.compositionId;
```

```python title="Python"
import os
import uuid

import requests

# Create the key once. Send this same key again if you retry this request.
idempotency_key = str(uuid.uuid4())
r = requests.post(
    f"https://api.gpt-video.com/v1/projects/{project_id}/timeline/operations",
    headers={
        "Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}",
        "Idempotency-Key": idempotency_key,
    },
    json={
        "expectedRevision": revision,
        "operations": [
            {
                "op": "split_segment",
                "segmentId": first_segment_id,
                "atSourceSec": 135
            },
            {
                "op": "remove_segments",
                "segmentIds": [
                    second_segment_id
                ]
            }
        ]
    },
)
data = r.json()
revision = data["timeline"]["revision"]
composition_id = data["compositionId"]
```

**Example response**

`POST /v1/projects/prj_b9c1f9c7873dcef51a57/timeline/operations`

```json title="HTTP 200"
{
  "timeline": {
    "id": "tml_bbe061171a8229f8daed",
    "projectId": "prj_b9c1f9c7873dcef51a57",
    "compositionId": "cmp_cfbdd85eaaff301fa997",
    "revision": 2,
    "durationSec": 30,
    "aspect": "9:16",
    "reframe": "off",
    "captions": {
      "enabled": true,
      "style": "bold-karaoke"
    },
    "music": null,
    "fadeInSec": 0,
    "fadeOutSec": 0,
    "clips": [
      {
        "assetId": "ast_3f9a1c7e5b2d4086a1c4",
        "title": "Interview with a founder",
        "durationSec": 1800,
        "state": "ready"
      }
    ],
    "segments": [
      {
        "id": "seg_7279fbb508f4a22e65a4",
        "assetId": "ast_3f9a1c7e5b2d4086a1c4",
        "sourceStartSec": 120,
        "sourceEndSec": 135,
        "zoom": 1,
        "muted": false,
        "volume": 1,
        "outputStartSec": 0,
        "outputEndSec": 15
      },
      {
        "id": "seg_5a0e3c8f1d7b42e69c13",
        "assetId": "ast_3f9a1c7e5b2d4086a1c4",
        "sourceStartSec": 135,
        "sourceEndSec": 150,
        "zoom": 1,
        "muted": false,
        "volume": 1,
        "outputStartSec": 15,
        "outputEndSec": 30
      }
    ],
    "texts": [
      {
        "id": "ovl_49f5239b9cd73496225d",
        "text": "Paid before the product",
        "preset": "headline",
        "outputStartSec": 0,
        "durationSec": 3,
        "xPercent": null,
        "yPercent": null
      }
    ],
    "broll": [
      {
        "id": "brl_f44d18e008ec4009bf10",
        "assetId": "ast_8ac5d8b97546145cdbf6",
        "outputStartSec": 12,
        "durationSec": 4,
        "fit": "cover"
      }
    ],
    "diagnostics": []
  },
  "compositionId": "cmp_cfbdd85eaaff301fa997",
  "warnings": []
}
```

The operations apply in order, as one change and one new revision, or not at all. This request splits the first segment at a time you set in `atSourceSec`, which must fall inside that segment, and removes the second segment. Change the operations to the cuts you want. The reference lists every operation in [apply_timeline_operations](/developers/api/apply-timeline-operations.md) (`POST /v1/projects/{projectId}/timeline/operations`).

The answer is the edit after the change. `warnings` says what the change adjusted. `compositionId` is the composition the edit compiles to, which you export later. The example keeps the new revision, so the next change can use it.

Each change uses no credits. Adding another video to the edit costs credits and has its own call, [add_timeline_clip](/developers/api/add-timeline-clip.md) (`POST /v1/projects/{projectId}/timeline/clips`).

## Change the captions and the shape

[`apply_timeline_operations`](/developers/api/apply-timeline-operations.md) (`POST /v1/projects/{projectId}/timeline/operations`). Change the captions, a word and the shape.

```bash title="curl"
# This command needs jq to read the answer.
# Create the key once. Send this same key again if you retry this request.
IDEMPOTENCY_KEY=$(uuidgen)
REVISION=$(curl -sS -X POST "https://api.gpt-video.com/v1/projects/$PROJECT_ID/timeline/operations" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "expectedRevision": '"$REVISION"',
    "operations": [
      {
        "op": "set_captions",
        "style": "word-pop",
        "options": {
          "uppercase": true,
          "maxWordsPerGroup": 3
        }
      },
      {
        "op": "replace_caption_word",
        "from": "customer",
        "to": "client"
      },
      {
        "op": "set_aspect",
        "aspect": "1:1"
      },
      {
        "op": "set_reframe",
        "mode": "center"
      }
    ]
  }' | jq -r '.timeline.revision')
```

```ts title="TypeScript"
// Create the key once. Send this same key again if you retry this request.
const idempotencyKey = crypto.randomUUID();
const res = await fetch(`https://api.gpt-video.com/v1/projects/${projectId}/timeline/operations`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
    "Idempotency-Key": idempotencyKey,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "expectedRevision": revision,
    "operations": [
      {
        "op": "set_captions",
        "style": "word-pop",
        "options": {
          "uppercase": true,
          "maxWordsPerGroup": 3
        }
      },
      {
        "op": "replace_caption_word",
        "from": "customer",
        "to": "client"
      },
      {
        "op": "set_aspect",
        "aspect": "1:1"
      },
      {
        "op": "set_reframe",
        "mode": "center"
      }
    ]
  }),
});
const data = await res.json();
const revision = data.timeline.revision;
```

```python title="Python"
import os
import uuid

import requests

# Create the key once. Send this same key again if you retry this request.
idempotency_key = str(uuid.uuid4())
r = requests.post(
    f"https://api.gpt-video.com/v1/projects/{project_id}/timeline/operations",
    headers={
        "Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}",
        "Idempotency-Key": idempotency_key,
    },
    json={
        "expectedRevision": revision,
        "operations": [
            {
                "op": "set_captions",
                "style": "word-pop",
                "options": {
                    "uppercase": True,
                    "maxWordsPerGroup": 3
                }
            },
            {
                "op": "replace_caption_word",
                "from": "customer",
                "to": "client"
            },
            {
                "op": "set_aspect",
                "aspect": "1:1"
            },
            {
                "op": "set_reframe",
                "mode": "center"
            }
        ]
    },
)
data = r.json()
revision = data["timeline"]["revision"]
```

**Example response**

`POST /v1/projects/prj_b9c1f9c7873dcef51a57/timeline/operations`

```json title="HTTP 200"
{
  "timeline": {
    "id": "tml_bbe061171a8229f8daed",
    "projectId": "prj_b9c1f9c7873dcef51a57",
    "compositionId": "cmp_cfbdd85eaaff301fa997",
    "revision": 2,
    "durationSec": 60,
    "aspect": "1:1",
    "reframe": "center",
    "captions": {
      "enabled": true,
      "style": "word-pop"
    },
    "music": null,
    "fadeInSec": 0,
    "fadeOutSec": 0,
    "clips": [
      {
        "assetId": "ast_3f9a1c7e5b2d4086a1c4",
        "title": "Interview with a founder",
        "durationSec": 1800,
        "state": "ready"
      }
    ],
    "segments": [
      {
        "id": "seg_7279fbb508f4a22e65a4",
        "assetId": "ast_3f9a1c7e5b2d4086a1c4",
        "sourceStartSec": 120,
        "sourceEndSec": 150,
        "zoom": 1,
        "muted": false,
        "volume": 1,
        "outputStartSec": 0,
        "outputEndSec": 30
      },
      {
        "id": "seg_a9dcee59c1fb9edb6751",
        "assetId": "ast_3f9a1c7e5b2d4086a1c4",
        "sourceStartSec": 300,
        "sourceEndSec": 330,
        "zoom": 1,
        "muted": false,
        "volume": 1,
        "outputStartSec": 30,
        "outputEndSec": 60
      }
    ],
    "texts": [
      {
        "id": "ovl_49f5239b9cd73496225d",
        "text": "Paid before the product",
        "preset": "headline",
        "outputStartSec": 0,
        "durationSec": 3,
        "xPercent": null,
        "yPercent": null
      }
    ],
    "broll": [
      {
        "id": "brl_f44d18e008ec4009bf10",
        "assetId": "ast_8ac5d8b97546145cdbf6",
        "outputStartSec": 12,
        "durationSec": 4,
        "fit": "cover"
      }
    ],
    "diagnostics": []
  },
  "compositionId": "cmp_cfbdd85eaaff301fa997",
  "warnings": []
}
```

This change sets the caption style, replaces a word in the captions, changes the shape of the edit and sets its framing. Change the values to yours. It uses the revision that the cut answered. If you send an old revision, the answer is `CONFLICT`. Read the edit with `get_timeline` (`GET /v1/projects/{projectId}/timeline`) and send the change again.

To see earlier versions of the edit, call [list_timeline_revisions](/developers/api/list-timeline-revisions.md) (`GET /v1/projects/{projectId}/timeline/revisions`). To go back to one, call [restore_timeline](/developers/api/restore-timeline.md) (`POST /v1/projects/{projectId}/timeline/restore`).

## Export the edit

[`create_export`](/developers/api/create-export.md) (`POST /v1/compositions/{compositionId}/exports`). Export a composition.

```bash title="curl"
# This command needs jq to read the answer.
# Create the key once. Send this same key again if you retry this request.
IDEMPOTENCY_KEY=$(uuidgen)
EXPORT_ID=$(curl -sS -X POST "https://api.gpt-video.com/v1/compositions/$COMPOSITION_ID/exports" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" | jq -r '.export.id')
```

```ts title="TypeScript"
// Create the key once. Send this same key again if you retry this request.
const idempotencyKey = crypto.randomUUID();
const res = await fetch(`https://api.gpt-video.com/v1/compositions/${compositionId}/exports`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
    "Idempotency-Key": idempotencyKey,
  },
});
const data = await res.json();
const exportId = data.export.id;
```

```python title="Python"
import os
import uuid

import requests

# Create the key once. Send this same key again if you retry this request.
idempotency_key = str(uuid.uuid4())
r = requests.post(
    f"https://api.gpt-video.com/v1/compositions/{composition_id}/exports",
    headers={
        "Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}",
        "Idempotency-Key": idempotency_key,
    },
)
data = r.json()
export_id = data["export"]["id"]
```

**Example response**

`POST /v1/compositions/cmp_4e8c2a6f09d14b73a1e5/exports`

```json title="HTTP 202"
{
  "export": {
    "id": "rnd_6d0f2a8c4e1b47a9c3f5",
    "compositionId": "cmp_4e8c2a6f09d14b73a1e5",
    "projectId": "prj_7c2e9a4f1b3d4e8096a5",
    "jobId": "job_8c3f1e5a9d2b4f7c06e1",
    "revision": 0,
    "status": "queued",
    "progress": 0,
    "errorMessage": null,
    "createdAt": "2026-09-01T11:00:00.000Z",
    "finishedAt": null,
    "file": null,
    "downloadUrl": null,
    "downloadUrlExpiresAt": null,
    "fileDeletesAt": null,
    "fileDeletedAt": null
  },
  "paidWith": "plan"
}
```

The answer comes back at once with the export, queued. It has no download link yet. An export counts against your daily export limit and uses no plan credits. When the API bonus pays for it, it uses bonus credits.

If the export answers `NOT_READY`, wait the seconds in the `Retry-After` header and send the same request again with the same `Idempotency-Key`.

## Download the file

[`get_export`](/developers/api/get-export.md) (`GET /v1/exports/{exportId}`). Wait for an export to finish.

```bash title="curl"
curl "https://api.gpt-video.com/v1/exports/$EXPORT_ID?wait=25" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY"
```

```ts title="TypeScript"
const res = await fetch(`https://api.gpt-video.com/v1/exports/${exportId}?wait=25`, {
  method: "GET",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
  },
});
const data = await res.json();
console.log(data);
```

```python title="Python"
import os

import requests

r = requests.get(
    f"https://api.gpt-video.com/v1/exports/{export_id}?wait=25",
    headers={
        "Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}",
    },
)
data = r.json()
print(data)
```

**Example response**

`GET /v1/exports/rnd_0f6b3e9d2c7a4158b4a3?wait=25`

```json title="HTTP 200"
{
  "export": {
    "id": "rnd_0f6b3e9d2c7a4158b4a3",
    "compositionId": "cmp_4e8c2a6f09d14b73a1e5",
    "projectId": "prj_7c2e9a4f1b3d4e8096a5",
    "jobId": "job_a3e9d05c7b1f4e2896d4",
    "revision": 0,
    "status": "done",
    "progress": 1,
    "errorMessage": null,
    "createdAt": "2026-09-01T10:10:00.000Z",
    "finishedAt": "2026-09-01T10:15:00.000Z",
    "file": {
      "mimeType": "video/mp4",
      "sizeBytes": 18874368,
      "durationSec": 30,
      "width": 1080,
      "height": 1920
    },
    "downloadUrl": "https://www.gpt-video.com/media/ast_c47d2e8a19b54f06d2e1?exp=1788258600&sig=9b2e61c04d7f3a58e1b6c2d9f0a4e7b3",
    "downloadUrlExpiresAt": "2026-09-01T10:30:00.000Z",
    "fileDeletesAt": "2026-09-15T10:15:00.000Z",
    "fileDeletedAt": null
  }
}
```

Send the request again while `status` is `queued` or `rendering`. When `status` is `done`, `downloadUrl` is a link to the MP4 file. The link works without your API key, so do not send the key to it. Save the response body as an `.mp4` file.

The link is short-lived, and `downloadUrlExpiresAt` says until when. Read the export again right before you download to get a fresh link. If the export failed, `errorMessage` says why. Export again with a new `Idempotency-Key`.
