# Stránkování a filtry

> Kurzorové stránkování, průběžná synchronizace přes updated_since, stropy na velikost stránky a filtry. Včetně toho, proč kurzor není pořadové číslo stránky.

- Sekce: Pro vývojáře
- Stránka: https://shopzi.cz/dokumentace/vyvojari/strankovani-a-filtry
- Ověřeno: 2026-10-20

---

Každý výpis vrací stejnou obálku:

```json
{
  "data": [],
  "next_cursor": "eyJ2IjoxLCJtIjoicyIsImEiOiIyMDI2LTA5LTAxVDA4OjE0OjAyLjExODIwNFoiLCJp...",
  "next_updated_since": "2026-09-02T05:55:00.412000Z"
}
```

Když je `next_cursor` `null`, další stránka není. `next_updated_since` patří do příštího průchodu
synchronizace, viz oddíl níž.

## Kurzor, ne offset [#kurzor-ne-offset]

Mezi dvěma vašimi požadavky mohou záznamy přibývat i se měnit. Offset by proto tiše přeskakoval:
co bylo na pozici 25, je po jednom novém přírůstku na pozici 26 a druhá stránka ho nikdy neukáže.

Stránkuje se tedy kurzorem. Hodnotu `next_cursor` z odpovědi pošlete beze změny jako parametr
`cursor` v dalším požadavku, se **stejnými** filtry a stejným `updated_since`, a nic nedopočítávejte.

```bash
curl -s "https://muj-eshop.cz/api/v1/orders?limit=100" \
  -H "Authorization: Bearer $SHOPZI_API_KEY"

curl -s "https://muj-eshop.cz/api/v1/orders?limit=100&cursor=eyJ2IjoxLCJtIjoiciIs..." \
  -H "Authorization: Bearer $SHOPZI_API_KEY"
```

Kurzor je **neprůhledný** řetězec. Nese pozici posledního řádku, hranici průchodu a otisk dotazu,
ke kterému patří, ale je to vnitřní tvar a smí se změnit. Kurzor z jiné cesty, s jiným filtrem nebo
s jiným `updated_since` API odmítne kódem 400 `invalid_cursor`, stejně jako kurzor, který se cestou
poškodil. Takovou chybu neopakujte se stejným kurzorem: začněte průchod znovu bez něj.

> **Změna od 15. září 2026** Do té doby byl kurzor dvojice času a identifikátoru oddělená svislítkem a čas v něm se zaokrouhloval na milisekundy, takže záznam vzniklý ve stejné milisekundě jako poslední řádek stránky mohl na hranici stránek vypadnout. Kurzor ve starém tvaru dnes vrátí 400 `invalid_cursor` a stačí průchod začít znovu. Nový kurzor je zapsaný v base64url, takže se v adrese nemusí nijak kódovat.

## Velikost stránky [#velikost-stranky]

Parametr `limit` má výchozí hodnotu 25 a
strop 100. Vyšší hodnota se **ořízne**, ne odmítne: požadavek projde a vrátí
strop, takže vás špatně nastavený limit nezastaví, jen vám nedá víc.

Nesmyslná hodnota (`0`, záporné číslo, text) spadne na výchozí hodnotu. Nepočítejte tedy s tím, že
vám API řekne, že jste limit napsali špatně.

`limit` do otisku dotazu v kurzoru nepatří, takže velikost stránky smíte mezi stránkami jednoho
průchodu měnit. Filtry a `updated_since` ne.

## Dvě pořadí: od nejnovějšího, nebo od poslední synchronizace [#dve-poradi-od-nejnovejsiho-nebo-od-posledni-synchronizace]

**Bez `updated_since`** vrací výpis záznamy od nejnovějšího podle času vzniku. To je pořadí pro
jednorázový pohled na to, co přišlo naposledy.

**S `updated_since`** vrací jen záznamy změněné od té chvíle včetně, a to **od nejstarší změny
vzestupně**. To je pořadí pro synchronizaci a dá se na něm stavět ze dvou důvodů:

* **Průchod je stabilní.** První stránka si zapamatuje čas, kdy průchod začal, a všechny další
  stránky vracejí jen změny do té hranice. Záznam se v jednom průchodu nikdy nezopakuje. Když se
  změní poté, co ho průchod vydal, přijde znovu v příštím průchodu; když se změní dřív, než na něj
  došla řada, v tomhle průchodu nepřijde vůbec a přijde až v příštím, rovnou v nové podobě.
