Speisekarte: Karten, Gänge, Gerichte

Der Endpunkt für eine fremde Website und einen Bildschirm im Lokal — mit Allergenen, Preisen in Cent und ohne die Kalkulation des Hauses.

Für Entwickler

Voraussetzungen

  • Recht `menu:read`
  • Kein Plan-Merkmal

DREI EBENEN: eine KARTE (Speisekarte, Weinkarte, Mittagskarte), darin KATEGORIEN (die Gänge) und darin GERICHTE. Ein Gericht trägt neben `categoryId` auch `menuId`, damit Sie nicht zwei Abfragen brauchen, um zu wissen, auf welcher Karte es steht.

`priceCents` IST DER VERKAUFSPREIS BRUTTO IN GANZEN CENT. Der Name trägt die Einheit, weil das Feld in der Datenbank schlicht `price` heisst — wer daraus Euro liest, schreibt „1200 €" auf die Tafel. `1200` sind zwölf Euro.

DIE KALKULATION DES HAUSES GEHT NIE HINAUS. Einkaufspreis, Nettopreis und Steuersatz sind in keiner Antwort dieses Bereichs enthalten. Der Grund ist der Anwendungsfall: ein Schlüssel für die Speisekarte liegt zwangsläufig im Code einer fremden Website — dort darf der Deckungsbeitrag des Betriebs nicht mitliegen. Selbst `vatRate` fehlt, obwohl es harmlos klingt: aus Bruttopreis und Steuersatz rechnet jeder den Nettopreis in einer Zeile aus.

ALLERGENE WERDEN GEPRÜFT AUSGELIEFERT. Die Spalte ist freier Text; ausgeliefert werden nur bekannte LMIV-Schlüssel, alles andere fällt weg. Eine Website, die daraus eine Allergenkennzeichnung baut, darf keinen erfundenen Schlüssel angezeigt bekommen — falsche Allergenangaben sind ein Gesundheitsrisiko und kein Darstellungsfehler.

`isAvailable` IST DER SCHALTER FÜR DIE TAFEL. `?available=true` liefert genau das, was heute angeboten wird. Ohne den Filter enthält die Liste auch, was gerade aus ist — für eine Website fast nie gemeint. Ein Tippfehler wie `?avaliable=true` ist übrigens 400 und nicht eine Liste mit allem: die Abfrageschemata sind streng, gerade weil dieser Fehler still wäre und wochenlang die falschen Gerichte anzeigte.

`menuId` IM PFAD WIRD ZUERST GEGEN DEN BETRIEB GEPRÜFT. Eine fremde Kennung ergibt 404 und nicht eine leere Liste — sonst wäre der Unterschied zwischen „gibt es nicht" und „gehört jemand anderem" unsichtbar, und die Folgeabfrage hinge allein am Mandantenprädikat.

KEIN PLAN-MERKMAL. Die Speisekarte gehört zur Website UND zur Reservierung; sie hängt im Dashboard an keinem Merkmal, und die API spiegelt das. Ein Betrieb ohne Website-Plan pflegt trotzdem seine Karte.

Die Routen

GET/api/v1/menus

Die Karten des Hauses — Speisekarte, Weinkarte, Mittagskarte.

Rechte

menu:read

Abfrageparameter

NameTypBedeutung
activetrue | falseNur aktive bzw. nur abgeschaltete Karten.
updatedSinceISO-8601 mit ZoneNur seither geänderte Karten.
limitinteger 1–200Vorgabe: 50Zeilen je Seite.
cursorundurchsichtiger Zeiger`nextCursor` der vorigen Antwort.
includeTotaltrue | falseVorgabe: falseErgänzt `total`.

Mögliche Fehler

  • validationUnbekannter oder doppelter Parameter, unlesbarer Cursor.
  • forbiddenDem Schlüssel fehlt `menu:read`.
  • Sortiert nach `sortOrder`, dann `id` — die Reihenfolge des Wirts. `isDefault` markiert die Karte, die zuerst gezeigt wird.

GET/api/v1/menus/{menuId}/categories

Die Gänge einer Karte — Vorspeisen, Hauptgänge, Desserts.

Rechte

menu:read

Pfad

NameTypBedeutung
{menuId}PflichtUUIDKennung der Karte. Eine fremde oder unbekannte Kennung ergibt 404.

Abfrageparameter

NameTypBedeutung
activetrue | falseNur aktive bzw. nur abgeschaltete Kategorien.
updatedSinceISO-8601 mit ZoneNur seither geänderte Kategorien.
limitinteger 1–200Vorgabe: 50Zeilen je Seite.
cursorundurchsichtiger Zeiger`nextCursor` der vorigen Antwort.
includeTotaltrue | falseVorgabe: falseErgänzt `total`.

