---
title: "Pagination and idempotency in the GPT-Video API"
description: "Read long lists page by page with a cursor, and retry a request that spends credits with the same Idempotency-Key so it never charges twice."
url: "https://www.gpt-video.com/developers/pagination-and-idempotency"
---

# 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`](/developers/api/list-projects.md) (`GET /v1/projects`). Ask for one project per page.

```bash title="curl"
# 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')
```

```ts title="TypeScript"
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;
```

```python title="Python"
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`

```json title="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`](/developers/api/list-projects.md) (`GET /v1/projects`). Read the next page.

```bash title="curl"
curl "https://api.gpt-video.com/v1/projects?limit=1&cursor=$CURSOR" \
  -H "Authorization: Bearer $GPT_VIDEO_API_KEY"
```

```ts title="TypeScript"
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);
```

```python title="Python"
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`

```json title="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.

| 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`](/developers/api/list-projects.md) (`GET /v1/projects`). List the projects.

```bash title="curl"
# 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')
```

```ts title="TypeScript"
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;
```

```python title="Python"
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`

```json title="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`](/developers/api/analyze-project.md) (`POST /v1/projects/{projectId}/analyze`). Analyze a project.

```bash title="curl"
# 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"
```

```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}/analyze`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.GPT_VIDEO_API_KEY}`,
    "Idempotency-Key": idempotencyKey,
  },
});
const data = await res.json();
console.log(data);
```

```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}/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`

```json title="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](/developers/errors.md) lists `VALIDATION_FAILED`, `CONFLICT`, `IDEMPOTENCY_KEY_REQUIRED`, `IDEMPOTENCY_IN_PROGRESS` and `IDEMPOTENCY_KEY_REUSED`, with what to do for each.
