URL: /docs/api/pagination

---
title: Pagination and sync
description: Opaque cursor paging on GET /api/v1/domains, the synced_at watermark, the since change feed, and tombstones.
---

`GET /api/v1/domains` is the only paged endpoint. It pages by opaque cursor, and on the Business plan the same endpoint doubles as a change feed.

## The page envelope

```json
{
  "data": [ /* up to `limit` listings, or tombstones on a `since` page */ ],
  "next_cursor": "00aLaXEG",
  "has_more": true,
  "synced_at": "2026-09-22T01:45:55.000Z"
}
```

| Field | Type | What it is |
| --- | --- | --- |
| `data` | array | Listings. On a `since` page it mixes listings and tombstones — read `status` on every row. |
| `next_cursor` | string or null | Pass as `cursor` for the next page. `null` on the last page. |
| `has_more` | boolean | Mirrors `next_cursor !== null`. |
| `synced_at` | string | The watermark for your **next** run. See [Incremental sync](#incremental-sync). |

## `limit`

| Default | Maximum | Minimum |
| --- | --- | --- |
| 50 | 250 | 1 |

An integer out of range is **clamped, not rejected** — ask for 5000 and you get 250, ask for 0 and you get 1.

A `limit` that is not a whole number is refused with `400` `invalid_request`, and that **includes the empty string**. `?limit=` is what `?limit=${n}` sends for an undefined `n`, and answering it with a page size of 1 would quietly make a full sync 250 times longer.

## `cursor`

`cursor` is an opaque string taken from the previous response's `next_cursor`.

<Warning>
  Treat it as opaque and do not construct one. The value is checked both for shape and for a position this directory actually holds, so a cursor this endpoint did not issue is a `400` `invalid_request` — never an empty page, and never a silent resume from the middle of the feed.
</Warning>

A cursor belongs to the ordering it came from, so **pass the same `sort` on every page of a run**. The default ordering's cursor is a listing id; `sort=latest`'s is a position in that order.

## Ordering

| `sort` | Ordering | Cursor-safe because |
| --- | --- | --- |
| omitted | Ascending listing id. The default since v1. | A listing id is written once and never rewritten. |
| `latest` | Newest `discovered_at` first, tie-broken by id. | `discovered_at` is written once and never rewritten. |

No other value is accepted. Every other ordering the website offers is over a column an update can move, and a feed that reshuffles under a cursor can skip or repeat rows — so the endpoint refuses those rather than serving one.

`latest` cannot be combined with `since`: the change feed is ordered by id.

## Paging the whole directory

Follow `next_cursor` until it comes back `null`. This works on every plan.

```bash
curl "https://getrevised.com/api/v1/domains?limit=250&rsMin=60" \
  -H "Authorization: Bearer $REVISED_API_KEY"
```

```python
import os, requests

BASE = "https://getrevised.com/api/v1"
AUTH = {"Authorization": f"Bearer {os.environ['REVISED_API_KEY']}"}

def page_all(**filters):
    """Every listing matching `filters`, one page at a time."""
    cursor = None
    while True:
        params = {"limit": 250, **filters}
        if cursor:
            params["cursor"] = cursor
        page = requests.get(f"{BASE}/domains", headers=AUTH, params=params, timeout=30).json()
        yield from page["data"]
        cursor = page["next_cursor"]
        if cursor is None:
            return

count = sum(1 for _ in page_all(rsMin=60, tld="com"))
print(f"{count} listings")
```

```text Output
1843 listings
```

Paging cannot skip or repeat a row while the directory is updated underneath you, because both orderings are over write-once columns.

## Incremental sync

<Note>
  `since` is **Business plan only**. Every other parameter and endpoint on this API answers every plan. On Free and Pro, `since` is refused with `402` `upgrade_required`, and paging with `cursor` reaches the same rows — it costs a full pass instead of a delta. Check `sync_feed` on `GET /api/v1/me`.
</Note>

`since` takes an ISO 8601 instant and is matched, inclusively, against each row's `updated_at`.

```bash
curl "https://getrevised.com/api/v1/domains?since=2026-09-21T23:45:55.000Z&limit=250" \
  -H "Authorization: Bearer $REVISED_API_KEY"
```

### Use `synced_at`, not your own clock

Pass the `synced_at` from the **first page of your previous run** as the next run's `since`. Never `datetime.now()`.

`synced_at` is taken before any row on that page is read, so a listing updated part-way through a run is picked up by the next run rather than skipped. It is also held deliberately **120 seconds behind server time**, which is what makes a row written just before your previous poll impossible to miss: a row is stamped a moment before it is committed, and a watermark taken in that gap would have filtered it out for good.

Consecutive polls therefore **overlap on purpose**. Expect to see rows you already hold, and write them straight through — a row is fully described by its `id` plus its `updated_at`, so upsert on `id` and ignore a row you already hold at that `updated_at`.

### Changed includes gone

A `since` page mixes full listings with **tombstones**: rows that have left the directory since your watermark.

```json A tombstone, as it arrives in the data array
{
  "id": "00aLaXEG",
  "status": "delisted",
  "updated_at": "2026-09-02T04:11:08.512Z"
}
```

A tombstone carries `id`, `status` and `updated_at` and nothing else — no name, because you already hold the name against that id from the poll that delivered it. Sold, blocked and delisted all report as `delisted`; they mean the same thing to a consumer.

**Read `status` before treating a row as a listing.** `active` is a listing to upsert; `delisted` is an id to drop. A first sync with no `since` returns listings only.

### A complete sync loop

```python
import os, requests

BASE = "https://getrevised.com/api/v1"
AUTH = {"Authorization": f"Bearer {os.environ['REVISED_API_KEY']}"}

def sync(since: str | None) -> str:
    """Apply everything changed since `since`. Returns the next watermark."""
    cursor, watermark = None, None
    while True:
        params = {"limit": 250}
        if since:
            params["since"] = since
        if cursor:
            params["cursor"] = cursor

        page = requests.get(f"{BASE}/domains", headers=AUTH, params=params, timeout=60).json()

        # Take the watermark from the FIRST page only: it was read before
        # any row was, so a row written mid-run is caught by the next run.
        if watermark is None:
            watermark = page["synced_at"]

        for row in page["data"]:
            if row["status"] == "delisted":
                drop(row["id"])
            else:
                upsert(row)          # idempotent on (id, updated_at)

        cursor = page["next_cursor"]
        if cursor is None:
            return watermark

watermark = load_watermark()             # None on a first run
watermark = sync(watermark)
save_watermark(watermark)
```

```text Output
applied 312 listings, dropped 4 ids
next watermark: 2026-09-22T01:45:55.000Z
```

### What `since` does not catch

Taking, extending or ending a hold does **not** move a listing's `updated_at`, so an incremental poll will not re-deliver a row whose hold state changed. Read the listing directly with `GET /api/v1/domains/{id}`, or call `GET /api/v1/holds` for your own.

## Listing ids

Ids are eight characters and **case-sensitive**, mixed case in practice (`00aLaXEG`, `X00w46xl`). Store them exactly as they arrive; a lower-cased id matches nothing, including in a cursor.

## Filters and paging together

A cursor names a position in the ordering, not the query that produced it. Change a filter mid-run and the next page continues from that position under the new filters, silently skipping every matching row behind it — no error, just a short result. Fix your filters before the first page and keep them, along with `sort`, for every page of the run. See [Filters](/docs/api/filters).
