Reservierungen lesen, anlegen und ändern

Das Reservierungsbuch: Liste mit zwanzig Filtern, Einzelabruf mit ETag, Anlegen durch dieselbe Torwache wie das Widget und Teiländerung ohne Datenverlust.

Für Entwickler

Voraussetzungen

  • Recht `reservations:read` zum Lesen, `reservations:write` zum Anlegen und Ändern
  • Handlung `action:guests.personal` für `internalNotes`
  • Plan-Merkmal „Reservierungen"

Vier Operationen auf einer Ressource: Liste, Einzelabruf, Anlegen, Teiländerung. Es gibt bewusst KEIN `PUT` und KEIN `DELETE`. Ein `PUT` würde jedes ausgelassene Feld leeren — die häufigste Folge wäre eine Reservierung ohne Telefonnummer, gemerkt am Abend des Besuchs. Ein `DELETE` gäbe es nicht zurück: an einer Reservierung hängen Auslastungsstatistik, Berichte, Mailprotokoll, Zahlungen und die Risikozähler des Gastes. Storniert wird über den Statuswechsel.

DIE ZEITFELDER, UND WARUM ES DREI SIND. `date` ist der KALENDERTAG des Betriebs (`"2026-09-20"`) — eine Buchung um 02:00 gehört zum Vortag, weil der Betriebstag so gezählt wird. `time` und `endTime` sind naive Ortszeiten (`"19:30"`); `endTime` darf `"25:00"` lauten, wenn der Tisch nach Mitternacht frei wird. `startsAt` und `endsAt` sind daraus abgeleitete echte Zeitpunkte MIT Zonenversatz (`"2026-09-20T19:30:00+02:00"`) und nur lesbar. `timezone` steht an jeder Zeile, damit sie allein reisen kann.

DER STATUS SAGT, WORAUF DER GAST SICH VERLASSEN DARF. `PENDING` — die Anfrage liegt vor, der Betrieb hat noch nicht zugesagt. `CONFIRMED` — zugesagt, der Tisch steht. `OPTION` — unverbindliche Vormerkung, die der Betrieb wieder auflösen kann. `CANCELED` — storniert, gleich von welcher Seite. `NOSHOW` — der Gast ist nicht erschienen; dieser Wert zieht eine hinterlegte Gebühr ein und erhöht das Risikoprofil des Gastes. `COMPLETED` — der Besuch ist abgeschlossen. Beim ANLEGEN sind nur `PENDING`, `CONFIRMED` und `OPTION` erlaubt; die anderen drei sind Endpunkte eines Lebenslaufs und laufen ausschliesslich über die Statusroute.

`source` SAGT, WOHER DIE BUCHUNG KAM: `API` (dieser Weg, Vorgabe), `PHONE` (Telefonanlage), `MANUAL` (jemand hat sie von Hand eingetragen), `WALKIN` (Laufkundschaft am Tresen), `WIDGET` (die Buchungsstrecke des Gastes), `WAITLIST` (aus der Warteliste nachgerückt). Die Spalte ist frei, angenommen wird trotzdem nur diese Liste — damit die Auswertungen im Dashboard nicht mit Fantasiewerten zulaufen.

BEIM ANLEGEN LÄUFT DIESELBE TORWACHE WIE IM WIDGET, einschliesslich `requireOnlineBookable`. Geprüft werden Sperren, Öffnungszeiten, Vorlauf, Personenzahl, Gruppenschwelle und Kapazität. Es gibt KEIN `force`: die Doppelbuchung, die der Wirt im Dashboard bewusst bestätigt, hat in einer maschinellen Schnittstelle keine Entsprechung — ein Fremdsystem kann nicht beurteilen, ob eine Ausnahme gerechtfertigt ist. Wo eine nötig ist, führt der Weg über das Dashboard.

TISCHE MÜSSEN SIE NICHT NENNEN. Lassen Sie `tableIds` weg, sucht der Tischplan selbst — dieselbe Rechnung wie im Widget, samt Zusammenlegen mehrerer Tische. Nennen Sie welche, werden genau diese geprüft und belegt. `tableIds: []` in einem `PATCH` heisst ausdrücklich „Zuordnung löschen"; das Feld ganz wegzulassen heisst „unverändert".

