Status wechseln: bestätigen, stornieren, No-Show melden

Die eine Route, über die eine Reservierung ihren Zustand ändert — hinter einer eigens vergebenen Handlung, weil ein No-Show Geld einzieht.

Für Entwickler

Voraussetzungen

  • Recht `reservations:write`
  • Handlung `action:reservations.status`
  • Plan-Merkmal „Reservierungen"

JEDER ZUSTANDSWECHSEL LÄUFT ÜBER DIESE EINE ROUTE. `PATCH /api/v1/reservations/{id}` lehnt das Feld `status` ausdrücklich mit 400 `read_only_field` ab — sonst wäre die Teiländerung der Weg um die Handlung `reservations.status` herum.

SIE VERLANGT ZWEI RECHTE: `reservations:write` UND die Handlung `action:reservations.status`. Der Grund steht in der Wirkung: ein `NOSHOW` zieht die bei der Buchung nur geblockte Gebühr tatsächlich ein und erhöht das betriebsübergreifende Risikoprofil des Gastes. Eine Fehlmeldung aus einem Fremdsystem belastet damit eine echte Karte und stuft einen Menschen accountübergreifend herab. Das ist mehr als „schreiben dürfen".

DIE SECHS ZUSTÄNDE UND WAS SIE AUSLÖSEN. `PENDING`: die Anfrage liegt vor, nichts ist zugesagt. `CONFIRMED`: zugesagt, `confirmedAt` wird gesetzt. `OPTION`: unverbindliche Vormerkung. `CANCELED`: storniert, `canceledAt` und `cancelReason` werden gesetzt, der Stornozähler des Gastes steigt, eine verkettete Folgereservierung (Restaurant → Bar) wird mitstorniert und geblockte Beträge werden freigegeben. `NOSHOW`: nicht erschienen — der No-Show-Zähler des Gastes steigt, das Risikoprofil wird erhöht und eine hinterlegte Gebühr wird eingezogen. `COMPLETED`: der Besuch ist abgeschlossen, Besuchszähler und Durchschnitte des Gastes werden fortgeschrieben, geblockte Beträge freigegeben.

DERSELBE STATUS NOCH EINMAL IST EIN ERFOLG OHNE WIRKUNG. Steht die Reservierung bereits im gewünschten Zustand, antwortet die Route mit 200 und `"changed": false` — und tut NICHTS. Ein zweites `NOSHOW` versucht also nicht ein zweites Mal einzuziehen. Genau so ein Aufruf entsteht bei jedem Netzwiederholversuch ohne Idempotenzschlüssel.

ES GEHT KEINE MAIL AN DEN GAST. Der Weg im Dashboard verschickt beim Stornieren eine Nachricht und benachrichtigt die Warteliste; beides unterbleibt hier. Wer den Gast informieren will, tut es aus seinem eigenen System.

EIN HARTES LÖSCHEN GIBT ES NICHT. `CANCELED` ist der Storno. An der Zeile hängen Auslastungsstatistik, Berichte, Mailprotokoll, Zahlungen und die Zähler des Gastes; eine gelöschte Zeile hinterlässt Löcher, die niemand mehr schliesst.

Die Route

POST/api/v1/reservations/{id}/status

Den Zustand einer Reservierung wechseln — bestätigen, vormerken, stornieren, No-Show melden oder abschliessen.

Rechte

reservations:writeaction:reservations.status

Alle genannten Rechte zusammen, nicht wahlweise.

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.

Kopfzeilen

NameTypBedeutung
If-MatchETag-WertWechselt nur, wenn sich die Zeile seit Ihrem Lesen nicht geändert hat. Sonst 412 — sinnvoll, wenn zwischen Lesen und Melden Minuten liegen.
Idempotency-Keystring bis 255 ZeichenFreiwillig, aber richtig: die Wiederholung nach einem Netzabbruch bekommt dieselbe Antwort statt eines zweiten Gebühreneinzugs.

Felder im Rumpf

NameTypBedeutung
statusPflichtPENDING | CONFIRMED | OPTION | CANCELED | NOSHOW | COMPLETEDDer Zielzustand. `PENDING` = Anfrage liegt vor, unbeantwortet. `CONFIRMED` = Tisch ist zugesagt. `OPTION` = unverbindlich vorgemerkt, mit Zeitfenster. `CANCELED` = abgesagt, zählt NICHT gegen den Gast. `NOSHOW` = zugesagt und nicht erschienen; das zieht eine hinterlegte Gebühr ein und erhöht das betriebsübergreifende Risikoprofil eines echten Menschen — eine Fehlmeldung aus einem Fremdsystem ist deshalb teuer. `COMPLETED` = Gast war da, Vorgang abgeschlossen. Anders als beim Anlegen sind hier alle sechs Werte erlaubt.
reasonstring bis 500 ZeichenWird bei `CANCELED` als `cancelReason` festgehalten und steht danach im Dashboard. Bei anderen Zuständen ohne Wirkung.

