Kostenlos testen
Handbuch

Rezept: API für ein Google Sheet

Ein Google Sheet als kleine Datenbank mit authentifizierter REST-API: Produkte lesen, Anfragen anhängen — mit API-Key, Rate-Limit und OpenAPI.

Viele Teams pflegen Stammdaten längst in einem Google Sheet: Produktliste, Preise, Termine, Lagerbestand. Mit einer API darauf können Shop, Website-Backend oder ein Partner die Daten direkt abfragen — ohne CSV-Export, ohne eigenen Server.

Ziel

  • POST /api/v1/tools/getProdukte liefert die Produkte aus dem Sheet als JSON, optional gefiltert
  • POST /api/v1/tools/createSheetAnfrage hängt eine Anfrage als neue Zeile an
  • Zugriff nur mit API-Key, Rate-Limit pro Key, OpenAPI-Spec für den Aufrufer

Voraussetzungen

  1. Sheet anlegen mit zwei Tabs:
    • Produkte — Kopfzeile sku | name | kategorie | preis | bestand
    • Anfragen — Kopfzeile zeitpunkt | sku | email | nachricht | quelle
  2. Mit dem Plattform-Service-Account teilen (E-Mail-Adresse steht unter Backend → Verwaltung → Google Dokumente). Nur lesen: Betrachter. Mit createSheetAnfrage: Bearbeiter.
  3. Sheet registrieren unter Backend → Verwaltung → Google Dokumente, Slug produkte. Der Slug verbindet das Tool mit genau diesem Sheet; ein fremdes Sheet-ID-Argument akzeptiert die API nicht.

1. Tools anlegen

config/tenants/<workspace>/tools/api/sheet_api.php — vollständiges Beispiel auch unter docs/examples/custom-api/sheet_api.php.

Der Aufbau folgt „ein Tool = ein API-Call": zwei interne Tools greifen fest verdrahtet auf je einen Tab zu, zwei freigegebene Tools enthalten die Fachlogik.

<?php

return [
    'tools' => [

        // --- Interne Bausteine: ein Sheet-Zugriff, fest verdrahtet ---------

        'readProdukteSheet' => [
            'description' => 'Liest den Tab Produkte aus dem Produkte-Sheet (roh).',
            'internal' => true,
            'handler' => [\App\Tooling\GoogleSheetsTools::class, 'readFromGoogleSheet'],
            'slug' => 'produkte',
            'defaultSheetName' => 'Produkte',
            'parameters' => [
                'type' => 'object',
                'properties' => [
                    'range' => ['type' => 'string', 'description' => 'A1-Bereich, z.B. A1:E'],
                ],
            ],
        ],

        'appendAnfrageRow' => [
            'description' => 'Haengt eine Zeile an den Tab Anfragen an.',
            'internal' => true,
            'handler' => [\App\Tooling\GoogleSheetsTools::class, 'writeToGoogleSheet'],
            'slug' => 'produkte',
            'defaultSheetName' => 'Anfragen',
            'parameters' => [
                'type' => 'object',
                'properties' => [
                    'data' => ['type' => 'array', 'items' => ['type' => 'array']],
                    'mode' => ['type' => 'string', 'enum' => ['append']],
                ],
                'required' => ['data'],
            ],
        ],

        // --- Extern freigegeben ------------------------------------------

        'getProdukte' => [
            'description' => 'Liefert Produkte aus dem Produkte-Sheet, optional gefiltert nach Kategorie.',
            'external_api_description' => 'Produkte abrufen (sku, name, kategorie, preis, bestand). Optional nach Kategorie filtern.',
            'internal' => true,
            'external_api' => true,
            'parameters' => [
                'type' => 'object',
                'properties' => [
                    'kategorie' => ['type' => 'string', 'maxLength' => 60],
                    'nur_verfuegbar' => ['type' => 'boolean', 'description' => 'Nur Produkte mit Bestand > 0'],
                ],
            ],
            'handler' => function (array $results, array $args, array $ctx): array {
                $raw = $ctx['tool']('readProdukteSheet', ['range' => 'A1:E']);
                if (empty($raw['success'])) {
                    $ctx['logger']->error('getProdukte: ' . ($raw['error'] ?? 'unbekannt'));

                    return ['success' => false, 'error' => 'Sheet nicht lesbar'];
                }

                $rows = $raw['data'] ?? [];
                $header = array_map(fn($h) => strtolower(trim((string)$h)), array_shift($rows) ?? []);
                $kategorie = isset($args['kategorie']) ? mb_strtolower(trim($args['kategorie'])) : null;
                $nurVerfuegbar = !empty($args['nur_verfuegbar']);

                $produkte = [];
                foreach ($rows as $row) {
                    $row = array_pad($row, count($header), '');
                    $p = array_combine($header, array_slice($row, 0, count($header)));
                    if (($p['sku'] ?? '') === '') {
                        continue;
                    }
                    $p['preis'] = (float)str_replace(',', '.', (string)($p['preis'] ?? '0'));
                    $p['bestand'] = (int)($p['bestand'] ?? 0);
                    if ($kategorie !== null && mb_strtolower((string)($p['kategorie'] ?? '')) !== $kategorie) {
                        continue;
                    }
                    if ($nurVerfuegbar && $p['bestand'] <= 0) {
                        continue;
                    }
                    $produkte[] = $p;
                }

                return ['success' => true, 'count' => count($produkte), 'produkte' => $produkte];
            },
        ],

        'createSheetAnfrage' => [
            'description' => 'Schreibt eine Produktanfrage in den Tab Anfragen.',
            'external_api_description' => 'Produktanfrage anlegen (landet als neue Zeile im Tab Anfragen).',
            'internal' => true,
            'external_api' => true,
            // Bewusst schreibend: haengt nur eine Zeile an, ueberschreibt nichts.
            'external_api_write' => true,
            'parameters' => [
                'type' => 'object',
                'properties' => [
                    'sku' => ['type' => 'string', 'pattern' => '^[A-Z0-9-]{2,20}$'],
                    'email' => ['type' => 'string', 'maxLength' => 200, 'pattern' => '^[^@\s]+@[^@\s]+\.[^@\s]+$'],
                    'nachricht' => ['type' => 'string', 'minLength' => 3, 'maxLength' => 1000],
                ],
                'required' => ['sku', 'email', 'nachricht'],
            ],
            'handler' => function (array $results, array $args, array $ctx): array {
                // Formel-Injection verhindern: Sheets wertet =, +, -, @ am
                // Zellanfang als Formel aus.
                $cell = fn(string $v): string => preg_match('/^[=+\-@]/', $v) ? "'" . $v : $v;

                $res = $ctx['tool']('appendAnfrageRow', [
                    'mode' => 'append',
                    'data' => [[
                        date('Y-m-d H:i:s'),
                        $cell($args['sku']),
                        $cell($args['email']),
                        $cell($args['nachricht']),
                        'api:' . ($args['_api_key']['name'] ?? 'unbekannt'),
                    ]],
                ]);
                if (!empty($res['error']) || (isset($res['success']) && !$res['success'])) {
                    $ctx['logger']->error('createSheetAnfrage: ' . ($res['error'] ?? 'unbekannt'));

                    return ['success' => false, 'error' => 'Anfrage konnte nicht gespeichert werden'];
                }

                return ['success' => true];
            },
        ],
    ],
];