ÜBER DIE API WIRD KEINE GÄSTEPOST AUSGELÖST. Weder beim Anlegen noch beim Ändern noch beim Stornieren geht eine Mail an den Gast. Die Felder `notify` und `sendEmail` werden mit 400 `read_only_field` abgewiesen. Eine gestohlene Erweiterung soll nicht allen Gästen im Namen des Wirts schreiben können.

`internalNotes` IST EIN PERSÖNLICHES MERKMAL. Dort steht der Vermerk zu einem gesperrten Gast und stehen Sätze wie „zahlt schlecht". Das Feld wird nur ausgeliefert, wenn der Schlüssel die Handlung `action:guests.personal` trägt — sonst steht dort in JEDER Antwort `null`. Auch die Volltextsuche `q` durchsucht es nur mit dieser Handlung, sonst wäre die Trefferliste ein Leseweg in genau das Feld, das die Ausgabe verschweigt.

FINANZFELDER GEHEN NICHT HINAUS. Anzahlung, No-Show-Gebühr, Zahlungsstatus und die Kennung des Zahlungsvorgangs sind in keiner Antwort enthalten, und sie lassen sich nicht setzen. `reservations:read` ist dafür zu grob — jede Rolle mit Leserecht sähe sonst Anzahlungen mit. Ebenfalls nie enthalten: `changeHash`, der Geheimniswert des Storno-Links aus den Gästemails. Wer ihn hätte, könnte jede Reservierung des Hauses ohne Anmeldung stornieren.

Die Routen

GET/api/v1/reservations

Das Reservierungsbuch lesen — gefiltert nach Tag, Zeitraum, Status, Bereich, Uhrzeit, Personenzahl oder Änderungszeitpunkt.

Rechte

reservations:read

Plan-Merkmal reservations — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Abfrageparameter

NameTypBedeutung
dateYYYY-MM-DDGenau ein Kalendertag des Betriebs. Schliesst `dateFrom`/`dateTo` aus.
dateFromYYYY-MM-DDErster Tag eines Zeitraums, einschliesslich.
dateToYYYY-MM-DDLetzter Tag eines Zeitraums, einschliesslich. Muss `dateFrom` sein oder danach liegen.
statusKommaliste aus PENDING, CONFIRMED, OPTION, CANCELED, NOSHOW, COMPLETEDNur Reservierungen in diesen Zuständen. Ohne Angabe: alle, auch stornierte — für einen Tagesplan also fast immer `status=CONFIRMED,PENDING,OPTION` setzen.
sourceKommaliste aus API, PHONE, MANUAL, WALKIN, WIDGET, WAITLISTNur Buchungen dieser Herkunft. `WIDGET` ist das Buchungsfenster auf der Website des Betriebs, `API` alles über diese Schnittstelle, `PHONE` und `MANUAL` das vom Personal Eingetragene (telefonisch bzw. am Tresen), `WALKIN` die Laufkundschaft ohne Voranmeldung, `WAITLIST` eine aus der Warteliste nachgerückte Buchung.
areaIdUUIDBereich SAMT seiner Unterbereiche. Eine Kennung, die nicht zu diesem Betrieb gehört, ist 400 — nicht eine leere Liste.
timeFromHH:mmFrühester Beginn, einschliesslich. Verglichen wird die naive Ortszeit.
timeToHH:mmSpätester Beginn, einschliesslich.
partyMininteger 1–9999Mindestens so viele Personen.
partyMaxinteger 1–9999Höchstens so viele Personen.
updatedSinceISO-8601 mit ZoneNur Zeilen, die seit diesem Zeitpunkt geändert wurden — der Filter für den inkrementellen Abgleich.
idsKommaliste von UUIDs, höchstens 100Genau diese Reservierungen. Unbekannte Kennungen fehlen still in der Antwort; die Liste ist kein Nachschlagewerk.
qstring, 2–200 ZeichenVolltext über GASTNAME und Anmerkungen — nie über E-Mail oder Telefon. Verlangt ein Datumsfenster von höchstens 400 Tagen (`date` oder `dateFrom`+`dateTo`), weil die Suche durch keinen Index gedeckt ist.
emailE-MailEXAKTER Treffer auf die vollständige Adresse, nie ein Teilstring. Kein Treffer ist eine leere Liste — nie ein anderer Status.
phonestring, höchstens 32 ZeichenEXAKTER Treffer auf die gespeicherte Form in internationaler Schreibweise, z. B. `+4366412345678`.
hasTabletrue | false`true`: nur Reservierungen mit zugeordnetem Tisch. `false`: nur die ohne — genau die Liste, die der Wirt am Morgen durchgeht.
limitinteger 1–200Vorgabe: 50Zeilen je Seite. Ein Wert über 200 ist 400 und nicht etwa stillschweigend gekappt — sonst hielte der Aufrufer eine halbe Antwort für eine ganze.
cursorundurchsichtiger Zeiger`nextCursor` der vorigen Antwort, unverändert. Gilt nur mit unveränderten Filtern und unveränderter Sortierung.
sortdate | updatedAt | createdAtVorgabe: dateSortierfeld. `date` sortiert nach Tag, dann Uhrzeit; für einen Abgleich ist `updatedAt` richtig.
dirasc | descVorgabe: ascRichtung. Sie ist Teil des Zeigers und darf sich zwischen zwei Seiten nicht ändern.
includeTotaltrue | falseVorgabe: falseErgänzt `total`. Kostet eine zweite Abfrage über dieselbe Menge.

