Flavoso APILeitfädenSpeisekarte abgleichen

Speisekarte abgleichen

PUT /menu gleicht die ganze Karte in einem Aufruf ab, mit external_id als Schlüssel. Dazu Ausverkauft in einem Aufruf und Einzelrouten für jedes Objekt.

Das Modell

Eine Karte hat vier Arten von Objekten, als flache Listen mit Verweisen:

ObjektWichtige Felder
Kategorie (category)name, position, tax_class
Gericht (product)category_id, name, description, price, available, position, allergens, diets, tax_class, image_url, option_group_ids
Optionsgruppe (option_group)name, min_select, max_select, position, options
Option (option)name, price_delta, available, position

Jedes Objekt trägt id, external_id (die deiner App) und updated_at. GET /menu liefert alles auf einmal, auch was gerade ausverkauft ist.

Abgleich mit PUT /menu

PUT /menu nimmt die Karte, wie deine Kasse sie kennt. Schlüssel ist überall die external_id: ein Objekt mit einer Kennung deiner App wird geändert, alles andere im Rumpf neu angelegt. Gerichte verweisen mit category_external_id auf ihre Kategorie und mit option_group_external_ids (in dieser Reihenfolge) auf ihre Optionsgruppen. Optionen stehen in ihrer Gruppe.

Ein Abgleich erkennt nur Objekte, die eine Kennung deiner App tragen. Gibt es im Betrieb schon eine Karte, legt ein erster PUT /menu sie doppelt an. Übernimm sie vorher: lies GET /menu, ordne die Objekte deinen Artikeln zu und setz je Objekt die external_id mit PATCH (PATCH /menu/categories/{id}, PATCH /menu/products/{id}, PATCH /menu/option-groups/{id}, PATCH /menu/options/{id}). Im Test-Betrieb erledigt das „Kennungen vergeben“ im Reiter Speisekarte der Konsole. Ein Probelauf zeigt Doppelte in summary.*.created.
Rumpf
{
  "mode": "merge",
  "dry_run": false,
  "categories": [
    {
      "external_id": "CAT-PIZZA",
      "name": "Pizza",
      "position": 1,
      "tax_class": "food"
    },
    {
      "external_id": "CAT-DRINKS",
      "name": "Getränke",
      "position": 2,
      "tax_class": "drink"
    }
  ],
  "products": [
    {
      "external_id": "ART-1001",
      "category_external_id": "CAT-PIZZA",
      "name": "Pizza Margherita",
      "description": "Tomato, mozzarella, basil",
      "price": 950,
      "available": true,
      "position": 1,
      "allergens": [
        "gluten",
        "milk"
      ],
      "diets": [
        "vegetarian"
      ],
      "option_group_external_ids": [
        "GRP-EXTRAS"
      ]
    },
    {
      "external_id": "ART-2001",
      "category_external_id": "CAT-DRINKS",
      "name": "Cola 0,33 l",
      "price": 290,
      "available": true,
      "position": 1,
      "image_url": "https://pos.example.com/images/cola.jpg"
    }
  ],
  "option_groups": [
    {
      "external_id": "GRP-EXTRAS",
      "name": "Extras",
      "min_select": 0,
      "max_select": 3,
      "options": [
        {
          "external_id": "OPT-CHEESE",
          "name": "Extra cheese",
          "price_delta": 150
        },
        {
          "external_id": "OPT-SALAMI",
          "name": "Spicy salami",
          "price_delta": 200,
          "available": false
        }
      ]
    }
  ]
}
  • Preise in Cent, brutto: price am Gericht, price_delta an der Option.
  • Pflicht sind mode, an jedem Objekt external_id und name, dazu price an Gerichten und options an Gruppen (auch leer). name und price setzt jeder Abgleich auf deinen Wert; eine Option deiner App, die im Rumpf nicht mehr vorkommt, löscht erst replace, bei merge bleibt sie stehen. Alle anderen Felder, die du weglässt, bleiben, wie sie sind: eine Kasse, die keine Allergene oder keinen Ausverkauft-Stand kennt, überschreibt nicht, was der Wirt eingetragen hat. Ein Feld, das der Abgleich nicht kennt, ergibt 400 mit param.
  • Eine Liste, die im Rumpf fehlt, ist nicht Teil des Abgleichs: replace ohne products löscht keine Gerichte. Erst eine leere Liste [] heißt „keine“.
  • image_url (https) holt Flavoso selbst ab und speichert es wie einen Upload im Dashboard: PNG, JPEG oder WebP, höchstens 1 MB. Trägt ein Gericht schon das Bild von genau dieser Adresse, lädt Flavoso es nicht noch einmal; ein neues Bild unter derselben Adresse bemerkt es deshalb nicht, häng dann einen Versionsparameter an, etwa ?v=2. Hat der Wirt das Bild im Dashboard ersetzt, setzt der nächste Abgleich wieder deins. Je Abgleich kommen bis zu 25 neue Bilder, der Rest beim nächsten (Hinweis images_deferred). Ein Test-Betrieb behält höchstens 50 geladene Bilder; darüber hinaus werden Bilder übersprungen (Hinweis images_capped). Zurücksetzen in der Konsole löscht sie wieder. null entfernt das Bild.
  • allergens nimmt die 14 Schlüssel unten und ihre Bezeichnungen in den fünf Sprachen (aus Soja wird soybeans), diets die sechs Schlüssel unten, auch anders geschrieben (Gluten-free). Alles andere bleibt eine eigene Angabe und steht wörtlich auf der Bestellseite: höchstens 20 eigene Allergene mit je 40 Zeichen und 14 eigene Ernährungsformen mit je 60. Nur tax_class nimmt allein die Werte unten.
