Skip to content

Pagination and idempotency

Two rules apply to many operations. Every list is read one page at a time with a cursor, and every call that spends credits is sent with an Idempotency-Key. Both work the same way on every operation.

Pagination

Every list operation takes the same two query parameters.

  • limit is how many items you want on a page. Without it you get the default page size.
  • cursor is the place to continue from. Leave it out to get the first page.

Every list answers with the same three fields.

  • data holds the items of this page.
  • hasMore is true when more items follow.
  • nextCursor is the cursor for the next page. On the last page it is null.

Send nextCursor back exactly as you received it. The cursor is opaque. Do not build one, read one or change one, because its content can change. A cursor that the API cannot read answers VALIDATION_FAILED.

Stop when hasMore is false.

The two requests below read the projects of an account one at a time. The first gets one project and a cursor. The second sends that cursor.

list_projects (GET /v1/projects). Ask for one project per page.

Request example
# This command needs jq to read the answer.
CURSOR=$(curl -sS "https://api.gpt-video.com/v1/projects?limit=1" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY" | jq -r '.nextCursor')
const res = await fetch("https://api.gpt-video.com/v1/projects?limit=1", {
  method: "GET",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
  },
});
const data = await res.json();
const cursor = data.nextCursor;
import os

import requests

r = requests.get(
    "https://api.gpt-video.com/v1/projects?limit=1",
    headers={
        "Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}",
    },
)
data = r.json()
cursor = data["nextCursor"]
Example response

GET /v1/projects?limit=1

HTTP 200
{
  "data": [
    {
      "id": "prj_1d8b6e0c47a24f93b1c2",
      "kind": "autoclip",
      "title": "Weekly podcast",
      "status": "draft",
      "sourceAssetId": null,
      "compositionId": null,
      "settings": {
        "language": null,
        "clipCount": 5,
        "minDurationSec": null,
        "maxDurationSec": null,
        "objective": "educational",
        "captionStyle": null,
        "reframe": null,
        "instructions": null,
        "vocabulary": null
      },
      "missingSettings": [
        "language",
        "minDurationSec",
        "maxDurationSec",
        "captionStyle",
        "reframe"
      ],
      "splitScreen": null,
      "streamer": null,
      "redditStory": null,
      "fakeText": null,
      "vibeEdit": null,
      "errorMessage": null,
      "createdAt": "2026-09-01T09:50:00.000Z",
      "updatedAt": "2026-09-01T09:50:00.000Z"
    }
  ],
  "hasMore": true,
  "nextCursor": "eyJrIjp7ImNyZWF0ZWRBdCI6IjIwMjYtMDktMDFUMDk6NTA6MDAuMDAwWiIsImlkIjoicHJqXzFkOGI2ZTBjNDdhMjRmOTNiMWMyIn0sInYiOjF9"
}

list_projects (GET /v1/projects). Read the next page.

Request example
curl "https://api.gpt-video.com/v1/projects?limit=1&cursor=$CURSOR" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY"
const res = await fetch(`https://api.gpt-video.com/v1/projects?limit=1&cursor=${cursor}`, {
  method: "GET",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
  },
});
const data = await res.json();
console.log(data);
import os

import requests

r = requests.get(
    f"https://api.gpt-video.com/v1/projects?limit=1&cursor={cursor}",
    headers={
        "Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}",
    },
)
data = r.json()
print(data)
Example response

GET /v1/projects?limit=1&cursor=eyJrIjp7ImNyZWF0ZWRBdCI6IjIwMjYtMDktMDFUMDk6NTA6MDAuMDAwWiIsImlkIjoicHJqXzFkOGI2ZTBjNDdhMjRmOTNiMWMyIn0sInYiOjF9

