Blätterung: der Zeiger statt der Seitenzahl

Warum es kein `?page=` gibt, wie `nextCursor` benutzt wird, welche zwei Umschläge es gibt — und wie ein inkrementeller Abgleich aussieht, der nichts überspringt.

Für Entwickler

ES GIBT KEINE SEITENZAHL UND KEIN `?offset=`. Jede Liste blättert über einen ZEIGER: die Antwort enthält `nextCursor`, und den schicken Sie unverändert als `?cursor=` an denselben Aufruf zurück. Ist `nextCursor` gleich `null`, sind Sie am Ende.

DER GRUND IST KEIN GESCHMACK. Während ein Abgleich Seite für Seite läuft, legt die Buchungsstrecke neue Zeilen an. Jede eingeschobene Zeile verschiebt alle folgenden Seiten um eins — der Abgleich ÜBERSPRINGT dann genau die Zeile an der Nahtstelle, und niemand merkt es. Ein Zeiger auf dem letzten Sortierwert kennt dieses Loch nicht. Dazu kommt der Preis: bei `OFFSET n` liest die Datenbank die übersprungenen Zeilen und wirft sie weg; Seite 40 kostet vierzigmal so viel wie Seite 1.

DER ZEIGER IST UNDURCHSICHTIG UND GEHÖRT NICHT AUSGEWERTET. Er trägt die Sortierwerte der zuletzt gelieferten Zeile UND einen Fingerabdruck über Sortierung und Filter. Ändern Sie zwischen zwei Seiten einen Filter oder die Sortierung, antwortet die API mit 400 statt mit einer Liste, die still aus zwei verschiedenen Abfragen zusammengesetzt ist. Er ist ausdrücklich KEIN Sicherheitsmerkmal: die Mandantengrenze hängt am Schlüssel, nicht am Zeiger.

ES GIBT ZWEI UMSCHLÄGE, und der Unterschied ist historisch, nicht bedeutungstragend. Die meisten Listen antworten flach: `{ "data": [...], "nextCursor": ..., "hasMore": ..., "limit": ... }`. Der Gästebereich fasst dasselbe in ein Unterobjekt: `{ "data": [...], "pagination": { "limit", "nextCursor", "hasMore" }, "meta": { "restaurantId", "timezone" } }`. Auf jeder Ressourcenseite steht, welcher der beiden gilt.

`limit` IST 50 IN DER VORGABE UND 200 IM HÖCHSTFALL. Es gibt kein „alles" — jede Abfrage der API trägt eine harte Obergrenze in der Abfrageschicht, und die kann nicht ausfallen (die Ratenbremse schon, siehe „Ratenbegrenzung").

`total` KOSTET EINE ZWEITE ABFRAGE und kommt deshalb nur auf Zuruf: `?includeTotal=true`. Ohne diesen Parameter steht das Feld nicht im Körper. Für einen Fortschrittsbalken ist es sinnvoll, für einen Abgleich ist es Verschwendung.

FÜR DEN ABGLEICH GIBT ES `updatedSince` (Reservierungen, Gäste, Stammdaten) bzw. `updatedAtFrom`/`updatedAtTo` (Warenwirtschaft). Merken Sie sich den höchsten `updatedAt`, den Sie gesehen haben, und fragen Sie beim nächsten Lauf ab genau diesem Zeitpunkt. Die Gästeliste ist dafür AUFSTEIGEND nach `updatedAt` sortiert: wird eine Zeile während des Blätterns geändert, wandert sie ans Ende und wird in DIESEM Durchlauf noch gelesen. Absteigend spränge sie hinter den Zeiger und fehlte bis zum nächsten Lauf.

Schritt für Schritt

  1. Erste Seite holen

    Ohne `cursor`, mit `limit` so gross wie sinnvoll (bis 200) und den Filtern, die für ALLE Seiten gelten sollen.

  2. `data` verarbeiten

    Die Zeilen stehen in `data`. Im Gästebereich zusätzlich `meta.restaurantId` prüfen, wenn Ihr System mehrere Häuser bedient.

  3. `nextCursor` unverändert weiterreichen

    Als `?cursor=<wert>` an denselben Aufruf, mit denselben Filtern und derselben Sortierung. Nicht zerlegen, nicht kürzen, nicht neu kodieren.

  4. Beenden, wenn `nextCursor` null ist

    Nicht auf eine leere `data`-Liste warten: die letzte volle Seite hat bereits `nextCursor: null`, ein weiterer Aufruf wäre umsonst.

  5. Eine Obergrenze an Seiten einbauen

    Eine `while`-Schleife ohne Deckel ist eine Endlosschleife, sobald irgendwo ein Zeiger stehen bleibt. Zehntausend Zeilen sind fünfzig Seiten; ein Deckel bei 1 000 Seiten kostet nichts und rettet den Nachtlauf.

Codebeispiele