Mögliche Fehler

  • not_foundDiese Karte gibt es für diesen Betrieb nicht.
  • validationUnbekannter oder doppelter Parameter, unlesbarer Cursor.
  • forbiddenDem Schlüssel fehlt `menu:read`.
  • `menuId` in der Antwort kann `null` sein — Altbestand ohne Kartenzuordnung.

GET/api/v1/menu-items

Die Gerichte, gefiltert nach Karte, Gang und Verfügbarkeit — der Endpunkt für Website und Digital Signage.

Rechte

menu:read

Abfrageparameter

NameTypBedeutung
menuIdUUIDNur Gerichte dieser Karte. Eine fremde Kennung ergibt 404.
categoryIdUUIDNur Gerichte dieses Gangs. Eine fremde Kennung ergibt 404.
availabletrue | false`true` liefert genau das, was heute angeboten wird — für eine Website fast immer gemeint.
updatedSinceISO-8601 mit ZoneNur seither geänderte Gerichte.
limitinteger 1–200Vorgabe: 50Zeilen je Seite.
cursorundurchsichtiger Zeiger`nextCursor` der vorigen Antwort.
includeTotaltrue | falseVorgabe: falseErgänzt `total`.

Mögliche Fehler

  • not_foundDer genannte `menuId` oder `categoryId` gehört nicht zu diesem Betrieb.
  • validationUnbekannter oder doppelter Parameter, unlesbarer Cursor.
  • forbiddenDem Schlüssel fehlt `menu:read`.
  • Sortiert nach `sortOrder`, dann `id`.
  • `allergens` enthält ausschliesslich bekannte LMIV-Schlüssel; unbekannter Inhalt der Spalte fällt weg.

Codebeispiele