Mögliche Fehler

  • validationUnbekannter Parameter, `date` zusammen mit `dateFrom`/`dateTo`, Enddatum vor Startdatum, `q` ohne Datumsfenster, fremde `areaId`, unlesbarer oder fremder `cursor`.
  • forbiddenDem Schlüssel fehlt `reservations:read`.
  • plan_upgrade_requiredDer Plan des Betriebs enthält Reservierungen nicht.
  • trial_expiredDie Testphase ist beendet und kein Plan gewählt.
  • rate_limited600 Leseaufrufe je Minute und Schlüssel bzw. 1 200 je Minute und Betrieb überschritten.
  • Ohne `status`-Filter enthält die Liste AUCH stornierte und No-Show-Reservierungen. Für einen Tagesplan ist `status=CONFIRMED,PENDING,OPTION` fast immer gemeint.
  • Die Antwort trägt den flachen Umschlag: `data`, `nextCursor`, `hasMore`, `limit` und bei `includeTotal=true` zusätzlich `total`.

POST/api/v1/reservations

Eine Reservierung anlegen — durch dieselbe Verfügbarkeitsprüfung, die auch der Gast im Widget durchläuft.

Rechte

reservations:write

Plan-Merkmal reservations — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Kopfzeilen

NameTypBedeutung
Idempotency-Keystring bis 255 ZeichenNicht Pflicht, aber dringend empfohlen: ohne ihn legt ein Netzwiederholversuch eine zweite Reservierung an. Eine UUIDv4 je fachlichem Vorgang.
X-TacticTable-Dry-Run1Rechnet alles durch und schreibt nichts. Die Antwort enthält unter `would`, was entstanden wäre.

Felder im Rumpf