* **Mezi průchody se nic neztratí.** `next_updated_since` je začátek průchodu mínus
  5 minut. Překryv pokryje zápis, který v okamžiku začátku průchodu ještě
  běžel a potvrdil se až po něm. Cena je, že příští průchod vrátí změny posledních minut znovu, a
  proto záznamy ukládejte podle `id` (přepsáním), nikdy přidáváním.

Většina cest stránkuje podle `updated_at`. Záznamy, které se po vzniku nemění, stránkují podle
`created_at`: `GET /api/v1/documents` a `GET /api/v1/stock/movements`. U každé cesty to říká i OpenAPI
v rozšíření `x-shopzi-sync-field`.

## Filtry [#filtry]

Filtry jsou u každé cesty vypsané ve sloupci **Filtry** v článku
[Endpointy REST API](/dokumentace/vyvojari/rest-api-endpointy). Dají se kombinovat a skládají se
konjunkcí, tedy platí všechny naráz.

* `updated_since` bere ISO 8601 se zónou `Z` nebo s číselným posunem, například
  `2026-09-01T00:00:00Z` nebo `2026-09-01T02:00:00+02:00`, a přepne výpis do synchronizačního
  pořadí. Hodnotu `next_updated_since` z odpovědi vložte do adresy tak, jak přišla: je v UTC se
  `Z`, takže kódování nepotřebuje. Když posíláte vlastní čas s `+` a adresu nekódujete, `+`
  dorazí jako mezera; API ho přesto přečte jako `+`. Čas bez posunu vrátí 400 `invalid_query`.
* `status` zužuje objednávky na stav objednávky, produkty na stav publikace, vratky na stav vratky,
  zásilky na stav zásilky a frontu ke schválení feedů dodavatelů na stav položky.
* `payment_status` zužuje objednávky podle stavu platby.
* `doc_type` zužuje doklady na druh (faktura, dobropis a další).
* `type` zužuje skladové pohyby na druh pohybu.
* `kind` zužuje frontu ke schválení feedů dodavatelů (`/api/v1/supplier-approvals`) na druh
  návrhu.
* `feed_id` zužuje katalog dodavatelů (`/api/v1/catalog-items`) na jeden feed a `include_gone=true`
  do něj vrátí i položky, které z feedu zmizely (výchozí je `false`).
* `subscription_id` zužuje běhy synchronizace (`/api/v1/supplier-sync-runs`) na jeden odběr.

> **Neznámá hodnota filtru je chyba, neznámý parametr se ignoruje** Hodnota, kterou výčet nezná (třeba `status=zaplaceno`), vrátí 400 `invalid_query` a zpráva vypíše povolené hodnoty. Do 15. září 2026 taková hodnota vracela prázdný výpis, který se od pravdy nedal odlišit. Překlep v názvu parametru ale chybu nevrátí, vrátí nezúžený výpis: když vám odpověď přijde větší, než jste čekali, je to první místo, kam se podívat.

## Kdy stránkovat po velkých kusech [#kdy-strankovat-po-velkych-kusech]

Strop 100 je maximum na požadavek, ne doporučení. Průběžná synchronizace
si vystačí s malou stránkou, protože přírůstek je malý. Plný sesyp katalogu má naopak brát maximum:
každá stránka je jeden dotaz do databáze obchodu, který v tu chvíli obsluhuje i zákazníky, a jedno
volání proti stropu klíče za minutu a za den (čísla podle tarifu jsou v článku
[Chybové odpovědi](/dokumentace/vyvojari/chybove-odpovedi)).

## Kde spotřebu volání uvidíte [#kde-spotrebu-volani-uvidite]

Kolik volání synchronizace opravdu spotřebuje, ukazuje administrace obchodu
v **Nastavení → API a MCP**:

* v kartě **API klíče** ukazuje u každého klíče sloupec **Naposledy** i počet dnešních volání;
* část **Využití** sečte volání, chyby, odmítnutí a volání nad limit za zvolené období, i po
  jednotlivých klíčích;
* část **Protokol volání** ukáže jednotlivá volání i s cestou, výsledkem a dobou zpracování, takže
  poznáte například průchod, který se zacyklil na první stránce.

Za běhu si tempo řiďte podle hlaviček `X-RateLimit-Remaining` a `X-RateLimit-Reset`, které nese
každá odpověď na volání, jež prošlo všemi branami.

