Keep a local copy in sync
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.
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_atis 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.
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)upserts, deletes, watermark = sync(session, since="2026-09-21T03:12:07.000Z")
print(len(upserts), "changed,", len(deletes), "delisted; next since =", watermark)612 changed, 27 delisted; next since = 2026-09-22T03:12:07.000ZAdvance 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:
{
"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.
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.
00aLaXEGand00alaxegare not the same listing. Use a case-sensitive column and a case-sensitive unique index. - A
nullmetric means absent, not zero. Keepmetrics_missingalongside the row, or store the metrics as nullable and never coalesce them to0on the way in. See Concepts.