Flavoso APIGuidesMenu sync

Menu sync

PUT /menu syncs the whole menu in one call, with external_id as the key. Plus sold-out updates in one call and single routes for every object.

The model

A menu has four kinds of objects, as flat lists with references:

ObjectImportant fields
Category (category)name, position, tax_class
Product (product)category_id, name, description, price, available, position, allergens, diets, tax_class, image_url, option_group_ids
Option group (option_group)name, min_select, max_select, position, options
Option (option)name, price_delta, available, position

Every object carries id, external_id (your app's) and updated_at. GET /menu returns everything at once, including what is currently sold out.

Syncing with PUT /menu

PUT /menu takes the menu as your POS knows it. The key is always the external_id: an object carrying an id of your app is updated, anything else in the body is created. Products point to their category with category_external_id and to their option groups with option_group_external_ids (in this order). Options sit inside their group.

A sync only recognises objects that carry an id of your app. If the restaurant has a menu already, a first PUT /menu creates it a second time. Take it over first: read GET /menu, map the objects to your items and set the external_id per object with PATCH (PATCH /menu/categories/{id}, PATCH /menu/products/{id}, PATCH /menu/option-groups/{id}, PATCH /menu/options/{id}). In the test restaurant, “Assign ids” on the console's Menu tab does this. A dry run shows duplicates in summary.*.created.
Body
{
  "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
        }
      ]
    }
  ]
}
  • Prices in cents, gross: price on the product, price_delta on the option.
  • Required: mode, and on every object external_id and name, plus price on products and options on groups (it may be empty). Every sync sets name and price to your values; an option of your app that no longer appears in the body is only deleted by replace, with merge it stays. All other fields you leave out stay as they are: a POS that does not track allergens or sold-out states does not overwrite what the owner entered. An unknown field gets 400 with param.
  • A list missing from the body is not part of the sync: replace without products deletes no products. Only an empty list [] means “none”.
  • Flavoso fetches image_url (https) itself and stores it like an upload in the dashboard: PNG, JPEG or WebP, at most 1 MB. If a product already carries the image from exactly this address, Flavoso does not download it again; so it does not notice a new image at the same address; in that case, append a version parameter, for example ?v=2. If the owner replaced the image in the dashboard, the next sync sets yours again. Each sync fetches up to 25 new images, the rest with the next one (warning images_deferred). A test restaurant keeps at most 50 downloaded images; beyond that, images are skipped (warning images_capped). Resetting in the console deletes them. null removes the image.
  • allergens takes the 14 keys below and their names in the five languages (Soja becomes soybeans), diets the six keys below, also spelled differently (Gluten-free). Anything else stays a custom entry and appears verbatim on the ordering page: at most 20 custom allergens of 40 characters each and 14 custom diets of 60 each. Only tax_class takes nothing but the values below.
# 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 or replace

modeWhat is in the bodyWhat is missing
mergeis created or changedstays as it is
replaceis created or changedis deleted, but only if it carries an external_id of YOUR app

Objects without your app's id (created in the dashboard, by the menu import or by another app) are not changed or deleted by a sync, in neither mode, with two exceptions in replace: if it deletes one of your categories, the restaurant's own products in it move to “No category”, and if it deletes one of your option groups, the restaurant's own options in that group are deleted with it (warning unmanaged_options_deleted). replace is right when the POS is the single source of truth for its items; merge when it only delivers part of them.

Dry run

With "dry_run": true, Flavoso checks the whole body and answers with the same summary, without changing anything; images are not downloaded. Ideal for the first connection and before a replace: you see what would be deleted. If a product that exists already is counted in summary.*.created the first time, it does not carry an id of your app yet.

The response

The response counts per kind what was created, changed, left unchanged and deleted, names the Flavoso id for every external_id and collects hints in warnings. Store the ids: events and orders carry Flavoso's.

Response
{
  "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"
      }
    ]
  }
}

Every warning has code and message:

codeMeaning
image_failedAn image could not be used; the product keeps its image.
images_deferredMore new images than one sync fetches. Sync again right away.
images_not_checkedDry run: images were not downloaded.
images_cappedTest restaurant only: more images than it keeps (50); the rest was not downloaded.
unmanaged_keptObjects without your app's id that a replace left in place.
unmanaged_options_deletedOptions without your id that went with a deleted group.
own_option_group_keptA product's own group stays attached.
option_group_releasedThe group of a deleted product became a shared group.

Limits and transaction

A sync may contain at most 200 categories, 2000 products, 500 option groups and 5000 options; beyond that, Flavoso answers 422 limit_exceeded. The body may be up to 2 MB, counted in bytes; a larger one gets 413 payload_too_large. Each connection can run at most 10 syncs per minute.

The sync runs in one transaction, completely or not at all: if one part fails, the menu stays as it was. An error in one entry comes as 400 invalid_request with param, for example products[3].price, and so does a reference to nothing, for example products[3].category_external_id.

Valid values

NameValues
Tax group (tax_class)fooddrinkalcohol
Allergens (allergens)glutencrustaceanseggsfishpeanutssoybeansmilknutscelerymustardsesamesulphiteslupinmolluscs
Diets (diets)vegetarianvegangluten_freelactose_freehalalkosher

Sold out in one call

POST /menu/availability sets the availability of up to 500 products and options at once, all or nothing. Every entry names type (product or option), plus id or external_id, and available as the target state. Every object that changes reports its own menu.product.updated or 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
    }
  ]
}'

Single objects

For targeted changes, every kind has its own routes: list, create, read, change, delete. The complete list is in the reference.

  • GET /menu/products filters by category_id (an id or none for “No category”), available, external_id and updated_since, with pagination; GET /menu/categories, GET /menu/option-groups and GET /menu/options only by external_id and updated_since. GET /menu/options lists every option: a new surcharge or a sold-out option changes neither its product nor its group, so poll it as well. The menu lists always come in ascending order of updated_at.
  • PATCH /menu/products/{id} changes every field, including the option groups (option_group_ids, in this order), the image and the external_id. A product's own group always comes first there and cannot be detached, only deleted; another product's own group cannot be attached (400).
  • POST /menu/option-groups creates a group with its options; POST /menu/option-groups/{id}/options an option in an existing group. Creating answers 201.
  • DELETE /menu/categories/{id} moves the products to “No category”, as in the dashboard. With ?with_products=true they are deleted as well.
# 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"

Menu events

Every change to the menu reports an event, including changes made by your own app: menu.category.*, menu.product.*, menu.option_group.* and menu.option.*, each created, updated and deleted. updated lists the changed fields in changed and their old values in previous, deleted shows the object as it was before the deletion. See Event types.

A sync with PUT /menu reports exactly one event instead of many single ones: menu.synced with mode, summary and the connection_id of the connection that synced; if it is your own, you can skip it. Fetch what changed afterwards with GET /menu/products?updated_since=… and GET /menu/options?updated_since=…. external_id is always null in events, because one envelope goes to every recipient alike.

Who has the last word

The owner can still change everything in the dashboard, including products carrying your app's id. A sync overwrites the fields it sends. A proven split: the POS is the source for its items, names and prices; the owner maintains descriptions, images and allergens in Flavoso, and the POS leaves these fields out. With the menu.* events you see what was changed in the dashboard.

Still unclear? Write to support@flavoso.com and include the id from X-Request-Id. It lets us find your request in the log.