Auskunft und Löschbegehren (DSGVO)

Art. 15 und 20 als vollständige Auskunft, Art. 17 als Anonymisierung — dreifach gebremst, weil beides unumkehrbar oder hochverdichtet ist.

Für Entwickler

Voraussetzungen

  • Auskunft: `guests:read` UND `action:guests.personal`
  • Löschbegehren: `guests:write` UND `action:guests.personal`
  • Plan-Merkmal „Gäste / CRM"

ZWEI VORGÄNGE, ZWEI ROUTEN. Die AUSKUNFT (`GET …/export`) stellt alles zusammen, was der Betrieb über eine Person gespeichert hat: Stammdaten, Einwilligungen, Reservierungen, Wartelisteneinträge und die Metadaten der KI-Sitzungen. Das LÖSCHBEGEHREN (`POST …/anonymize`) leert den Personenbezug überall dort — unumkehrbar.

DIE AUSKUNFT IST DIE DICHTESTE ZUSAMMENSTELLUNG PERSONENBEZOGENER DATEN, die dieses Haus erzeugen kann. Sie verlangt deshalb `guests:read` UND die Handlung `action:guests.personal` (sie enthält Notizen, Sperrgrund und No-Show-Zähler — ohne die Handlung wäre sie der Umgehungsweg um genau diese Schranke) und zieht vom Stundenkontingent des Schlüssels ab.