# menu.json holds the body from above. Run it with "dry_run": true first:
# nothing changes, the answer says what would. Then again with "dry_run": false.
curl -X PUT https://api.flavoso.com/v1/menu \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @menu.json

merge oder replace

modeWas im Rumpf stehtWas fehlt
mergewird angelegt oder geändertbleibt, wie es ist
replacewird angelegt oder geändertwird gelöscht, aber nur, wenn es eine external_id DEINER App trägt

Objekte ohne Kennung deiner App (im Dashboard angelegt, vom Karten-Import oder von einer anderen App) ändert und löscht ein Abgleich nicht, in keinem der beiden Fälle, mit zwei Ausnahmen bei replace: löscht er eine deiner Kategorien, wandern die eigenen Gerichte des Betriebs darin nach „Ohne Kategorie“, und löscht er eine deiner Optionsgruppen, gehen die eigenen Optionen des Betriebs darin mit (Hinweis unmanaged_options_deleted). replace ist richtig, wenn die Kasse die vollständige Wahrheit über ihre Artikel ist; merge, wenn sie nur einen Teil liefert.

Probelauf

Mit "dry_run": true prüft Flavoso den ganzen Rumpf und antwortet mit derselben Zusammenfassung, ohne etwas zu ändern; Bilder werden dabei nicht geladen. Ideal beim ersten Verbinden und vor einem replace: du siehst, was gelöscht würde. Steht beim ersten Mal ein Gericht, das es schon gibt, in summary.*.created, trägt es noch keine Kennung deiner App.

Die Antwort

Die Antwort zählt je Art, was angelegt, geändert, unverändert und gelöscht ist, nennt zu jeder external_id die Flavoso-Kennung und sammelt Hinweise in warnings. Speichere die Kennungen: Ereignisse und Bestellungen nennen die von Flavoso.