NameTypBedeutung
guestNamePflichtstring, 1–100 ZeichenDer Name, unter dem der Tisch geführt wird.
partySizePflichtinteger 1–200Personenzahl. Sie steuert die Tischsuche und wird gegen die Online-Grenzen des Betriebs geprüft.
datePflichtYYYY-MM-DDKalendertag des Betriebs. Ein Zeitstempel wird abgewiesen, nicht gekürzt.
timePflichtHH:mmBeginn als naive Ortszeit. Muss eine Uhrzeit sein, die die Verfügbarkeitsrechnung für diesen Tag anbietet.
emailE-Mail oder nullKontaktadresse des Gastes. `null` und `""` bedeuten beide „nicht gesetzt".
phoneRufnummer oder nullWird nach E.164 normalisiert gespeichert (`+4366412345678`). Eine unlesbare Nummer ist 400.
endTimeHH:mm oder nullNur nötig, wenn für diese Startzeit eine Sloteinstellung greift — dann nennt `GET /api/v1/availability` unter `endTimes` die zulässigen Werte. Sonst bestimmt der Betrieb die Dauer.
areaIdUUID oder nullGewünschter Bereich. Er muss zu diesem Betrieb gehören, online buchbar und in der Saison sein.
tableIdsListe von UUIDs, höchstens 30Genau diese Tische belegen. Weglassen heisst: der Tischplan sucht selbst — das ist der Normalfall.
notesstring bis 1000 Zeichen oder nullWunsch des Gastes. Erscheint im Dashboard und in der Bestätigung.
internalNotesstring bis 2000 Zeichen oder nullInterner Vermerk. Verlangt die Handlung `action:guests.personal` — sonst ist er in keiner Antwort lesbar.
tableGuaranteedbooleanVorgabe: falseDer Tisch ist dem Gast fest zugesagt und wird nicht umgelegt.
sourceAPI | PHONE | MANUAL | WALKIN | WIDGET | WAITLISTVorgabe: APIHerkunft der Buchung, für die Auswertung im Dashboard. Die Vorgabe `API` ist fast immer richtig. Setzen Sie stattdessen `PHONE`, wenn Ihr System eine telefonische Buchung weiterreicht, `WALKIN` für Laufkundschaft ohne Voranmeldung, `WIDGET` für ein eigenes Buchungsfenster, `WAITLIST` für eine nachgerückte Buchung. `MANUAL` bleibt dem Eintrag im Dashboard vorbehalten.
statusPENDING | CONFIRMED | OPTIONVorgabe: CONFIRMEDZustand bei der Entstehung. `CONFIRMED` ist der ZUGESAGTE Tisch — der Gast darf sich darauf verlassen. `OPTION` ist die unverbindliche Vormerkung mit Zeitfenster, die verfällt, wenn niemand bestätigt. `PENDING` ist die eingegangene Anfrage, die noch jemand ansehen muss. Wer hier `CONFIRMED` setzt, wo er `OPTION` meint, schickt den Gast mit einer Zusage weg, die der Betrieb nie gegeben hat. `CANCELED`, `NOSHOW` und `COMPLETED` sind hier NICHT erlaubt — sonst wäre „direkt als NOSHOW anlegen" der Weg um die Handlung `reservations.status` herum.
guestIdUUID oder nullEinen bestehenden Gast verknüpfen, statt ihn über Name und Kontakt suchen zu lassen.

Mögliche Fehler

  • validationSchema verletzt, unbekanntes Feld, fremde `areaId`/`tableIds`/`guestId`, oder die Torwache lehnt aus einem Eingabegrund ab (`unknown_area`, `area_not_bookable`, `invalid_time`, `end_time_required`, `party_size`).
  • read_only_fieldDer Rumpf setzt ein Feld, das der Server bestimmt — `startsAt`, `durationMin`, `depositAmount`, `notify`, `force` und weitere.
  • conflictDie Torwache lehnt aus einem Zustandsgrund ab: `closed`, `blocked`, `limit`, `capacity`, `advance` oder `table_conflict`.
  • forbiddenDem Schlüssel fehlt `reservations:write`.
  • forbidden_action`internalNotes` ohne die Handlung `action:guests.personal`.
  • idempotency_key_reuseDerselbe `Idempotency-Key` wurde bereits für eine andere Anfrage benutzt.
  • idempotency_in_progressEine Anfrage mit diesem Schlüssel läuft noch.
  • Antwortet mit 201 und dem Körper `{ "reservation": { … } }`.
  • Ein Gast wird bei Bedarf automatisch angelegt oder gefunden — genau wie im Dashboard. Sie müssen ihn nicht vorher erzeugen.
  • Es geht KEINE Mail an den Gast. Wer eine Bestätigung verschicken will, tut das aus seinem eigenen System.

GET/api/v1/reservations/{id}

Eine Reservierung vollständig lesen, samt ETag für ein späteres Schreiben ohne Überfahren.

Rechte

reservations:read

Plan-Merkmal reservations — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Pfad

NameTypBedeutung
{id}PflichtUUIDKennung der Reservierung. Eine fremde Kennung ergibt 404 — nie 403, das verriete, dass es die Zeile gibt.