curl — die Karte für die Website
curl
curl -sS "https://tactictable.com/api/v1/menus?active=true" \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" curl -sS "https://tactictable.com/api/v1/menus/1b26d199-7099-57e0-b127-6c2b75887de5/categories?active=true" \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" curl -sS "https://tactictable.com/api/v1/menu-items?menuId=1b26d199-7099-57e0-b127-6c2b75887de5&available=true&limit=200" \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM"
Drei Aufrufe, einmal je Ebene. Für eine Website genügt meist der dritte: `menu-items` trägt `menuId` und `categoryId` bereits mit.
Antwort 200 — GET /api/v1/menu-items
JSON
{  "data": [    {      "id": "b79f086d-d89e-53c5-81dc-888d3d93ce3b",      "categoryId": "17997a5f-0bd2-5a6f-add0-3bd1f46f8657",      "menuId": "1b26d199-7099-57e0-b127-6c2b75887de5",      "name": "Backhendl mit Erdäpfelsalat",      "description": "Gebackenes Hühnerfilet, lauwarmer Erdäpfelsalat mit Kürbiskernöl.",      "priceCents": 2180,      "imageUrl": "https://cdn.tactictable.com/menu/backhendl.jpg",      "allergens": ["gluten", "eggs"],      "tags": ["Klassiker", "regional"],      "isVegetarian": false,      "isVegan": false,      "isGlutenFree": false,      "isAvailable": true,      "sortOrder": 3,      "timezone": "Europe/Vienna",      "createdAt": "2026-03-04T12:00:00.000Z",      "updatedAt": "2026-09-01T09:14:33.000Z"    }  ],  "nextCursor": null,  "hasMore": false,  "limit": 200}
`priceCents: 2180` sind 21,80 €. Rechnen Sie erst bei der Ausgabe in Euro um und speichern Sie den Wert als ganze Zahl — ein Gleitkommabetrag verliert früher oder später einen Cent.
Antwort 200 — GET /api/v1/menus
JSON
{  "data": [    {      "id": "1b26d199-7099-57e0-b127-6c2b75887de5",      "name": "Speisekarte",      "description": "Ganzjährig, dazu wechselnde Tagesempfehlungen.",      "icon": "UtensilsCrossed",      "sortOrder": 0,      "isActive": true,      "isDefault": true,      "timezone": "Europe/Vienna",      "createdAt": "2026-03-04T11:58:00.000Z",      "updatedAt": "2026-03-04T11:58:00.000Z"    }  ],  "nextCursor": null,  "hasMore": false,  "limit": 50}
`icon` ist der Name eines Symbols aus dem Symbolsatz der Oberfläche. Kennen Sie ihn nicht, zeichnen Sie keins — ein Ersatzzeichen sieht aus, als fehlte etwas.
TypeScript — Karte nach Gängen gruppieren
TypeScript
interface Gericht {    id: string    categoryId: string    menuId: string | null    name: string    description: string | null    priceCents: number    allergens: string[]    isVegetarian: boolean    isVegan: boolean    isGlutenFree: boolean    isAvailable: boolean    sortOrder: number} /** Cent -> Anzeige. NIE vorher in Gleitkomma umrechnen und speichern. */export function euro(cents: number): string {    return (cents / 100).toLocaleString('de-AT', { style: 'currency', currency: 'EUR' })} export async function karteLaden(token: string, menuId: string) {    const p = new URLSearchParams({ menuId, available: 'true', limit: '200' })     const antwort = await fetch('https://tactictable.com/api/v1/menu-items?' + p, {        headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },    })    if (antwort.status === 404) throw new Error('Diese Karte gibt es hier nicht.')    if (!antwort.ok) throw new Error('HTTP ' + antwort.status)     const gerichte = ((await antwort.json()) as { data: Gericht[] }).data     const nachGang = new Map<string, Gericht[]>()    for (const g of gerichte) {        const liste = nachGang.get(g.categoryId) ?? []        liste.push(g)        nachGang.set(g.categoryId, liste)    }    for (const liste of nachGang.values()) {        liste.sort((a, b) => a.sortOrder - b.sortOrder)    }     return nachGang}
Die Umrechnung in Euro passiert erst bei der Ausgabe. Wer `priceCents / 100` speichert, hat spätestens bei der dritten Rechnung einen Rundungsfehler im Bestand.
Python — Tafel für den Bildschirm im Lokal
Python
import requests # LMIV-Schluessel -> Klartext. Ein unbekannter Schluessel wird DURCHGEREICHT,# nie weggelassen: eine fehlende Allergenangabe ist ein Gesundheitsrisiko.ALLERGENE = {    "gluten": "Gluten",    "crustaceans": "Krebstiere",    "eggs": "Eier",    "fish": "Fisch",    "peanuts": "Erdnüsse",    "soybeans": "Soja",    "milk": "Milch",    "nuts": "Schalenfrüchte",    "celery": "Sellerie",    "mustard": "Senf",    "sesame": "Sesam",    "sulphites": "Sulfite",    "lupin": "Lupinen",    "molluscs": "Weichtiere",}  def tafel(sitzung: requests.Session, menu_id: str) -> list:    antwort = sitzung.get(        "https://tactictable.com/api/v1/menu-items",        params={"menuId": menu_id, "available": "true", "limit": 200},        timeout=20,    )    antwort.raise_for_status()     zeilen = []    for g in antwort.json()["data"]:        zeilen.append({            "name": g["name"],            "preis": "{:,.2f} €".format(g["priceCents"] / 100).replace(",", " ").replace(".", ","),            "allergene": [ALLERGENE.get(a, a) for a in g["allergens"]],            "vegan": g["isVegan"],        })     return zeilen
`ALLERGENE.get(a, a)` gibt den Rohwert zurück, wenn der Schlüssel unbekannt ist. Ein Allergen stillschweigend wegzulassen, weil die eigene Tabelle es nicht kennt, ist der gefährlichste Fehler in diesem ganzen Kapitel.
PHP — Speisekarte in eine Website einbinden
PHP
<?php function speisekarte(string $token, string $menuId): array{    $adresse = 'https://tactictable.com/api/v1/menu-items?'        . http_build_query(['menuId' => $menuId, 'available' => 'true', 'limit' => 200]);     $ch = curl_init($adresse);    curl_setopt_array($ch, [        CURLOPT_RETURNTRANSFER => true,        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],        CURLOPT_TIMEOUT => 20,    ]);    $rumpf = curl_exec($ch);    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);    curl_close($ch);     if ($status === 404) {        throw new RuntimeException('Diese Karte gehoert nicht zu diesem Betrieb.');    }    if ($status !== 200) {        throw new RuntimeException('HTTP ' . $status);    }     $json = json_decode($rumpf, true, 512, JSON_THROW_ON_ERROR);     return array_map(static function (array $g): array {        return [            'name' => $g['name'],            'beschreibung' => $g['description'],            // Ganze Cent bleiben ganze Cent, bis sie angezeigt werden.            'preis' => number_format($g['priceCents'] / 100, 2, ',', '.') . ' €',            'allergene' => $g['allergens'],        ];    }, $json['data']);}
Der Schlüssel dieser Einbindung braucht NUR `menu:read`. Genau deshalb liefert die Route keine Einkaufspreise: er liegt im Code einer Website, die jeder herunterladen kann.