curl — erste Seite und Folgeseite
curl
# Erste Seite: gross anfragen, Filter setzen.curl -sS "https://tactictable.com/api/v1/reservations?date=2026-09-20&limit=200&sort=date&dir=asc" \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" # Folgeseite: DERSELBE Aufruf plus cursor. Filter unveraendert lassen.curl -sS "https://tactictable.com/api/v1/reservations?date=2026-09-20&limit=200&sort=date&dir=asc&cursor=eyJ2IjoxLCJzb3J0IjoiZGF0ZSJ9" \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM"
Lässt man beim zweiten Aufruf `sort` weg, ist der Fingerabdruck ein anderer und die Antwort ein 400 — nicht eine falsche Seite. Das ist der Zweck des Fingerabdrucks.
Antwort — der flache Umschlag
JSON
{  "data": [    {      "id": "31061d44-ac33-550d-b4d0-a16973e270f5",      "status": "CONFIRMED",      "date": "2026-09-20",      "time": "19:30",      "partySize": 4,      "guestName": "Familie Berger"    }  ],  "nextCursor": "eyJ2IjoxLCJzb3J0IjoiZGF0ZSIsImRpciI6ImFzYyJ9",  "hasMore": true,  "limit": 200}
Gekürzt — eine echte Reservierungszeile hat 33 Felder. `hasMore` und `nextCursor !== null` sagen dasselbe; prüfen Sie eines von beiden, nicht beide.
Antwort — der Umschlag des Gästebereichs
JSON
{  "data": [    {      "id": "fb69190b-e35f-5a2e-be8a-b7c272782903",      "name": "Anna Berger",      "email": "anna.berger@example.at",      "totalVisits": 12,      "updatedAt": "2026-09-14T07:12:44.019Z"    }  ],  "pagination": {    "limit": 200,    "nextCursor": "MjAyNi0wOS0xNFQwNzoxMjo0NC4wMTlafGZiNjkxOTBi",    "hasMore": true  },  "meta": {    "restaurantId": "35122c8e-d842-5b49-814c-0e8f2aaed157",    "timezone": "Europe/Vienna"  }}
`meta.restaurantId` steht bewusst drin, obwohl der Aufrufer ihn nie mitschickt: eine Erweiterung, die N Schlüssel hält, kann so nachrechnen, zu welchem Haus die Antwort gehört, statt es aus der Reihenfolge ihrer eigenen Aufrufe zu schliessen.
TypeScript — alle Seiten holen, mit Deckel
TypeScript
interface Seite<T> {    data: T[]    nextCursor: string | null    hasMore: boolean    limit: number    total?: number} const MAX_SEITEN = 1000 export async function alleSeiten<T>(    pfadMitFiltern: string,    token: string,): Promise<T[]> {    const alles: T[] = []    let cursor: string | null = null     for (let seite = 0; seite < MAX_SEITEN; seite++) {        const trenner = pfadMitFiltern.includes('?') ? '&' : '?'        const adresse =            'https://tactictable.com/api/v1' +            pfadMitFiltern +            (cursor ? trenner + 'cursor=' + encodeURIComponent(cursor) : '')         const antwort = await fetch(adresse, {            headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },        })        if (!antwort.ok) throw new Error('HTTP ' + antwort.status)         const seiteDaten = (await antwort.json()) as Seite<T>        alles.push(...seiteDaten.data)         if (!seiteDaten.nextCursor) return alles        cursor = seiteDaten.nextCursor    }     // Kein stilles Abbrechen: eine halbe Liste, die wie eine ganze aussieht,    // ist der Fehler, den man erst im naechsten Quartal bemerkt.    throw new Error('Mehr als ' + MAX_SEITEN + ' Seiten — Abfrage eingrenzen.')} // Aufruf: await alleSeiten('/reservations?dateFrom=2026-09-01&dateTo=2026-09-30&limit=200', token)
`encodeURIComponent` ist Pflicht: der Zeiger ist base64url und enthält `-` und `_`, aber ein späteres Format könnte mehr enthalten. Und der Deckel wirft, statt still abzubrechen.
Python — inkrementeller Abgleich der Gästekartei
Python
import requests MAX_SEITEN = 1000  def geaenderte_gaeste(sitzung: requests.Session, seit: str) -> list:    """Alle Gaeste, die sich seit `seit` (ISO-8601 mit Zone) geaendert haben."""    gesammelt = []    cursor = None     for _ in range(MAX_SEITEN):        parameter = {"limit": 200, "updatedSince": seit}        if cursor:            parameter["cursor"] = cursor         antwort = sitzung.get("https://tactictable.com/api/v1/guests", params=parameter, timeout=30)        antwort.raise_for_status()        rumpf = antwort.json()         gesammelt.extend(rumpf["data"])         cursor = rumpf["pagination"]["nextCursor"]        if cursor is None:            return gesammelt     raise RuntimeError("Mehr als {} Seiten — Zeitfenster verkleinern.".format(MAX_SEITEN))  # Beim naechsten Lauf: den hoechsten updatedAt der letzten Runde uebergeben.# neue = geaenderte_gaeste(sitzung, "2026-09-14T00:00:00Z")# naechster_stand = max(g["updatedAt"] for g in neue) if neue else stand
Speichern Sie den höchsten gesehenen `updatedAt`, nicht den Zeitpunkt Ihres Laufs. Sonst verlieren Sie jede Zeile, die zwischen der Abfrage und dem Ende Ihres Laufs geschrieben wurde.
PHP — Seite für Seite durch die Speisekarte
PHP
<?php function alle_gerichte(string $token): array{    $alles = [];    $cursor = null;    $seiten = 0;     do {        $adresse = 'https://tactictable.com/api/v1/menu-items?limit=200&available=true';        if ($cursor !== null) {            $adresse .= '&cursor=' . rawurlencode($cursor);        }         $ch = curl_init($adresse);        curl_setopt_array($ch, [            CURLOPT_RETURNTRANSFER => true,            CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],            CURLOPT_TIMEOUT => 30,        ]);        $rumpf = curl_exec($ch);        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);        curl_close($ch);         if ($status !== 200) {            throw new RuntimeException('HTTP ' . $status . ': ' . $rumpf);        }         $json = json_decode($rumpf, true, 512, JSON_THROW_ON_ERROR);        $alles = array_merge($alles, $json['data']);        $cursor = $json['nextCursor'];    } while ($cursor !== null && ++$seiten < 1000);     return $alles;}
`rawurlencode` und nicht `urlencode`: letzteres macht aus einem Leerzeichen ein `+`, und ein Zeiger, der so verändert ankommt, ist ein 400.