Antwort
{
  "object": "menu_sync",
  "mode": "merge",
  "dry_run": false,
  "summary": {
    "categories": {
      "created": 2,
      "updated": 0,
      "unchanged": 0,
      "deleted": 0
    },
    "products": {
      "created": 2,
      "updated": 0,
      "unchanged": 0,
      "deleted": 0
    },
    "option_groups": {
      "created": 1,
      "updated": 0,
      "unchanged": 0,
      "deleted": 0
    },
    "options": {
      "created": 2,
      "updated": 0,
      "unchanged": 0,
      "deleted": 0
    }
  },
  "warnings": [],
  "ids": {
    "categories": [
      {
        "external_id": "CAT-PIZZA",
        "id": "5a1c2e3d-4b5f-4a6e-8d7c-9b0a1f2e3d4c"
      },
      {
        "external_id": "CAT-DRINKS",
        "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e"
      }
    ],
    "products": [
      {
        "external_id": "ART-1001",
        "id": "303b87c9-58a3-4644-9a8b-2841cc1f89d6"
      },
      {
        "external_id": "ART-2001",
        "id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d"
      }
    ],
    "option_groups": [
      {
        "external_id": "GRP-EXTRAS",
        "id": "8e7d6c5b-4a39-4281-b0f9-e8d7c6b5a4f3"
      }
    ],
    "options": [
      {
        "external_id": "OPT-CHEESE",
        "id": "1f2e3d4c-5b6a-4798-8a7b-6c5d4e3f2a1b"
      },
      {
        "external_id": "OPT-SALAMI",
        "id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f"
      }
    ]
  }
}

Jeder Hinweis hat code und message:

codeBedeutung
image_failedEin Bild ließ sich nicht verwenden; das Gericht behält sein Bild.
images_deferredMehr neue Bilder, als ein Abgleich lädt. Gleich noch einmal abgleichen.
images_not_checkedProbelauf: Bilder wurden nicht geladen.
images_cappedNur im Test-Betrieb: mehr Bilder, als er behält (50); der Rest wurde nicht geladen.
unmanaged_keptObjekte ohne Kennung deiner App, die ein replace stehen gelassen hat.
unmanaged_options_deletedOptionen ohne deine Kennung, die mit einer gelöschten Gruppe gegangen sind.
own_option_group_keptDie eigene Gruppe eines Gerichts bleibt angehängt.
option_group_releasedDie Gruppe eines gelöschten Gerichts ist eine gemeinsame Gruppe geworden.

Grenzen und Transaktion

Ein Abgleich darf höchstens 200 Kategorien, 2000 Gerichte, 500 Optionsgruppen und 5000 Optionen enthalten; darüber antwortet Flavoso mit 422 limit_exceeded. Der Rumpf darf 2 MB groß sein, gezählt in Bytes; ein größerer bekommt 413 payload_too_large. Je Verbindung gehen höchstens 10 Abgleiche in der Minute.

Der Abgleich läuft in einer Transaktion, ganz oder gar nicht: scheitert ein Teil, bleibt die Karte, wie sie war. Ein Fehler in einem Eintrag kommt als 400 invalid_request mit param, etwa products[3].price, ein Verweis ins Leere ebenso, etwa products[3].category_external_id.

Gültige Werte

NameWerte
Steuergruppe (tax_class)fooddrinkalcohol
Allergene (allergens)glutencrustaceanseggsfishpeanutssoybeansmilknutscelerymustardsesamesulphiteslupinmolluscs
Ernährungsformen (diets)vegetarianvegangluten_freelactose_freehalalkosher

Ausverkauft in einem Aufruf

POST /menu/availability setzt die Verfügbarkeit von bis zu 500 Gerichten und Optionen auf einmal, ganz oder gar nicht. Jeder Eintrag nennt type (product oder option), dazu id oder external_id, und available als Zielzustand. Jedes Objekt, das sich dabei ändert, meldet sein eigenes menu.product.updated oder menu.option.updated.

curl -X POST https://api.flavoso.com/v1/menu/availability \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "items": [
    {
      "type": "product",
      "external_id": "ART-1001",
      "available": false
    },
    {
      "type": "option",
      "external_id": "OPT-SALAMI",
      "available": true
    },
    {
      "type": "product",
      "id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
      "available": true
    }
  ]
}'

Einzelne Objekte

