Keep a copy in sync
The change feed lists every new item, change and removal in order. Read it with a cursor and you get each change exactly once.
The change feed (/changes) is the way to keep your own copy of a dataset current: instead of reading everything
again, you read what happened since last time. It is in the paid plans; on Free it answers 402.
How it works
Every change has an id, and ids only grow. You ask for the changes after the last id you have; the answer gives you the next ones, oldest first, and the cursor to continue from:
curl -H "Authorization: Bearer $PRICANA_KEY" "https://pricana.io/api/v1/changes?after=0&limit=1000"{
"changes": [
{
"id": 812,
"kind": "CHANGED",
"observedAt": "2026-10-06T21:03:00Z",
"price": 1399.0,
"previousPrice": 1499.0,
"changes": { "price": [1499.0, 1399.0] },
"item": { "id": 77, "title": "Lenovo ThinkPad X1 Carbon Gen 12", "url": "https://…", "currency": "EUR" }
}
],
"nextCursor": 812,
"hasMore": true
}- Start with
after=0(the beginning of your plan's history), or with a cursor you stored. - Process the changes. Then pass
nextCursorasafterin the next request. - While
hasMoreistrue, ask again at once. When it isfalse, you are up to date: store the cursor and ask again later (every few minutes is plenty).
Read like this, you get each change exactly once, in order, also after a pause: the feed keeps your plan's history (e.g. 365 days on Pro). Changes appear in the feed about 30 seconds after they happen.
A complete sync loop
import os, time, requests
KEY = os.environ["PRICANA_KEY"]
BASE = "https://pricana.io/api/v1"
def load_cursor() -> int:
try:
return int(open("pricana.cursor").read())
except FileNotFoundError:
return 0
def save_cursor(cursor: int) -> None:
with open("pricana.cursor", "w") as f:
f.write(str(cursor))
cursor = load_cursor()
while True:
r = requests.get(f"{BASE}/changes", params={"after": cursor, "limit": 1000},
headers={"Authorization": f"Bearer {KEY}"}, timeout=60)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "5")))
continue
r.raise_for_status()
page = r.json()
for change in page["changes"]:
apply(change) # your code: insert, update or mark removed
cursor = page["nextCursor"]
save_cursor(cursor) # after applying: a crash repeats changes, never skips them
if not page["hasMore"]:
time.sleep(300)Store the cursor after you applied the changes, in the same transaction if you can. Then a crash at worst repeats
some changes, and applying a change twice does no harm if you key your rows by item id.
Applying changes
| kind | in your copy |
|---|---|
NEW | insert the item (read it with /items/{id} if you need all its fields) |
CHANGED | update it: changes has [old, new] for every field that changed; price is the new price |
REMOVED | mark it as no longer listed |
RELISTED | mark it as listed again |
Changes carry the item's id, title, link and site. For the full item, read /items/{id}, or read many at once with
/items?changedSince= (below).
The first copy
The feed starts at the beginning of your plan's history, which may be long. To start fresh instead:
- Read
/changes/recent?limit=1and keep theidof the change in it (with none, keep0). A change shows there when it shows in the feed, so nothing after it is missed. - Then read all items with an export (or
/itemspage by page). - Read the feed from
after=<that id>. Changes made during the export come once more; applying a change again does no harm.
Simpler and often enough: /items?changedSince=<time of last sync>&status=all returns every item that changed since
then, in its current state. Removed items are among them with removedAt set: delete them from your copy. (Without
status=all you get listed items only, and a copy kept this way never learns of removals.)
One dataset only
dataset=<id> narrows the feed to one of your datasets. Keep one cursor per feed you read.
Pushed instead of polled
Webhooks send the same changes to your server as they happen, in the same order, with the same objects. Use them when you want changes pushed within a minute, the feed when you prefer to pull.