Mögliche Fehler

  • forbidden_actionDem Schlüssel fehlt `action:reservations.status`. Sie wird getrennt vom Schreibrecht vergeben.
  • forbiddenDem Schlüssel fehlt `reservations:write`.
  • validationUnbekannter Zustand, unbekanntes Feld im Rumpf oder ein Rumpf, der kein JSON ist.
  • read_only_fieldDer Rumpf enthält ein Feld, das der Server bestimmt.
  • not_foundDie Kennung gehört zu keiner Reservierung dieses Betriebs.
  • precondition_failed`If-Match` passt nicht mehr.
  • idempotency_key_reuseDerselbe Schlüssel wurde bereits für eine andere Anfrage benutzt.
  • Antwortet mit 200, `{ "reservation": …, "changed": true|false, "followUpCanceled": <id>|null }` und einem frischen `ETag`.
  • `followUpCanceled` nennt die Kennung der mitstornierten Folgereservierung — bei verketteten Buchungen (Restaurant und danach Bar) sitzt der Gast sonst allein an der Bar.

Codebeispiele

curl — Reservierung stornieren
curl
curl -sS -X POST \  https://tactictable.com/api/v1/reservations/31061d44-ac33-550d-b4d0-a16973e270f5/status \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -H "Content-Type: application/json" \  -H "Idempotency-Key: 5a1c8f30-2b7d-4c19-9f88-1de0a7b43c62" \  -d '{"status": "CANCELED", "reason": "Gast hat telefonisch abgesagt"}'
`reason` landet in `cancelReason` und steht danach im Dashboard neben der Buchung. Ohne ihn steht dort `api` — richtig, aber wenig hilfreich für den Wirt.
curl — No-Show melden
curl
curl -sS -X POST \  https://tactictable.com/api/v1/reservations/31061d44-ac33-550d-b4d0-a16973e270f5/status \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -H "Content-Type: application/json" \  -H "Idempotency-Key: 9c7b21ae-4d05-4f7a-83a2-6f0d4e9b1c77" \  -d '{"status": "NOSHOW"}'
DIESER AUFRUF KOSTET GELD UND RUF. Er zieht eine hinterlegte No-Show-Gebühr ein und erhöht das betriebsübergreifende Risikoprofil des Gastes. Melden Sie ihn erst, wenn der Tisch wirklich leer geblieben ist — nicht automatisch nach Ablauf einer Frist.
Antwort 200 — gewechselt
JSON
{  "reservation": {    "id": "31061d44-ac33-550d-b4d0-a16973e270f5",    "status": "CANCELED",    "date": "2026-09-20",    "time": "19:30",    "partySize": 4,    "guestName": "Familie Berger",    "canceledAt": "2026-09-14T10:02:44.512Z",    "cancelReason": "Gast hat telefonisch abgesagt",    "timezone": "Europe/Vienna",    "updatedAt": "2026-09-14T10:02:44.512Z"  },  "changed": true,  "followUpCanceled": "7a8425f8-bf97-5d1e-b9bd-49193c662c2f"}
Gekürzt — `reservation` enthält dieselben 33 Felder wie überall. `followUpCanceled` ist hier gesetzt: die verkettete Folgebuchung an der Bar wurde mitstorniert.
Antwort 200 — war schon so
JSON
{  "reservation": {    "id": "31061d44-ac33-550d-b4d0-a16973e270f5",    "status": "CANCELED",    "canceledAt": "2026-09-14T10:02:44.512Z",    "cancelReason": "Gast hat telefonisch abgesagt",    "updatedAt": "2026-09-14T10:02:44.512Z"  },  "changed": false}
`changed: false` heisst: der Zustand stimmte bereits, und es wurde NICHTS ausgelöst. Werten Sie das als Erfolg — es ist die Antwort, die ein Netzwiederholversuch bekommt.
Antwort 403 — die Handlung fehlt
JSON
{  "error": "forbidden_action",  "message": "Diese Operation verlangt zusaetzlich die Handlung „reservations.status“. Sie wird getrennt vom Schreibrecht vergeben, weil sie mehr bewirkt als ein gewoehnliches Aendern.",  "action": "reservations.status",  "module": "reservations",  "op": "write",  "docs": "https://tactictable.com/dokumentation/api/fehler/forbidden_action",  "requestId": "req_8f31c0a94d2b47e6ba05"}
`reservations:write` allein legt an und ändert, wechselt aber keinen Status. Der Betrieb setzt den zusätzlichen Haken unter Einstellungen → API-Einstellungen.
TypeScript — bestätigen, stornieren, abschliessen
TypeScript
type Zielzustand = 'PENDING' | 'CONFIRMED' | 'OPTION' | 'CANCELED' | 'NOSHOW' | 'COMPLETED' interface StatusAntwort {    reservation: { id: string; status: string; updatedAt: string }    changed: boolean    followUpCanceled: string | null} export async function statusSetzen(    token: string,    id: string,    ziel: Zielzustand,    grund?: string,): Promise<StatusAntwort> {    const rumpf = JSON.stringify(grund ? { status: ziel, reason: grund } : { status: ziel })     const antwort = await fetch(        'https://tactictable.com/api/v1/reservations/' + encodeURIComponent(id) + '/status',        {            method: 'POST',            headers: {                Authorization: 'Bearer ' + token,                'Content-Type': 'application/json',                'Idempotency-Key': crypto.randomUUID(),            },            body: rumpf,        },    )     const ergebnis = await antwort.json()    if (!antwort.ok) throw new Error(ergebnis.error + ': ' + ergebnis.message)     // changed === false heisst: der Zustand stimmte schon. Kein Fehler.    return ergebnis as StatusAntwort}
Der Idempotenzschlüssel wird hier je Aufruf erzeugt — vertretbar, weil die Route bei gleichem Zielzustand ohnehin nichts tut. Wo Ihr System den Vorgang kennt, ist ein gespeicherter Schlüssel trotzdem besser.
Python — Tagesabschluss: alles Abgesessene auf COMPLETED
Python
import json import requests  def tagesabschluss(sitzung: requests.Session, tag: str) -> int:    """Setzt bestaetigte Reservierungen eines vergangenen Tages auf COMPLETED."""    antwort = sitzung.get(        "https://tactictable.com/api/v1/reservations",        params={"date": tag, "status": "CONFIRMED", "limit": 200},        timeout=30,    )    antwort.raise_for_status()     gezaehlt = 0    for zeile in antwort.json()["data"]:        erg = sitzung.post(            "https://tactictable.com/api/v1/reservations/" + zeile["id"] + "/status",            data=json.dumps({"status": "COMPLETED"}),            headers={"Content-Type": "application/json"},            timeout=30,        )        if erg.status_code == 200 and erg.json()["changed"]:            gezaehlt += 1     return gezaehlt  # ACHTUNG: NIE automatisch auf NOSHOW setzen. Der Wert zieht Geld ein und# stuft einen Menschen betriebsuebergreifend herab — das entscheidet ein# Mensch, der gesehen hat, dass der Tisch leer blieb.
Der Hinweis am Ende ist der wichtigste Teil dieses Beispiels. `COMPLETED` automatisch zu setzen ist harmlos; `NOSHOW` automatisch zu setzen ist es nie.
C# — Status aus einem Kassensystem melden
C#
using System;using System.Net.Http;using System.Net.Http.Headers;using System.Text;using System.Text.Json;using System.Threading.Tasks; public sealed class Reservierungsstatus{    private readonly HttpClient _klient;     public Reservierungsstatus(HttpClient klient, string token)    {        _klient = klient;        _klient.DefaultRequestHeaders.Authorization =            new AuthenticationHeaderValue("Bearer", token);    }     public async Task<bool> SetzeAsync(string id, string zielzustand, Guid vorgang)    {        var rumpf = JsonSerializer.Serialize(new { status = zielzustand });         using var anfrage = new HttpRequestMessage(            HttpMethod.Post,            "https://tactictable.com/api/v1/reservations/" + Uri.EscapeDataString(id) + "/status");         anfrage.Headers.Add("Idempotency-Key", vorgang.ToString());        anfrage.Content = new StringContent(rumpf, Encoding.UTF8, "application/json");         using var antwort = await _klient.SendAsync(anfrage);        var text = await antwort.Content.ReadAsStringAsync();         if (!antwort.IsSuccessStatusCode)        {            throw new HttpRequestException("Statuswechsel gescheitert: " + text);        }         using var json = JsonDocument.Parse(text);        return json.RootElement.GetProperty("changed").GetBoolean();    }}
`vorgang` kommt von aussen: die Kasse kennt ihren Bon und benutzt dessen Kennung als Idempotenzschlüssel. Ein in der Methode gewürfelter Wert wäre bei jedem Versuch ein neuer Vorgang — und damit wirkungslos.