Worauf es ankommt:

  • Kein slug-Parameter in den freigegebenen Tools. Die API verwirft undeklarierte Args; so kann niemand das Tool auf ein anderes Sheet umbiegen.
  • getProdukte ist lesend und läuft im Read-only-Kontext. Würde es versehentlich appendAnfrageRow rufen, verweigert der Sheets-Handler.
  • createSheetAnfrage schreibt und trägt deshalb external_api_write. Mit USER_ENTERED würde Sheets Eingaben wie =HYPERLINK(…) als Formel ausführen — der Handler setzt davor ein Hochkomma.

2. Lokal testen

bin/cake test_tool -t <workspace> --tool getProdukte --args='{"kategorie":"Zimmerpflanzen","nur_verfuegbar":true}'
{
    "success": true,
    "count": 1,
    "produkte": [
        {"sku": "MON-01", "name": "Monstera", "kategorie": "Zimmerpflanzen", "preis": 24.9, "bestand": 12}
    ]
}

3. API-Key anlegen

Backend → Verwaltung → API-Keys → „Neuer API-Key": Name z. B. Shop-Backend, Tools getProdukte (und bei Bedarf createSheetAnfrage) anhaken, Limit 60/Minute. Key kopieren — er wird nur einmal angezeigt.

4. Aufrufen

KEY=tm_live_…

# Produkte einer Kategorie
curl -s -X POST https://app.taskmonkey.de/api/v1/tools/getProdukte \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"args":{"kategorie":"Zimmerpflanzen"}}'

# Anfrage anlegen
curl -s -X POST https://app.taskmonkey.de/api/v1/tools/createSheetAnfrage \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"args":{"sku":"MON-01","email":"kunde@example.com","nachricht":"Ist die Monstera auch größer lieferbar?"}}'

# OpenAPI-Spec für diesen Key
curl -s https://app.taskmonkey.de/api/v1/openapi.json -H "Authorization: Bearer $KEY"

Ungültige Eingaben werden abgelehnt, bevor das Sheet angefasst wird:

curl -s -X POST https://app.taskmonkey.de/api/v1/tools/createSheetAnfrage \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"args":{"sku":"x","email":"keine-mail"}}'
# 400 {"success":false,"error":"Ungueltige Parameter","details":["Parameter 'nachricht' fehlt.", …]}

Grenzen

  • Die Sheets-API hat eigene Quoten (Richtwert: 60 Lesezugriffe pro Minute und Nutzer). Für viel Traffic das Key-Limit entsprechend niedrig halten oder die Daten in Supabase spiegeln.
  • Jeder Aufruf liest das Sheet neu — keine Zwischenspeicherung. Für große Sheets den range eingrenzen.
  • Ein Sheet ist keine Transaktions-Datenbank: parallele Anfragen landen in beliebiger Reihenfolge.

Verwandt

Zuletzt aktualisiert: 2026-09-25