## Doporučený tvar synchronizace [#doporuceny-tvar-synchronizace]

1. Při prvním běhu nastavte `updated_since` hluboko do minulosti, třeba `2000-01-01T00:00:00Z`, ať
   přijde všechno.
2. Dokud odpověď nese `next_cursor`, posílejte ho zpátky v `cursor` se stejným `updated_since`.
3. Každý řádek uložte podle `id`: existující přepište, nový přidejte.
4. Po poslední stránce si uložte `next_updated_since` z odpovědi jako hranici příštího běhu.
5. Další běh začněte s `updated_since` rovným uložené hranici.

```javascript
// SHOPZI_API_URL je adresa REST API obchodu i s cestou, třeba https://muj-eshop.cz/api/v1
async function syncOrders(since) {
  let cursor = null;
  for (;;) {
    const params = new URLSearchParams({ updated_since: since, limit: "100" });
    if (cursor) params.set("cursor", cursor);
    const res = await fetch(`${process.env.SHOPZI_API_URL}/orders?${params}`, {
      headers: { Authorization: `Bearer ${process.env.SHOPZI_API_KEY}` },
    });
    if (res.status === 429) {
      const wait = Number(res.headers.get("retry-after") ?? "60");
      await new Promise((resolve) => setTimeout(resolve, wait * 1000));
      continue;
    }
    if (!res.ok) throw new Error(`Shopzi ${res.status}`);
    const page = await res.json();
    await upsertById(page.data);
    if (!page.next_cursor) return page.next_updated_since; // hranice příštího běhu
    cursor = page.next_cursor;
  }
}
```

Hranici berte z odpovědi, ne z hodin vlastního stroje: `next_updated_since` počítá databáze obchodu,
takže nezávisí na tom, jak přesně jdou hodiny u vás. Jak navázat na průchod, který spadl uprostřed,
ukazuje rychlý start na úvodní stránce [Dokumentace pro vývojáře](/dokumentace/vyvojari).

> **Smazání přes updated_since nepřijde** Smazaný záznam ve výpisu prostě chybí a změnou se neohlásí. Kdo potřebuje smazané záznamy odstranit i u sebe, projde jednou za čas celý výpis bez `updated_since` a porovná identifikátory. U produktů pomůže i událost `product.deleted` přes [webhooky](/dokumentace/vyvojari/webhooky); u ostatních záznamů událost o smazání není. Položka katalogu dodavatelů se nemaže: když z feedu zmizí, dostane čas v `gone_at`, posune se jí `updated_at` a z výchozího výpisu `/api/v1/catalog-items` vypadne. Synchronizace, která ji má zachytit, proto posílá `include_gone=true`; totéž ohlásí webhook `catalog_item.gone`. Stejný celý průchod zachytí i změny, které čas `updated_at` neposouvají, třeba cenu varianty u produktu.

## Časté potíže [#caste-potize]

* **Průchod se točí pořád na první stránce.** Kurzor se do adresy nedostal: klient ho neposílá,
  nebo ho posílá pod jiným jménem než `cursor`, a neznámý parametr se ignoruje. Poškozený kurzor by
  API odmítlo kódem `400 invalid_cursor`, takže smyčka bez chyby znamená chybějící kurzor.
  `next_cursor` je v base64url a do adresy patří beze změny.
* **`400 invalid_cursor` uprostřed průchodu.** Mezi stránkami se změnil filtr nebo `updated_since`,
  nebo kurzor patří jiné cestě. Začněte průchod znovu bez `cursor`; přepsáním podle `id` se nic
  nezdvojí.
* **`400 invalid_query` u `updated_since`.** Čas nemá časový posun (`2026-09-01T00:00:00` bez `Z`),
  nebo je to jen datum. Pošlete `2026-09-01T00:00:00Z` nebo hodnotu `next_updated_since` beze změny.
* **Výpis je větší, než jste čekali.** Překlep v názvu parametru se ignoruje a výpis zůstane
  nezúžený. Zkontrolujte názvy filtrů podle článku
  [Endpointy REST API](/dokumentace/vyvojari/rest-api-endpointy).
* **Synchronizace nezachytila změnu ceny nebo zásoby.** Cena varianty, obrázek ani zásoba čas
  `updated_at` produktu neposouvají. Zásoby synchronizujte přes `/api/v1/stock` a katalog jednou
  za čas projděte celý, bez `updated_since`.
