URL: /docs/guides/incremental-sync

---
title: Keep a local copy in sync
description: Poll GET /api/v1/domains with since to receive only what changed, including tombstones for listings that have left.
---

If you hold a local copy of the directory, `since` is how you keep it current without re-paging 17,713 rows. A `since` page returns listings whose `updated_at` is at or after the instant you pass, plus tombstones for listings that have left.

<Warning>
  `since` is **Business plan only**. On Free and Pro it answers `402 upgrade_required`, and it is the only thing on this API a plan can be refused. Check `sync_feed` on `GET /api/v1/me` before you build on it. Paging with `cursor` reaches the same rows without it — it is just more requests.
</Warning>

## The watermark rule

Every page carries `synced_at`. **Pass the first page's value as the next run's `since`.** Not the last page's, and not your own clock.

Two properties make that correct:

- `synced_at` is read **before any row is**, so a listing updated halfway through a long run is picked up on the next run rather than falling into the gap.
- It trails server time by 120 seconds, deliberately. A row written just before your poll cannot fall through the window between being stamped and being committed.

Consecutive polls therefore **overlap by design**. You will see rows you already have. That is the feed working, not a bug.

## Every row is idempotent

Every row is idempotent by `id` plus `updated_at`. Because of that overlap, the only safe consumer is an upsert keyed on `id`, which ignores a row it already holds at the same `updated_at`.

```python
import time

import requests

BASE = "https://getrevised.com"


def sync(session, since=None, limit=250, pause=0.6):
    """Pull everything changed since `since`. Returns (upserts, deletes, watermark)."""
    upserts, deletes = [], []
    cursor, watermark = None, None

    while True:
        params = {"limit": limit}
        if since:
            params["since"] = since
        if cursor:
            params["cursor"] = cursor

        response = session.get(f"{BASE}/api/v1/domains", params=params, timeout=30)
        if response.status_code == 402:
            raise RuntimeError("the since feed is Business-only; check sync_feed on /me")
        response.raise_for_status()
        payload = response.json()

        if watermark is None:
            watermark = payload["synced_at"]  # FIRST page only

        for row in payload["data"]:
            if row["status"] == "delisted":
                deletes.append(row["id"])
            else:
                upserts.append(row)

        if not payload["has_more"]:
            return upserts, deletes, watermark
        cursor = payload["next_cursor"]
        time.sleep(pause)
```

```python
upserts, deletes, watermark = sync(session, since="2026-09-21T03:12:07.000Z")
print(len(upserts), "changed,", len(deletes), "delisted; next since =", watermark)
```

```text Output
612 changed, 27 delisted; next since = 2026-09-22T03:12:07.000Z
```

Advance your stored watermark **only when the whole run completed**. If a page fails halfway through, discard the new watermark and re-run from the old one — the overlap costs you a few duplicate rows, and skipping forward costs you rows you will never see again.

## Tombstones

A row that has left the directory comes back with nothing but three fields:

```json
{
  "id": "X00w46xl",
  "status": "delisted",
  "updated_at": "2026-09-21T14:22:08.000Z"
}
```

It is reported the same way whether the listing was delisted, sold or blocked — the reason is not disclosed. Every row in a `since` page carries `status`, so **read that before you treat a row as a listing**. A consumer that assumes `data[]` is homogeneous will try to read `tier` off a tombstone and fail.

Outside the change feed, `GET /api/v1/domains/{id}` simply returns `404` once a listing has gone.

## First run

There is no bootstrap endpoint. Page the whole directory once without `since`, keep the first page's `synced_at`, and poll from there.

```python
rows, _, watermark = sync(session)          # full crawl, no since
store_all(rows)
save_watermark(watermark)

# later, on a schedule
changed, gone, watermark = sync(session, since=load_watermark())
```

Pick a poll interval that fits your rate limit: 120 on Business, per key, per minute.

## What the feed does not tell you

**Hold state does not move `updated_at`.** Taking, extending or ending a hold is invisible to `since`, so a row whose `hold_state` flipped will not be re-delivered. If hold state matters to you, read the listing directly, or call `GET /api/v1/holds` for your own holds.

**`sort=latest` cannot be combined with `since`.** The change feed is ordered by listing id, and its cursors belong to that ordering. Use `sort=latest` for "what is new in the directory" browsing, and `since` for synchronisation — they are different jobs.

**`discovered_at` is not `updated_at`.** `discovered_at` is written once, when the listing entered the directory, and never rewritten — which is what makes `sort=latest` safe to page. It is a fact about the directory, not about the domain: it is neither the registration date nor the date the name expired.

## Storing what comes back

Two details will bite a schema that ignores them:

- **Ids are case-sensitive.** `00aLaXEG` and `00alaxeg` are not the same listing. Use a case-sensitive column and a case-sensitive unique index.
- **A `null` metric means absent, not zero.** Keep `metrics_missing` alongside the row, or store the metrics as nullable and never coalesce them to `0` on the way in. See [Concepts](/docs/concepts#missing-is-not-zero).