Mögliche Fehler

  • not_foundDie Kennung gehört zu keiner Reservierung DIESES Betriebs.
  • forbiddenDem Schlüssel fehlt `reservations:read`.
  • Antwortet mit `{ "reservation": { … } }` und der Kopfzeile `ETag` (schwach, z. B. `W/"1789412460123"`).
  • Listen liefern KEIN ETag — heben Sie es aus dem Einzelabruf auf.

PATCH/api/v1/reservations/{id}

Einzelne Felder ändern. Ein fehlendes Feld bleibt unverändert; ein ausdrückliches `null` leert es.

Rechte

reservations:write

Plan-Merkmal reservations — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Pfad

NameTypBedeutung
{id}PflichtUUIDKennung der Reservierung.

Kopfzeilen

NameTypBedeutung
If-MatchETag-WertSchreibt nur, wenn sich die Zeile seit Ihrem Lesen nicht geändert hat. Sonst 412. `*` heisst „nur, wenn es die Zeile gibt".
Idempotency-Keystring bis 255 ZeichenFreiwillig. Bei einer Wiederholung nach Netzabbruch kommt dieselbe Antwort zurück statt einer zweiten Änderung.

Felder im Rumpf

NameTypBedeutung
guestNamestring, 1–100 ZeichenNeuer Name.
partySizeinteger 1–200Neue Personenzahl. Löst die Verfügbarkeitsprüfung und gegebenenfalls eine neue Tischsuche aus.
dateYYYY-MM-DDVerschieben auf einen anderen Tag. Die Torwache läuft erneut.
timeHH:mmNeue Startzeit. Die eigene bisherige Belegung wird dabei ausgeklammert — eine Verschiebung um 15 Minuten blockiert sich nicht selbst.
endTimeHH:mm oder nullNeue Endzeit, wenn eine Sloteinstellung greift.
areaIdUUID oder nullAnderer Bereich; `null` hebt die Zuordnung auf.
tableIdsListe von UUIDs, höchstens 30ERSETZT die Zuordnung vollständig. `[]` löscht sie und verhindert, dass der Plan selbst wieder einen Tisch sucht. Das Feld wegzulassen heisst „unverändert".
notesstring bis 1000 Zeichen oder nullGastwunsch; `null` oder `""` leert ihn.
internalNotesstring bis 2000 Zeichen oder nullInterner Vermerk. Verlangt `action:guests.personal`.
tableGuaranteedbooleanFeste Tischzusage setzen oder aufheben.
guestIdUUID oder nullVerknüpfung zum Gastprofil setzen oder lösen.

Mögliche Fehler

  • nothing_to_writeDer Rumpf enthält kein änderbares Feld. Ein Aufruf, der nichts ändert, wird nicht als Erfolg beantwortet.
  • read_only_fieldDer Rumpf enthält `status` (das läuft über die eigene Route) oder ein anderes vom Server bestimmtes Feld.
  • validationSchema verletzt, fremde Kennung im Rumpf oder Eingabegrund der Torwache.
  • conflictDie neue Zeit ist nicht verfügbar oder einer der genannten Tische ist belegt.
  • precondition_failed`If-Match` passt nicht mehr — jemand anderes war schneller.
  • not_foundDie Kennung gehört zu keiner Reservierung dieses Betriebs.
  • `status` steht ausdrücklich auf der Sperrliste. Ohne das wäre `PATCH` der Weg um die Handlung `reservations.status` herum: ein Schlüssel mit blossem `reservations:write` könnte NOSHOW setzen und damit eine Gebühr einziehen lassen.
  • Antwortet mit 200, `{ "reservation": { … } }` und einem frischen `ETag`.

Codebeispiele

