Keeping a mirror in step
Most integrations end up holding a copy of some part of the catalogue. Doing that badly is the most expensive mistake available on this API, and it has three recognisable forms: re-reading everything on a timer, paging with an offset, and treating an absent record as a deleted one.
The shape that works
- Backfill once. Page the filter you care about from the first cursor to the last, storing records as you go.
- Record the high-water mark. Keep the newest
updated_atyou saw, not the time your job ran. They are different, and the difference is where records go missing. - Poll for changes. Ask for
updated_sincea few minutes before that mark. - Or stop polling. Webhooks remove this loop entirely, and cost no quota.
import { Skautik } from "@skautik/sdk";
const skautik = new Skautik({ apiKey: process.env.SKAUTIK_API_KEY! });
async function backfill(city: string) {
let newest = "";
for await (const property of skautik.properties.listAll({ city })) {
await upsert(property);
if (property.updatedAt > newest) {
newest = property.updatedAt;
}
}
return newest;
}The iterator follows the cursor for you and stops on has_more, which is the
only correct condition. Writing the loop by hand is where people stop on a short
page, and a full page can still be the last one.
Overlap the window
Ask for a few minutes before your high-water mark, not the mark itself:
GET /v1/api/properties?city=Berlin&updated_since=2026-08-11T03:55:00ZRecords are written concurrently. A strict boundary misses anything committed a moment after your cutoff but stamped a moment before it, and that record is then missing until it happens to change again. Handling the same record twice costs nothing if your write is an upsert; missing one costs you a wrong answer for an unbounded length of time.
Absence is not deletion
A record that stops appearing in a filtered list has not necessarily gone. It
may have changed in a way that moves it out of your filter: a price rise past
your max_price, a change of district, a withdrawal.
Withdrawal is explicit. A withdrawn property keeps its identifier and stays
readable by id; its listing status says archived or sold. It leaves the
default list because the default is active listings, not because it stopped
existing.
So: never infer deletion from absence. Either subscribe to property.withdrawn,
or ask for the status explicitly when you need to know:
GET /v1/api/properties?city=Berlin&status=archivedIf your mirror deletes anything that fell out of a filtered page, one price change will delete a property that is still on the market.
What to store
Store the identifier and the updated_at at minimum. The identifier is opaque
and stable; the timestamp is what makes the next sync cheap.
Store external_id too if you are also the source of the record. It is your own
identifier echoed back, and it is what lets you reconcile without keeping a
mapping table of our ids to yours.
Cost
| Approach | Requests per day, 20,000 properties |
|---|---|
| Re-read hourly, 200 per page | 2,400 |
updated_since hourly | 24 to 50, depending on churn |
| Webhooks | 0 |
The first row is why quotas exist. The third is why webhooks do.
When a backfill is the wrong tool
Sustained, high-volume reads of the whole catalogue are better served by an export than by a faster crawl. An export is one request, produces one file, and does not compete with your own production traffic for the burst limit. Ask before building something that pages the whole market every night.