WAS NICHT DRINSTEHT: die Nachrichteninhalte der KI-Sitzungen. Sie enthalten regelmässig Aussagen über DRITTE („meine Frau isst kein Gluten") — die Auskunft an eine Person darf keine Auskunft über eine andere sein. Deshalb nur die Metadaten der Sitzungen, genau wie im Dashboard-Export.

`complete` SAGT, OB DIE AUSKUNFT VOLLSTÄNDIG IST. Je Teilliste gibt es eine Obergrenze (1 000 Reservierungen, 500 Wartelisteneinträge, 500 KI-Sitzungen). Wird eine erreicht, steht `complete: false` — und dann ist die Auskunft ausdrücklich unvollständig und darf nicht als vollständig weitergegeben werden.

ANONYMISIERT WIRD, NICHT GELÖSCHT, und das ist die wichtigste Entscheidung dieses Bereichs. Der Weg im Dashboard löscht die Gastzeile; weil die Verknüpfungen auf „leeren" stehen, verlieren damit ALLE Reservierungen ihre Zuordnung. Berichte, Auslastungsstatistik und Besuchszählung bekommen Löcher, die niemand mehr schliesst. Hier bleibt die Zeile stehen und wird GELEERT: der Personenbezug ist weg, die Zählung bleibt.

DREI SCHRANKEN, WEIL ES KEIN ZURÜCK GIBT. Erstens `guests:write` UND `action:guests.personal`. Zweitens das ausgeschriebene Wort `{"confirm":"anonymisieren"}` im Rumpf — eine Adresse allein löst nichts aus, damit ein Vorschauabruf, ein verirrter Wiederholversuch oder eine falsch gebaute Schleife ins Leere treffen. Drittens ist `Idempotency-Key` PFLICHT: die Wiederholung nach einem Netzabbruch darf nicht die zweite Hälfte einer halben Löschung sein — und die Idempotenzzeile ist derzeit der einzige dauerhafte Nachweis, DASS gelöscht wurde. Dazu ein eigenes Stundenkontingent von zwanzig Vorgängen: mehr sind kein Betrieb, sondern ein Zwischenfall.

WAS DIE ANONYMISIERUNG ANFASST: die Gastzeile (Name, Mail, Telefon, Firma, Notizen, Merkmale, Sperre, Einwilligungen, Newsletter-Token), die Reservierungen (Name, Mail, Telefon, Gastwunsch; `anonymizedAt` wird gesetzt), die Wartelisteneinträge, die Notizen der Tischbelegungen und die KI-Sitzungen samt ihrer Nachrichteninhalte. Der Vorgang schreibt über Modulgrenzen hinweg, OHNE die Rechte dieser Module zu verlangen — das ist bewusst: eine „Löschung", die Name und Mailadresse an jeder Buchung stehen lässt, meldet Erfolg und hat nichts gelöscht. Er kann dabei in keiner Richtung etwas offenlegen oder anlegen; er entfernt ausschliesslich.

ZÄHLER UND DURCHSCHNITTE BLEIBEN STEHEN. `totalVisits`, `cancelCount`, `noShowCount` und `averagePartySize` sagen nach der Anonymisierung nichts mehr über einen Menschen aus, tragen aber die Auslastungsrechnung des Hauses. Sie zu nullen hiesse, die Statistik rückwirkend zu fälschen — und das verlangt Art. 17 nicht.

EIN ZWEITES LÖSCHBEGEHREN IST EIN ERFOLG, KEIN KONFLIKT. Ist die Zeile bereits geleert, antwortet die Route mit 200 und `anonymized.alreadyAnonymized: true`. Ein zweites Ticket zum selben Menschen darf nicht wie ein Fehler aussehen.

Die Routen

GET/api/v1/guests/{id}/export

Die vollständige Auskunft nach Art. 15 und 20 DSGVO zu einer Person — anstossbar aus einem Kanzlei- oder Ticketsystem, ohne dass jemand im Dashboard sitzt.

Rechte

guests:readaction:guests.personal

Alle genannten Rechte zusammen, nicht wahlweise.

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

Pfad

NameTypBedeutung
{id}PflichtUUIDKennung des Gastes. Eine fremde Kennung ergibt 404.

Mögliche Fehler

  • forbidden_actionDem Schlüssel fehlt `action:guests.personal`. `guests:read` allein reicht nicht — die Auskunft enthält Notizen, Sperrgrund und No-Show-Zähler.
  • forbiddenDem Schlüssel fehlt `guests:read`.
  • not_foundZu dieser Kennung gibt es in diesem Betrieb keinen Gast.
  • rate_limitedDas Stundenkontingent für zeilenliefernde Gästeaufrufe ist ausgeschöpft (`scope: "guests.bulk"`).
  • Zählt als EIN zeilenliefernder Gästeaufruf gegen das Stundenkontingent — unabhängig davon, wie viele Zeilen sie enthält.
  • Prüfen Sie IMMER `complete`. Steht dort `false`, ist eine Teilliste an ihrer Obergrenze gelaufen und die Auskunft unvollständig.

POST/api/v1/guests/{id}/anonymize

Das Löschbegehren nach Art. 17 DSGVO ausführen: den Personenbezug überall leeren, die Zeilen und die Zählung behalten.

Rechte

guests:writeaction:guests.personal

Alle genannten Rechte zusammen, nicht wahlweise.

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

Pfad

NameTypBedeutung
{id}PflichtUUIDKennung des Gastes.

Kopfzeilen

NameTypBedeutung
Idempotency-KeyPflichtstring bis 255 ZeichenPFLICHT. Ohne ihn antwortet die Route mit 400. Er ist zugleich der dauerhafte Nachweis des Vorgangs.

Felder im Rumpf

NameTypBedeutung
confirmPflichtgenau der Text "anonymisieren"Die ausgeschriebene Absicht. Jeder andere Wert ist 400 — der Vorgang ist unumkehrbar, also darf ihn keine Adresse allein auslösen.
referencestring bis 120 ZeichenIhr eigener Beleg (Ticketnummer, Aktenzeichen). Wird in der Antwort zurückgegeben und dient den Unterlagen des Betriebs.

Mögliche Fehler

  • validation`Idempotency-Key` fehlt, `confirm` fehlt oder lautet anders, oder der Rumpf enthält ein unbekanntes Feld.
  • forbidden_actionDem Schlüssel fehlt `action:guests.personal`.
  • forbiddenDem Schlüssel fehlt `guests:write`.
  • not_foundZu dieser Kennung gibt es in diesem Betrieb keinen Gast.
  • rate_limitedMehr als zwanzig Anonymisierungen je Stunde und Schlüssel (`scope: "guests.anonymize"`).
  • Antwortet mit 200 und `{ "data": …, "anonymized": { … }, "meta": … }`. Unter `anonymized` steht, wie viele Zeilen je Bereich angefasst wurden.
  • Der Prüfmodus (`X-TacticTable-Dry-Run: 1`) läuft auch hier und zeigt, was passieren würde, ohne etwas zu leeren.

Codebeispiele

curl — Auskunft anfordern
curl
curl -sS \  https://tactictable.com/api/v1/guests/fb69190b-e35f-5a2e-be8a-b7c272782903/export \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -o auskunft.json
Die Antwort kann gross werden — bis zu 1 000 Reservierungen, 500 Wartelisteneinträge und 500 Sitzungen. Schreiben Sie sie in eine Datei statt ins Terminal.
Antwort 200 — GET /api/v1/guests/{id}/export
JSON
{  "exportedAt": "2026-09-14T11:05:22.918Z",  "complete": true,  "guest": {    "id": "fb69190b-e35f-5a2e-be8a-b7c272782903",    "name": "Anna Berger",    "email": "anna.berger@example.at",    "phone": "+4366412345678",    "company": "Berger Consulting GmbH",    "notes": "Allergie: Sellerie",    "tags": ["Stammgast", "Fensterplatz"],    "isVIP": true,    "isBlocked": false,    "blockReason": null,    "source": "WIDGET",    "totalVisits": 12,    "noShowCount": 0,    "cancelCount": 1,    "totalSpentCents": 0,    "averagePartySize": 3,    "lastVisit": "2026-08-29T18:00:00.000Z",    "createdAt": "2026-02-18T10:22:41.000Z",    "updatedAt": "2026-09-14T07:12:44.019Z"  },  "consent": {    "privacyConsentAt": "2026-02-18T10:22:41.000Z",    "newsletterOptIn": true,    "newsletterConfirmedAt": "2026-02-18T10:24:02.000Z",    "newsletterUnsubscribedAt": null,    "source": "WIDGET"  },  "reservations": [    {      "id": "31061d44-ac33-550d-b4d0-a16973e270f5",      "date": "2026-08-29",      "time": "18:00",      "partySize": 3,      "status": "COMPLETED",      "source": "WIDGET",      "area": "Terrasse",      "notes": "Fensterplatz, wenn möglich",      "anonymizedAt": null,      "createdAt": "2026-08-20T09:15:01.000Z"    }  ],  "waitlistEntries": [],  "aiChatSessions": [    {      "id": "8c1f5b2a-9d33-4e10-b0a7-2f5c3d81e904",      "createdAt": "2026-07-02T15:44:09.000Z",      "anonymizedAt": null    }  ],  "meta": {    "restaurantId": "35122c8e-d842-5b49-814c-0e8f2aaed157",    "timezone": "Europe/Vienna"  }}
`aiChatSessions` trägt nur Metadaten — keine Nachrichten. Die enthalten regelmässig Aussagen über Dritte, und die Auskunft an eine Person darf keine Auskunft über eine andere sein.
curl — Löschbegehren ausführen
curl
curl -sS -X POST \  https://tactictable.com/api/v1/guests/fb69190b-e35f-5a2e-be8a-b7c272782903/anonymize \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -H "Content-Type: application/json" \  -H "Idempotency-Key: 2f9a7c41-8b6d-4e52-a0f3-7c19d8e40b25" \  -d '{"confirm": "anonymisieren", "reference": "Ticket 2026-1841"}'
DIESER AUFRUF IST UNUMKEHRBAR. Beides — `Idempotency-Key` und das Wort im Rumpf — ist Pflicht, damit ihn keine verirrte Schleife und kein Vorschauabruf auslösen kann.
Antwort 200 — anonymisiert
JSON
{  "data": {    "id": "fb69190b-e35f-5a2e-be8a-b7c272782903",    "name": "Anonymisierter Gast",    "email": null,    "phone": null,    "company": null,    "tags": [],    "isVIP": true,    "source": "WIDGET",    "totalVisits": 12,    "lastVisit": "2026-08-29T18:00:00.000Z",    "cancelCount": 1,    "totalSpentCents": 0,    "averagePartySize": 3,    "privacyConsentAt": null,    "newsletter": {      "subscribed": false,      "optIn": false,      "confirmedAt": "2026-02-18T10:24:02.000Z",      "unsubscribedAt": null    },    "createdAt": "2026-02-18T10:22:41.000Z",    "updatedAt": "2026-09-14T11:09:03.441Z",    "personal": {      "isBlocked": false,      "blockReason": null,      "noShowCount": 0,      "notes": null    }  },  "anonymized": {    "alreadyAnonymized": false,    "at": "2026-09-14T11:09:03.441Z",    "reference": "Ticket 2026-1841",    "reservations": 12,    "waitlistEntries": 0,    "tableOccupancies": 3,    "aiChatSessions": 1,    "aiChatMessages": 14  },  "meta": {    "restaurantId": "35122c8e-d842-5b49-814c-0e8f2aaed157",    "timezone": "Europe/Vienna"  }}
Die Zahlen unter `anonymized` sind der Nachweis: zwölf Reservierungen, drei Tischbelegungen und vierzehn Nachrichten wurden geleert. `totalVisits` bleibt 12 — die Zählung trägt die Auslastungsrechnung und sagt nichts mehr über einen Menschen.
Antwort 200 — war schon anonymisiert
JSON
{  "data": {    "id": "fb69190b-e35f-5a2e-be8a-b7c272782903",    "name": "Anonymisierter Gast",    "email": null,    "phone": null,    "personal": { "isBlocked": false, "blockReason": null, "noShowCount": 0, "notes": null }  },  "anonymized": { "alreadyAnonymized": true },  "meta": {    "restaurantId": "35122c8e-d842-5b49-814c-0e8f2aaed157",    "timezone": "Europe/Vienna"  }}
Gekürzt. `alreadyAnonymized: true` mit Status 200: das Begehren ist erfüllt. Ein zweites Ticket zum selben Menschen darf nicht wie ein Fehler aussehen.
Antwort 400 — die Bestätigung fehlt
JSON
{  "error": "validation",  "message": "Der Rumpf muss {\"confirm\":\"anonymisieren\"} enthalten. Der Vorgang ist unumkehrbar; die Absicht gehoert deshalb in den Rumpf und nicht allein in die Adresse.",  "issues": [    {      "path": "confirm",      "code": "invalid_literal",      "message": "Invalid literal value, expected \"anonymisieren\""    }  ],  "docs": "https://tactictable.com/dokumentation/api/fehler/validation",  "requestId": "req_8f31c0a94d2b47e6ba05"}
Dieselbe Antwort bekommt ein leerer POST. Genau dafür steht die Bestätigung: ein Link-Vorschauabruf oder ein verirrter Wiederholversuch trifft ins Leere.
TypeScript — Ticket abarbeiten: Auskunft, dann Löschung
TypeScript
import { randomUUID } from 'node:crypto' interface Auskunft {    exportedAt: string    complete: boolean    guest: { id: string; name: string }    reservations: unknown[]    waitlistEntries: unknown[]    aiChatSessions: unknown[]} export async function auskunft(token: string, gastId: string): Promise<Auskunft> {    const antwort = await fetch(        'https://tactictable.com/api/v1/guests/' + encodeURIComponent(gastId) + '/export',        { headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' } },    )    if (!antwort.ok) throw new Error('HTTP ' + antwort.status)     const daten = (await antwort.json()) as Auskunft     // NIE als vollstaendig weitergeben, wenn der Server das nicht sagt.    if (!daten.complete) {        throw new Error(            'Die Auskunft ist unvollstaendig (eine Teilliste hat ihre Obergrenze erreicht). ' +                'Bitte im Dashboard vervollstaendigen.',        )    }     return daten} export async function loeschbegehren(    token: string,    gastId: string,    aktenzeichen: string,): Promise<{ bereitsGeleert: boolean }> {    const antwort = await fetch(        'https://tactictable.com/api/v1/guests/' + encodeURIComponent(gastId) + '/anonymize',        {            method: 'POST',            headers: {                Authorization: 'Bearer ' + token,                'Content-Type': 'application/json',                // PFLICHT. Zugleich der dauerhafte Nachweis des Vorgangs.                'Idempotency-Key': randomUUID(),            },            body: JSON.stringify({ confirm: 'anonymisieren', reference: aktenzeichen }),        },    )     const rumpf = await antwort.json()    if (!antwort.ok) throw new Error(rumpf.error + ': ' + rumpf.message)     return { bereitsGeleert: rumpf.anonymized.alreadyAnonymized === true }}
Holen Sie die Auskunft VOR der Löschung — danach gibt es sie nicht mehr. Und heben Sie den Idempotenzschlüssel in Ihrem Ticket auf: er ist derzeit der einzige dauerhafte Beleg, dass der Vorgang stattgefunden hat.
Python — Auskunft als Datei für die Akte
Python
import json import requests  def auskunft_speichern(sitzung: requests.Session, gast_id: str, ziel: str) -> dict:    antwort = sitzung.get(        "https://tactictable.com/api/v1/guests/" + gast_id + "/export",        timeout=60,    )     if antwort.status_code == 403:        raise PermissionError(            "Dem Schluessel fehlt die Handlung guests.personal — ohne sie keine Auskunft."        )    if antwort.status_code == 429:        raise RuntimeError("Stundenkontingent erschoepft; spaeter erneut versuchen.")     antwort.raise_for_status()    daten = antwort.json()     with open(ziel, "w", encoding="utf-8") as datei:        json.dump(daten, datei, ensure_ascii=False, indent=2)     if not daten["complete"]:        raise RuntimeError("Auskunft unvollstaendig — eine Teilliste hat ihre Grenze erreicht.")     return daten
`ensure_ascii=False` erhält Umlaute, `indent=2` macht die Datei lesbar. Eine Auskunft, die jemand ausdrucken und dem Betroffenen geben soll, wird sonst zu einer Zeile.
Java — Löschbegehren aus einem Ticketsystem
Java
import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse; public final class Loeschbegehren {     /**     * Fuehrt das Loeschbegehren aus. `vorgang` ist die Ticketkennung —     * sie dient als Idempotency-Key und damit als Nachweis.     */    public static String ausfuehren(            HttpClient klient, String token, String gastId, String vorgang, String aktenzeichen)            throws Exception {         String rumpf = "{\"confirm\":\"anonymisieren\",\"reference\":\""                + aktenzeichen.replace("\"", "")                + "\"}";         HttpRequest anfrage = HttpRequest.newBuilder()                .uri(URI.create("https://tactictable.com/api/v1/guests/" + gastId + "/anonymize"))                .header("Authorization", "Bearer " + token)                .header("Content-Type", "application/json")                .header("Idempotency-Key", vorgang)                .POST(HttpRequest.BodyPublishers.ofString(rumpf))                .build();         HttpResponse<String> antwort = klient.send(anfrage, HttpResponse.BodyHandlers.ofString());         if (antwort.statusCode() != 200) {            throw new IllegalStateException("Loeschbegehren gescheitert: " + antwort.body());        }         return antwort.body();    }}
Die Ticketkennung als Idempotenzschlüssel ist hier genau richtig: dasselbe Ticket ein zweites Mal abzuarbeiten darf nicht ein zweites Mal arbeiten — und der Schlüssel verbindet beide Seiten.