curl — der Tagesplan
curl
curl -sS -G https://tactictable.com/api/v1/reservations \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  --data-urlencode "date=2026-09-20" \  --data-urlencode "status=CONFIRMED,PENDING,OPTION" \  --data-urlencode "sort=date" \  --data-urlencode "dir=asc" \  --data-urlencode "limit=200"
`-G` mit `--data-urlencode` baut den Query-String sauber zusammen — das Komma in `status` und ein `+` in einer Rufnummer sind sonst die zwei Stellen, an denen ein von Hand gebauter Aufruf kippt.
Antwort 200 — GET /api/v1/reservations
JSON
{  "data": [    {      "id": "31061d44-ac33-550d-b4d0-a16973e270f5",      "status": "CONFIRMED",      "source": "WIDGET",      "date": "2026-09-20",      "time": "19:30",      "endTime": "21:30",      "durationMin": 120,      "startsAt": "2026-09-20T19:30:00+02:00",      "endsAt": "2026-09-20T21:30:00+02:00",      "timezone": "Europe/Vienna",      "partySize": 4,      "guestName": "Familie Berger",      "email": "anna.berger@example.at",      "phone": "+4366412345678",      "notes": "Fensterplatz, wenn möglich",      "internalNotes": null,      "tableGuaranteed": false,      "isPlanned": true,      "isPlaced": false,      "areaId": "ce2038c4-0bb6-51d7-b705-dc831177a2d8",      "area": { "id": "ce2038c4-0bb6-51d7-b705-dc831177a2d8", "name": "Gastraum" },      "tableIds": ["653d49c8-cd70-53ea-839f-d3131a574417"],      "tables": [{ "id": "653d49c8-cd70-53ea-839f-d3131a574417", "name": "7" }],      "guestId": "fb69190b-e35f-5a2e-be8a-b7c272782903",      "answers": [        { "fieldId": "anlass", "label": "Anlass", "value": "Geburtstag" }      ],      "seatedAt": null,      "finishedAt": null,      "confirmedAt": "2026-09-13T17:02:11.004Z",      "canceledAt": null,      "cancelReason": null,      "anonymizedAt": null,      "createdAt": "2026-09-13T17:02:10.887Z",      "updatedAt": "2026-09-13T17:02:11.004Z"    }  ],  "nextCursor": null,  "hasMore": false,  "limit": 200}
`internalNotes` ist `null`, weil dieser Schlüssel die Handlung `guests.personal` nicht trägt — das heisst NICHT, dass keine Notiz da ist. `isPlanned` sagt, dass ein Tisch zugeordnet ist; `isPlaced`, dass der Gast bereits sitzt. `answers` sind die Antworten auf die Zusatzfragen des Betriebs, roh und unmaskiert — wer sie anzeigt, muss selbst maskieren.
curl — Reservierung anlegen
curl
curl -sS -X POST https://tactictable.com/api/v1/reservations \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -H "Content-Type: application/json" \  -H "Idempotency-Key: 6b2f0d18-4f0a-4f6b-9a91-0c4a2d8e7f51" \  -d '{    "guestName": "Familie Berger",    "email": "anna.berger@example.at",    "phone": "+4366412345678",    "partySize": 4,    "date": "2026-09-20",    "time": "19:30",    "notes": "Fensterplatz, wenn möglich",    "source": "PHONE",    "status": "CONFIRMED"  }'
Ohne `tableIds` sucht der Tischplan selbst. `source: "PHONE"` sagt der Auswertung, dass diese Buchung am Telefon entstanden ist — der Vorgabewert wäre `API`.
Antwort 201 — angelegt
JSON
{  "reservation": {    "id": "7a8425f8-bf97-5d1e-b9bd-49193c662c2f",    "status": "CONFIRMED",    "source": "PHONE",    "date": "2026-09-20",    "time": "19:30",    "endTime": "21:30",    "durationMin": 120,    "startsAt": "2026-09-20T19:30:00+02:00",    "endsAt": "2026-09-20T21:30:00+02:00",    "timezone": "Europe/Vienna",    "partySize": 4,    "guestName": "Familie Berger",    "email": "anna.berger@example.at",    "phone": "+4366412345678",    "notes": "Fensterplatz, wenn möglich",    "internalNotes": null,    "tableGuaranteed": false,    "isPlanned": true,    "isPlaced": false,    "areaId": "ce2038c4-0bb6-51d7-b705-dc831177a2d8",    "area": { "id": "ce2038c4-0bb6-51d7-b705-dc831177a2d8", "name": "Gastraum" },    "tableIds": ["bfdfcf1b-8d9b-5708-8a65-9f553849dfae"],    "tables": [{ "id": "bfdfcf1b-8d9b-5708-8a65-9f553849dfae", "name": "12" }],    "guestId": "fb69190b-e35f-5a2e-be8a-b7c272782903",    "answers": [],    "seatedAt": null,    "finishedAt": null,    "confirmedAt": "2026-09-14T09:31:07.220Z",    "canceledAt": null,    "cancelReason": null,    "anonymizedAt": null,    "createdAt": "2026-09-14T09:31:07.220Z",    "updatedAt": "2026-09-14T09:31:07.220Z"  }}
`durationMin` und `endTime` hat der Server gesetzt — sie folgen aus den Einstellungen des Betriebs und lassen sich nicht mitschicken. `guestId` zeigt auf den Gast, der dabei gefunden oder angelegt wurde.
Antwort 409 — der Zeitraum gibt nichts her
JSON
{  "error": "conflict",  "message": "Für diese Personenzahl ist zu dieser Zeit kein Tisch mehr frei.",  "reason": "capacity",  "docs": "https://tactictable.com/dokumentation/api/fehler/conflict",  "requestId": "req_8f31c0a94d2b47e6ba05"}
`reason` benennt die Ursache maschinenlesbar: `closed` (an diesem Tag keine Online-Reservierung), `blocked` (gesperrt), `limit` (Kontingent der Uhrzeit ausgeschöpft), `capacity` (kein Tisch), `advance` (ausserhalb des Buchungszeitraums), `table_conflict` (ein genannter Tisch ist belegt). Zeigen Sie dem Anrufer die Uhrzeiten aus `GET /api/v1/availability` statt einer Fehlermeldung.
TypeScript — Tagesplan holen und anzeigen
TypeScript
interface Reservierung {    id: string    status: string    date: string    time: string    endTime: string | null    partySize: number    guestName: string    phone: string | null    tables: { id: string; name: string }[]    timezone: string} interface Liste {    data: Reservierung[]    nextCursor: string | null    hasMore: boolean    limit: number} export async function tagesplan(token: string, tag: string): Promise<Reservierung[]> {    const parameter = new URLSearchParams({        date: tag,        status: 'CONFIRMED,PENDING,OPTION',        sort: 'date',        dir: 'asc',        limit: '200',    })     const antwort = await fetch('https://tactictable.com/api/v1/reservations?' + parameter, {        headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },    })    if (!antwort.ok) throw new Error('HTTP ' + antwort.status)     const seite = (await antwort.json()) as Liste    // Ein Haus mit mehr als 200 Reservierungen an einem Tag blaettert weiter;    // siehe „Blaetterung".    return seite.data}
`URLSearchParams` kodiert das Komma in `status` korrekt. Die Schnittstelle nimmt Kommalisten an — das ist im Haus schon die Form, nicht etwa `status[]=`.
Python — Reservierung anlegen
Python
import jsonimport uuid import requests  def reservierung_anlegen(token: str, daten: dict) -> dict:    rumpf = json.dumps(daten)    antwort = requests.post(        "https://tactictable.com/api/v1/reservations",        data=rumpf,        headers={            "Authorization": "Bearer " + token,            "Content-Type": "application/json",            "Idempotency-Key": str(uuid.uuid4()),        },        timeout=30,    )     ergebnis = antwort.json()     if antwort.status_code == 409:        # Nicht als Ausfall behandeln: der Zeitraum gibt nichts her.        # Dem Anrufer stattdessen freie Uhrzeiten anbieten.        raise ValueError("nicht buchbar: " + ergebnis.get("reason", "conflict"))     if antwort.status_code != 201:        raise RuntimeError("{}: {}".format(ergebnis["error"], ergebnis["message"]))     return ergebnis["reservation"]  # reservierung_anlegen(token, {#     "guestName": "Familie Berger",#     "phone": "+4366412345678",#     "partySize": 4,#     "date": "2026-09-20",#     "time": "19:30",#     "source": "PHONE",# })
409 ist kein Ausfall, sondern eine Antwort: „zu dieser Zeit geht es nicht". Wer das als Fehler protokolliert, hat ein Protokoll voller Meldungen, die keine sind.
PHP — Reservierung ändern, ohne Fremdes zu überfahren
PHP
<?php function reservierung_aendern(string $token, string $id, array $felder): array{    // 1. Lesen — der ETag steht in den Kopfzeilen.    $ch = curl_init('https://tactictable.com/api/v1/reservations/' . rawurlencode($id));    curl_setopt_array($ch, [        CURLOPT_RETURNTRANSFER => true,        CURLOPT_HEADER => true,        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],    ]);    $roh = curl_exec($ch);    $kopflaenge = curl_getinfo($ch, CURLINFO_HEADER_SIZE);    curl_close($ch);     $kopf = substr($roh, 0, $kopflaenge);    $etag = null;    if (preg_match('/^etag:\s*(.+)$/mi', $kopf, $treffer)) {        $etag = trim($treffer[1]);    }     // 2. Schreiben mit genau diesem Wert.    $kopfzeilen = [        'Authorization: Bearer ' . $token,        'Content-Type: application/json',    ];    if ($etag !== null) {        $kopfzeilen[] = 'If-Match: ' . $etag;    }     $ch = curl_init('https://tactictable.com/api/v1/reservations/' . rawurlencode($id));    curl_setopt_array($ch, [        CURLOPT_RETURNTRANSFER => true,        CURLOPT_CUSTOMREQUEST => 'PATCH',        CURLOPT_HTTPHEADER => $kopfzeilen,        CURLOPT_POSTFIELDS => json_encode($felder, JSON_THROW_ON_ERROR),    ]);    $rumpf = curl_exec($ch);    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);    curl_close($ch);     $json = json_decode($rumpf, true, 512, JSON_THROW_ON_ERROR);     if ($status === 412) {        throw new RuntimeException('Zwischenzeitlich geaendert — neu lesen und erneut versuchen.');    }    if ($status !== 200) {        throw new RuntimeException($json['error'] . ': ' . $json['message']);    }     return $json['reservation'];}
`CURLOPT_CUSTOMREQUEST => "PATCH"` — cURL kennt keinen eigenen Schalter dafür. Ohne `If-Match` gewinnt der letzte Schreiber, und die Änderung des Kellners von vor einer Minute ist weg.
Go — inkrementeller Abgleich des Buchs
Go
package tactictable import (    "encoding/json"    "fmt"    "net/http"    "net/url") // Geaendert holt alle Reservierungen, die sich seit "seit" geaendert haben.// Sortiert nach updatedAt aufsteigend: eine Zeile, die waehrend des Blaetterns// geschrieben wird, wandert ans ENDE und faellt nicht durch.func Geaendert(klient *http.Client, token, seit string) ([]Reservierung, error) {    gesammelt := []Reservierung{}    cursor := ""     for seite := 0; seite < 1000; seite++ {        frage := url.Values{}        frage.Set("updatedSince", seit)        frage.Set("sort", "updatedAt")        frage.Set("dir", "asc")        frage.Set("limit", "200")        if cursor != "" {            frage.Set("cursor", cursor)        }         anfrage, err := http.NewRequest("GET", "https://tactictable.com/api/v1/reservations?"+frage.Encode(), nil)        if err != nil {            return nil, err        }        anfrage.Header.Set("Authorization", "Bearer "+token)         antwort, err := klient.Do(anfrage)        if err != nil {            return nil, err        }        defer antwort.Body.Close()         if antwort.StatusCode != http.StatusOK {            return nil, fmt.Errorf("HTTP %d", antwort.StatusCode)        }         var gelesen Liste        if err := json.NewDecoder(antwort.Body).Decode(&gelesen); err != nil {            return nil, err        }        gesammelt = append(gesammelt, gelesen.Data...)         if gelesen.NextCursor == nil {            return gesammelt, nil        }        cursor = *gelesen.NextCursor    }     return nil, fmt.Errorf("mehr als 1000 Seiten — Zeitfenster verkleinern")}
Merken Sie sich den höchsten `updatedAt` aus dem Ergebnis, nicht die Uhrzeit Ihres Laufs. Sonst verlieren Sie jede Zeile, die zwischen der Abfrage und dem Ende Ihres Laufs geschrieben wurde.