Skip to content

Guides

Pagination

Every list of the account's own records pages by cursor; the catalogue lists are short and answer whole. A cursor marks the last item of the page you were given, so the next page starts right after it rather than after a count of items to skip.

The shape

A list takes limit and cursor and answers an array plus nextCursor. Ask for the next page by sending back exactly the string you were given.

http
GET /api/v1/webhooks?limit=2

{
  "data": [
    {
      "id": "8a92a00b-3e6b-42a8-94ef-d23cdd0c68ab",
      "url": "https://hooks.example.com/gogoscreen-3",
      "events": ["marketing.export.succeeded"],
      "description": "Docs example 3",
      "status": "active",
      "failureStreak": 0,
      "disabledAt": null,
      "disabledReason": null,
      "lastDeliveryAt": null,
      "createdAt": "2026-09-21T16:18:17.000Z",
      "updatedAt": "2026-09-21T16:18:17.000Z"
    },
    {
      "id": "424f6a3e-27a7-4d0b-8e2a-d2ba7ece31ee",
      "url": "https://hooks.example.com/gogoscreen-2",
      "events": ["marketing.export.succeeded"],
      "description": "Docs example 2",
      "status": "active",
      "failureStreak": 0,
      "disabledAt": null,
      "disabledReason": null,
      "lastDeliveryAt": null,
      "createdAt": "2026-09-21T16:18:17.000Z",
      "updatedAt": "2026-09-21T16:18:17.000Z"
    }
  ],
  "nextCursor": "eyJ0IjoiMjAyNi0wOS0yMVQxNjoxODoxNy4wMDBaIiwiaSI6IjQyNGY2YTNlLTI3YTctNGQwYi04ZTJhLWQyYmE3ZWNlMzFlZSJ9"
}
http
GET /api/v1/webhooks?limit=2&cursor=eyJ0IjoiMjAyNi0wOS0yMVQxNjoxODoxNy4wMDBaIiwiaSI6IjQyNGY2YTNlLTI3YTctNGQwYi04ZTJhLWQyYmE3ZWNlMzFlZSJ9

{
  "data": [
    {
      "id": "1bb1ee72-8b7b-405e-86fa-eabfcd5f40b9",
      "url": "https://hooks.example.com/gogoscreen",
      "events": ["render.succeeded", "render.failed", "storyboard.succeeded"],
      "description": "Docs example receiver",
      "status": "active",
      "failureStreak": 0,
      "disabledAt": null,
      "disabledReason": null,
      "lastDeliveryAt": "2026-09-21T16:16:28.000Z",
      "createdAt": "2026-09-21T16:16:20.000Z",
      "updatedAt": "2026-09-21T16:16:28.000Z"
    },
    {
      "id": "f1b6ba57-005d-4fc8-93c8-b6a4f920b945",
      "url": "https://hooks.example.com/older",
      "events": ["render.succeeded"],
      "description": null,
      "status": "active",
      "failureStreak": 0,
      "disabledAt": null,
      "disabledReason": null,
      "lastDeliveryAt": null,
      "createdAt": "2026-09-21T16:15:53.000Z",
      "updatedAt": "2026-09-21T16:15:53.000Z"
    }
  ],
  "nextCursor": null
}
The last page. nextCursor is null, so the walk stops.
FieldTypeDefaultMeaning
limitinteger20Rows per page, 1 to 100. Most lists default to 20; the 3 named below default to 100. A value outside the range is refused, not clamped.
cursorstringnoneThe nextCursor of the previous page. Omit it for the first page.
dataarrayThe rows, newest first. See the two exceptions below for lists that name the array something else.
nextCursorstring | nullThe cursor for the next page, or null when this was the last one. Null is the end of the walk and is never an empty page with a cursor attached.

Walking every page

The loop is always the same: ask, consume, follow nextCursor, stop when it is null. Never count pages and never compute a cursor.

# Walk every render, 100 at a time. jq reads the cursor back out.
BASE=https://api.gogoscreen.com/api/v1
cursor=""

while : ; do
  url="$BASE/renders?limit=100"
  [ -n "$cursor" ] && url="$url&cursor=$cursor"

  page=$(curl -sS "$url" -H "Authorization: Bearer $GOGOSCREEN_API_KEY")
  echo "$page" | jq -c '.data[]'

  cursor=$(echo "$page" | jq -r '.nextCursor // empty')
  [ -z "$cursor" ] && break
done

The cursor is opaque

A cursor is base64url of a small object, and you must treat it as a string with no structure. Do not parse it, do not build one, do not store a decoded version and reassemble it later. The encoding is free to change, and a client that reads a timestamp out of it will eventually write one back in, at which point the shape becomes a contract nobody agreed to.

It is not signed, and it does not need to be. It carries nothing secret and grants nothing: results are always limited to your own account, so a forged cursor can only page through your own items from a moment you invented. It is still bounded, because an unbounded one would be a way to make the server do work: the timestamp must be within a hundred years of now and the id must look like an id this product mints. Anything else is 400 invalid_cursor.

http
HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "invalid_cursor",
    "message": "That page cursor is not valid.",
    "requestId": "36052921-f6bb-46de-ba7a-8a8837d95eff"
  }
}
GET /renders?cursor=not-a-cursor

What to do about it: drop the cursor and start the walk again from the first page. A cursor that is refused will not become valid.

limit is refused, not clamped

limit=0 and limit=500 are both 400 validation_failed with the field named. A silently clamped limit would mean a caller asking for 500 and getting 100 with no way to know the difference between a small page and the end of a list.

http
HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "validation_failed",
    "message": "That request is not valid.",
    "details": {
      "issues": [
        { "path": "limit", "message": "Too big: expected number to be <=100", "code": "too_big" }
      ]
    },
    "requestId": "3ad0cd65-05e6-453d-9b6a-304151d6ef52"
  }
}
GET /webhooks?limit=500

The order, and why it is not offset paging

Lists are ordered createdAt DESC, id DESC: newest first, with the id breaking a tie. The id is part of the order rather than decoration. Two rows written in the same millisecond do happen, and createdAt alone would make one of them unreachable.

Two lists are ordered differently, and always have been.

  • A project's revisions are ordered by their number, a unique integer inside the project, because that is what going back to version seven means.
  • Saved render templates are ordered by updatedAt, which is the order the dashboard shows them in.

The dashboard pages with an offset and a limit, which is right for a dashboard: the page is drawn once and a row inserted in between only shifts something nobody is looking at. It is wrong for an API. An agent walking five thousand renders with an offset while renders are being made sees rows twice and misses rows entirely, because every insert at the head shifts the whole tail by one. And every page costs the server the items it skipped, so page two hundred is two hundred times the work of page one.

A cursor has neither problem. The page after a given item starts right there, so page two hundred costs what page one costs, and a row inserted at the head is not part of a walk that started before it existed.