HTTP 200
{
  "data": [
    {
      "id": "prj_7c2e9a4f1b3d4e8096a5",
      "kind": "autoclip",
      "title": "Founder interview",
      "status": "ready",
      "sourceAssetId": "ast_3f9a1c7e5b2d4086a1c4",
      "compositionId": null,
      "settings": {
        "language": "en",
        "clipCount": 3,
        "minDurationSec": 15,
        "maxDurationSec": 60,
        "objective": "viral",
        "captionStyle": "word-pop",
        "reframe": "face",
        "instructions": null,
        "vocabulary": null
      },
      "missingSettings": [],
      "splitScreen": null,
      "streamer": null,
      "redditStory": null,
      "fakeText": null,
      "vibeEdit": null,
      "errorMessage": null,
      "createdAt": "2026-09-01T09:10:00.000Z",
      "updatedAt": "2026-09-01T09:40:00.000Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}

A cursor can stop being valid when the list changes under it. list_clips (GET /v1/projects/{projectId}/clips) is the example. If the project is analyzed again, the old cursor answers CONFLICT. Start again without a cursor.

Idempotency

A request that spends credits, or that creates something, can fail on the way back to you. A timeout does not tell you whether the work started. The Idempotency-Key header lets you send the request again without the risk of paying twice.

Create the key once for each request, before you send it. It can be any random string up to the length in the table below, and a UUID works well. Keep it in a variable. If the request fails or times out, send it again with the same key.

What the API does with a key:

  • A successful answer is stored for a limited time. If you send the same request with the same key, you get the same answer again, and the response has the header Idempotent-Replayed: true. The work does not run a second time.
  • After an error, including a 500, the key can be used again. Send the same request with the same key and it runs, and it does not spend twice.
  • While the first request with the key is still running, a second one answers IDEMPOTENCY_IN_PROGRESS. Wait for Retry-After, then send it again.
  • A key that was used for a different request answers IDEMPOTENCY_KEY_REUSED. Create a new key for the new request.
  • Once the answer is no longer stored, the same key is a new request.

A new key is a new request. If you change the key to retry, you can be charged a second time. Always retry with the key you already have.

Operations that spend credits require the header. Without it they answer IDEMPOTENCY_KEY_REQUIRED. Operations that create something accept it too. Send it there as well, so that a retry does not create a second one. The page of each operation says whether the key is required.

The page size and the time a key is kept are in this table.

LimitValue
Items in a page (limit)From 1 to 100, 20 by default
Idempotency-Key length255 characters
How long an Idempotency-Key is kept24 hours
Longest wait25 seconds
Requests with wait open at once for each account10
A download link works for15 minutes
Size of an upload part8 MiB
Parts in an upload1024
Largest upload8 GiB
Largest JSON body1 MiB

The code samples on the operation pages create the key in a variable before the request, so you can copy them and retry with the same key.

The next two requests start an analysis. The first finds a project to analyze, and the second sends the analysis with its key.

list_projects (GET /v1/projects). List the projects.

Request example
# This command needs jq to read the answer.
PROJECT_ID=$(curl -sS "https://api.gpt-video.com/v1/projects" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY" | jq -r '.data[0].id')
const res = await fetch("https://api.gpt-video.com/v1/projects", {
  method: "GET",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
  },
});
const data = await res.json();
const projectId = data.data[0].id;
import os

import requests

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

GET /v1/projects

HTTP 200
{
  "data": [
    {
      "id": "prj_1d8b6e0c47a24f93b1c2",
      "kind": "autoclip",
      "title": "Weekly podcast",
      "status": "draft",
      "sourceAssetId": null,
      "compositionId": null,
      "settings": {
        "language": null,
        "clipCount": 5,
        "minDurationSec": null,
        "maxDurationSec": null,
        "objective": "educational",
        "captionStyle": null,
        "reframe": null,
        "instructions": null,
        "vocabulary": null
      },
      "missingSettings": [
        "language",
        "minDurationSec",
        "maxDurationSec",
        "captionStyle",
        "reframe"
      ],
      "splitScreen": null,
      "streamer": null,
      "redditStory": null,
      "fakeText": null,
      "vibeEdit": null,
      "errorMessage": null,
      "createdAt": "2026-09-01T09:50:00.000Z",
      "updatedAt": "2026-09-01T09:50:00.000Z"
    },
    {
      "id": "prj_7c2e9a4f1b3d4e8096a5",
      "kind": "autoclip",
      "title": "Founder interview",
      "status": "ready",
      "sourceAssetId": "ast_3f9a1c7e5b2d4086a1c4",
      "compositionId": null,
      "settings": {
        "language": "en",
        "clipCount": 3,
        "minDurationSec": 15,
        "maxDurationSec": 60,
        "objective": "viral",
        "captionStyle": "word-pop",
        "reframe": "face",
        "instructions": null,
        "vocabulary": null
      },
      "missingSettings": [],
      "splitScreen": null,
      "streamer": null,
      "redditStory": null,
      "fakeText": null,
      "vibeEdit": null,
      "errorMessage": null,
      "createdAt": "2026-09-01T09:10:00.000Z",
      "updatedAt": "2026-09-01T09:40:00.000Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}

analyze_project (POST /v1/projects/{projectId}/analyze). Analyze a project.

Request example
# Create the key once. Send this same key again if you retry this request.
IDEMPOTENCY_KEY=$(uuidgen)
curl -X POST "https://api.gpt-video.com/v1/projects/$PROJECT_ID/analyze" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY"
// 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}/analyze`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
    "Idempotency-Key": idempotencyKey,
  },
});
const data = await res.json();
console.log(data);
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}/analyze",
    headers={
        "Authorization": f"Bearer {os.environ['GPT_VIDEO_API_KEY']}",
        "Idempotency-Key": idempotency_key,
    },
)
data = r.json()
print(data)
Example response

POST /v1/projects/prj_7c2e9a4f1b3d4e8096a5/analyze

HTTP 202
{
  "job": {
    "id": "job_b582bc8315e372bf9ba3",
    "kind": "analysis",
    "status": "queued",
    "progress": 0,
    "errorMessage": null,
    "projectId": "prj_7c2e9a4f1b3d4e8096a5",
    "compositionId": null,
    "exportId": null,
    "assetId": null,
    "toolRunId": null,
    "turnId": null,
    "createdAt": "2026-09-01T11:10:00.000Z",
    "startedAt": null,
    "finishedAt": null
  },
  "creditsCharged": 30,
  "reused": false,
  "paidWith": "plan"
}

When the same analysis is already running, the call does not start a second one and does not charge again. The response then says reused: true.

Errors for these rules

The errors page lists VALIDATION_FAILED, CONFLICT, IDEMPOTENCY_KEY_REQUIRED, IDEMPOTENCY_IN_PROGRESS and IDEMPOTENCY_KEY_REUSED, with what to do for each.