Für gezielte Änderungen hat jede Art ihre eigenen Routen: Liste, Anlegen, Lesen, Ändern, Löschen. Die vollständige Liste steht in der Referenz.

  • GET /menu/products filtert nach category_id (eine Kennung oder none für „Ohne Kategorie“), available, external_id und updated_since, mit Blättern; GET /menu/categories, GET /menu/option-groups und GET /menu/options nur nach external_id und updated_since. GET /menu/options listet jede Option: ein neuer Aufpreis oder eine ausverkaufte Option ändert weder ihr Gericht noch ihre Gruppe, frag die Liste deshalb mit ab. Die Listen der Karte kommen immer aufsteigend nach updated_at.
  • PATCH /menu/products/{id} ändert jedes Feld, auch die Optionsgruppen (option_group_ids, in dieser Reihenfolge), das Bild und die external_id. Die eigene Gruppe eines Gerichts steht dort immer vorn und lässt sich nicht abhängen, nur löschen; die eigene Gruppe eines anderen Gerichts lässt sich nicht anhängen (400).
  • POST /menu/option-groups legt eine Gruppe samt Optionen an; POST /menu/option-groups/{id}/options eine Option in einer bestehenden Gruppe. Anlegen antwortet mit 201.
  • DELETE /menu/categories/{id} lässt die Gerichte nach „Ohne Kategorie“ wandern, wie im Dashboard. Mit ?with_products=true werden sie mitgelöscht.
# A new dish in an existing category
curl -X POST https://api.flavoso.com/v1/menu/products \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: create-ART-1002" \
  -d '{"external_id": "ART-1002", "category_id": "5a1c2e3d-4b5f-4a6e-8d7c-9b0a1f2e3d4c", "name": "Pizza Funghi", "price": 1090}'

# New price, sold out, other option groups (in this order)
curl -X PATCH https://api.flavoso.com/v1/menu/products/PRODUCT_ID \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"price": 1150, "available": false, "option_group_ids": ["8e7d6c5b-4a39-4281-b0f9-e8d7c6b5a4f3"]}'

# The dishes of one category, only what is sold out
curl "https://api.flavoso.com/v1/menu/products?category_id=5a1c2e3d-4b5f-4a6e-8d7c-9b0a1f2e3d4c&available=false" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

# Delete a category together with its dishes (without with_products they move to "No category")
curl -X DELETE "https://api.flavoso.com/v1/menu/categories/CATEGORY_ID?with_products=true" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Ereignisse der Speisekarte

Jede Änderung an der Karte meldet ein Ereignis, auch die deiner eigenen App: menu.category.*, menu.product.*, menu.option_group.* und menu.option.*, je created, updated und deleted. updated nennt in changed die geänderten Felder und in previous ihre alten Werte, deleted zeigt das Objekt, wie es vor dem Löschen war. Siehe Ereignisarten.

Ein Abgleich mit PUT /menu meldet statt vieler Einzelereignisse genau eines: menu.synced mit mode, summary und der connection_id der Verbindung, die abgeglichen hat; ist es deine eigene, kannst du es überspringen. Was sich geändert hat, holst du danach mit GET /menu/products?updated_since=… und GET /menu/options?updated_since=…. external_id steht in Ereignissen immer auf null, weil ein Umschlag an alle Empfänger gleich geht.

Wer das letzte Wort hat

Der Wirt kann im Dashboard weiter alles ändern, auch Gerichte mit einer Kennung deiner App. Ein Abgleich überschreibt die Felder, die er schickt. Bewährt hat sich: die Kasse ist die Quelle für ihre Artikel, Namen und Preise; Beschreibungen, Bilder und Allergene pflegt der Wirt in Flavoso, und die Kasse schickt diese Felder dann nicht mit. Mit den menu.*-Ereignissen siehst du, was im Dashboard geändert wurde.

Etwas unklar geblieben? Schreib an support@flavoso.com und nenne die Kennung aus X-Request-Id. Damit finden wir deine Anfrage im Protokoll.