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.
limitis how many items you want on a page. Without it you get the default page size.cursoris the place to continue from. Leave it out to get the first page.
Every list answers with the same three fields.
dataholds the items of this page.hasMoreistruewhen more items follow.nextCursoris the cursor for the next page. On the last page it isnull.
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.
# 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
{
"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.
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
{
"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 forRetry-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.
| Limit | Value |
|---|---|
Items in a page (limit) | From 1 to 100, 20 by default |
Idempotency-Key length | 255 characters |
How long an Idempotency-Key is kept | 24 hours |
Longest wait | 25 seconds |
Requests with wait open at once for each account | 10 |
| A download link works for | 15 minutes |
| Size of an upload part | 8 MiB |
| Parts in an upload | 1024 |
| Largest upload | 8 GiB |
| Largest JSON body | 1 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.
# 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
{
"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.
# 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
{
"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.