Skip to content

Sync incrementally instead of re-fetching

Verified on production13 min

Every response carries api.timestamp. Send it back on the next request as the timestamp query parameter and you get only the records that changed since then. This is the difference between pulling 87 records and pulling 2.

If you are polling any catalogue or list on a schedule and not using this, you are re-downloading a dataset that did not change.

Two fields do the work:

FieldWhereMeaning
api.timestampresponse envelopeserver time when this response was generated
uton each recordUNIX timestamp of that record's last update

You store api.timestamp and replay it. The full loop:

Terminal window
# 1. pierwszy, pełny przebieg
curl "https://api.statscore.com/v2/incidents?token=YOUR_TOKEN&sport_id=5&limit=250"
{
"api": {
"timestamp": 1785843788,
"method": { "name": "incidents.index", "total_items": 87 },
"data": { "incidents": [ { "id": 400, "name": "In possession", "ut": 1484735880 }, "…" ] }
}
}
Terminal window
# 2. kolejny przebieg - podajemy zapamiętany timestamp
curl "https://api.statscore.com/v2/incidents?token=YOUR_TOKEN&sport_id=5&limit=250&timestamp=1749470582"
{
"api": {
"timestamp": 1785843817,
"method": { "name": "incidents.index", "total_items": 2 },
"data": {
"incidents": [
{ "id": 3106, "name": "Play short-handed", "ut": 1784109946 },
{ "id": 3107, "name": "Player return to pitch", "ut": 1784109946 }
]
}
}
}

87 → 2. Note that total_items also drops: it reports the size of the filtered result, not the size of the collection. Do not use it as a record count for your local store.

2. The boundary is inclusive - your writes must be idempotent

Section titled “2. The boundary is inclusive - your writes must be idempotent”

This is the part that quietly corrupts data if you get it wrong.

The filter is ut >= timestamp, not >. Measured directly: the two records above have ut: 1784109946, and a request with timestamp=1784109946 still returns both of them.

Requesttotal_items
no timestamp87
timestamp=17494705822
timestamp=1784109946 - exactly the records' own ut2

So every sync re-delivers the records that sat on the previous boundary.

Three rules, each of which has a failure mode attached:

Store api.timestamp, not max(ut). The response timestamp is server time and is always at least the highest ut in the payload. Deriving your cursor from max(ut) looks equivalent but breaks on an empty result - there is no ut to take a maximum of, so you either keep the old cursor (correct, by accident) or write 0 (a full re-sync).

Advance the cursor only after the write succeeds. If you commit the cursor first and the write then fails, those records are gone from your next window forever. Order: fetch → write → commit cursor.

Do not share one cursor across endpoints. Each collection moves at its own pace; one cursor per (endpoint, filter) pair. A cursor for sport_id=5 says nothing about sport_id=1.

import { apiGet } from "./client.js"; // z recipe 01
/**
* Przyrostowa synchronizacja jednej kolekcji.
* Kursor to `api.timestamp` z odpowiedzi, NIE max(ut) - max(ut) nie istnieje
* przy pustym wyniku.
*/
export async function syncCollection({ path, key, params = {}, cursor, upsert }) {
// apiGet zwraca api.data, a my potrzebujemy też api.timestamp - stąd wariant raw
const body = await apiGetRaw(path, { ...params, ...(cursor ? { timestamp: cursor } : {}) });
const records = body.api.data?.[key] ?? [];
// Granica jest INKLUZYWNA (ut >= timestamp), więc rekordy z poprzedniej granicy
// przyjdą ponownie. Zapis musi być idempotentny - klucz na id.
for (const r of records) await upsert(r);
// kursor przesuwamy DOPIERO po udanym zapisie
return { cursor: body.api.timestamp, changed: records.length };
}
async function apiGetRaw(path, params) {
const url = new URL("https://api.statscore.com" + path);
url.searchParams.set("token", await getToken());
for (const [k, v] of Object.entries(params)) {
if (v != null) url.searchParams.set(k, String(v));
}
const res = await fetch(url, { headers: { Accept: "application/json" } });
const body = await res.json();
// logujemy ścieżkę, nie URL - URL zawiera token
if (!res.ok) throw new Error(`GET ${path} → HTTP ${res.status}`);
return body;
}
# Python - ten sam kontrakt
def sync_collection(path: str, key: str, cursor: int | None, upsert, **params):
if cursor:
params["timestamp"] = cursor
body = api_get_raw(path, **params)
records = (body["api"].get("data") or {}).get(key) or []
# granica inkluzywna → rekordy z poprzedniej granicy wrócą; upsert po id
for r in records:
upsert(r)
# kursor przesuwamy po zapisie, nie przed
return {"cursor": body["api"]["timestamp"], "changed": len(records)}

Every endpoint has a cache time, and polling faster than it just burns quota against a response that cannot have changed. /v2/stages/{id}/bracket caches for 60 seconds, so a bracket poller tighter than 60 s is pure waste.

Two mechanisms, different jobs - use both:

timestampIf-Modified-Since → 304
Savespayload sizethe whole response body
Granularityper recordper response
Cursor to keepapi.timestampHTTP date

timestamp narrows what comes back; 304 Not Modified skips the transfer entirely when nothing changed at all. A poller can send both.

timestamp is a change filter, not a livescore mechanism. For a match in progress, the useful signals are /v2/livescore and /v2/feed (recipe 05) - the update rate there is driven by play, and you want the current state, not a diff.

Use incremental sync for the things that change slowly and that you mirror locally:

CollectionWhy mirror it
/v2/incidents87 records for soccer alone; ids and attributes you resolve on every timeline render
/v2/statuses369 records; needed to label every match
/v2/sports, /v2/areas, /v2/languagestiny, near-static, and you should never hardcode the ids
/v2/competitions, /v2/seasons, /v2/participantslarge, slow-moving, expensive to re-pull
Statusinternal_codeCause
4003timestamp older than the allowed minimum - the message carries the minimum; drop the parameter for a full re-sync
4004malformed timestamp - it is UNIX seconds, not milliseconds and not ISO 8601
4012expired token - re-exchange, keep the cursor

Full reference: GET /v2/incidents · GET /v2/statuses